1use super::{analyze, span_contains};
4use crate::ast::{
5 ComputedKind, Declaration, FieldAttribute, FieldModifier, FieldType, ModelAttribute, Schema,
6};
7use crate::span::Span;
8use crate::token::{Token, TokenKind};
9
10#[derive(Debug, Clone, PartialEq)]
12pub struct HoverInfo {
13 pub content: String,
15 pub span: Option<Span>,
17}
18
19pub fn hover(source: &str, offset: usize) -> Option<HoverInfo> {
28 let result = analyze(source);
29 let ast = result.ast.as_ref()?;
30
31 if let Some(h) = attribute_hover_at(&result.tokens, offset, Some(ast)) {
33 return Some(h);
34 }
35
36 for decl in &ast.declarations {
38 if !span_contains(decl.span(), offset) {
39 continue;
40 }
41
42 match decl {
43 Declaration::Model(model) => {
44 for field in &model.fields {
46 if span_contains(field.span, offset) {
47 let modifier = match field.modifier {
48 FieldModifier::Array => "[]",
49 FieldModifier::Optional => "?",
50 FieldModifier::NotNull => "!",
51 FieldModifier::None => "",
52 };
53 let type_str =
54 format!("{}{}", field_type_name(&field.field_type), modifier);
55
56 if field.has_relation_attribute() {
57 let base = format!("**{}**: `{}`", field.name.value, type_str);
58 let extra = relation_hover_details(ast, offset).unwrap_or_default();
59 let content = if extra.is_empty() {
60 base
61 } else {
62 format!("{base} \n\n{extra}")
63 };
64 return Some(HoverInfo {
65 content,
66 span: Some(field.span),
67 });
68 }
69
70 let attrs_str = format_field_attrs_short(&field.attributes);
71 let detail = field_type_description(&field.field_type);
72 let nullability = match field.modifier {
73 FieldModifier::Optional => Some("nullable"),
74 FieldModifier::NotNull => Some("not null"),
75 _ => None,
76 };
77 let mut content = format!("**{}**: `{}`", field.name.value, type_str);
78 if !attrs_str.is_empty() {
79 content.push_str(&format!(" \n{}", attrs_str));
80 }
81 if let Some(hint) = nullability {
82 content.push_str(&format!(" \n_{}_", hint));
83 }
84 if !detail.is_empty() {
85 content.push_str(&format!(" \n{}", detail));
86 }
87 return Some(HoverInfo {
88 content,
89 span: Some(field.span),
90 });
91 }
92 }
93 let composite_names: std::collections::HashSet<String> =
95 ast.types().map(|t| t.name.value.clone()).collect();
96 return Some(HoverInfo {
97 content: model_hover_content(model, &composite_names),
98 span: Some(model.span),
99 });
100 }
101
102 Declaration::Enum(enum_decl) => {
103 let variants: Vec<&str> = enum_decl
104 .variants
105 .iter()
106 .map(|v| v.name.value.as_str())
107 .collect();
108 return Some(HoverInfo {
109 content: format!(
110 "**enum** `{}` \n**Variants ({}):** {} \n",
111 enum_decl.name.value,
112 variants.len(),
113 variants
114 .iter()
115 .map(|v| format!("`{v}`"))
116 .collect::<Vec<_>>()
117 .join(" · ")
118 ),
119 span: Some(enum_decl.span),
120 });
121 }
122
123 Declaration::Datasource(ds) => {
124 for field in &ds.fields {
125 if span_contains(field.span, offset) {
126 return Some(HoverInfo {
127 content: config_field_hover(&field.name.value),
128 span: Some(field.span),
129 });
130 }
131 }
132 return Some(HoverInfo {
133 content: format!("**datasource** `{}`", ds.name.value),
134 span: Some(ds.span),
135 });
136 }
137
138 Declaration::Generator(gen) => {
139 for field in &gen.fields {
140 if span_contains(field.span, offset) {
141 return Some(HoverInfo {
142 content: config_field_hover(&field.name.value),
143 span: Some(field.span),
144 });
145 }
146 }
147 return Some(HoverInfo {
148 content: format!("**generator** `{}`", gen.name.value),
149 span: Some(gen.span),
150 });
151 }
152
153 Declaration::Type(type_decl) => {
154 for field in &type_decl.fields {
155 if span_contains(field.span, offset) {
156 let modifier = match field.modifier {
157 FieldModifier::Array => "[]",
158 FieldModifier::Optional => "?",
159 FieldModifier::NotNull => "!",
160 FieldModifier::None => "",
161 };
162 let type_str =
163 format!("{}{}", field_type_name(&field.field_type), modifier);
164 let attrs_str = format_field_attrs_short(&field.attributes);
165 let mut content = format!("**{}**: `{}`", field.name.value, type_str);
166 if !attrs_str.is_empty() {
167 content.push_str(&format!(" \n{}", attrs_str));
168 }
169 return Some(HoverInfo {
170 content,
171 span: Some(field.span),
172 });
173 }
174 }
175 return Some(HoverInfo {
176 content: composite_type_hover_content(type_decl),
177 span: Some(type_decl.span),
178 });
179 }
180 }
181 }
182
183 None
184}
185
186pub fn config_field_hover(key: &str) -> String {
188 match key {
189 "provider" => concat!(
190 "**provider** \n",
191 "Specifies the database provider or code-generator target. \n\n",
192 "Datasource values: `\"postgresql\"`, `\"mysql\"`, `\"sqlite\"` \n",
193 "Generator values: `\"python\"`",
194 ).to_string(),
195 "url" => concat!(
196 "**url** \n",
197 "Database connection URL. \n\n",
198 "Supports the `env(\"VAR\")` helper to read from environment variables.",
199 ).to_string(),
200 "output" => concat!(
201 "**output** \n",
202 "Output directory path for generated client files. \n\n",
203 "Relative paths are resolved from the schema file location.",
204 ).to_string(),
205 "interface" => concat!(
206 "**interface** \n",
207 "Controls whether the generated client uses a synchronous or asynchronous API. \n\n",
208 "- `\"sync\"` *(default)* — blocking API; safe to call from any context. \n",
209 "- `\"async\"` — `async/await` API; requires an async runtime.",
210 ).to_string(),
211 "recursive_type_depth" => concat!(
212 "**recursive_type_depth** \n",
213 "*(Python client only)* Depth of recursive include TypedDicts generated for the Python client. \n\n",
214 "Default: `5`. \n\n",
215 "Each depth level adds a `{Model}IncludeRecursive{N}` type and the corresponding \n",
216 "`FindMany{Target}ArgsFrom{Source}Recursive{N}` typed-dict classes. \n",
217 "At the maximum depth the `include` field is omitted to prevent infinite type recursion. \n\n",
218 "Example: `recursive_type_depth = 3`",
219 ).to_string(),
220 other => format!("**{other}**"),
221 }
222}
223
224fn attribute_hover_at(tokens: &[Token], offset: usize, ast: Option<&Schema>) -> Option<HoverInfo> {
227 let n = tokens.len();
228 let mut i = 0;
229 while i < n {
230 let tok = &tokens[i];
231 let is_double = tok.kind == TokenKind::AtAt;
232 let is_single = tok.kind == TokenKind::At;
233 if !is_double && !is_single {
234 i += 1;
235 continue;
236 }
237
238 let ident_i = match (i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline)) {
240 Some(j) => j,
241 None => {
242 i += 1;
243 continue;
244 }
245 };
246 let ident_tok = &tokens[ident_i];
247 let attr_name = match &ident_tok.kind {
248 TokenKind::Ident(name) => name.clone(),
249 _ => {
250 i += 1;
251 continue;
252 }
253 };
254
255 let attr_start = tok.span.start;
256 let attr_name_end = ident_tok.span.end;
257
258 let lparen_i = (ident_i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline));
260 let full_end = if lparen_i.map(|j| &tokens[j].kind) == Some(&TokenKind::LParen) {
261 find_paren_end(tokens, lparen_i.unwrap()).unwrap_or(attr_name_end)
262 } else {
263 attr_name_end
264 };
265
266 if offset >= attr_start && offset <= full_end {
267 let content = if is_double {
268 model_attr_hover_text(&attr_name)
269 } else {
270 field_attr_hover_text(&attr_name, ast, offset)
271 };
272 return Some(HoverInfo {
273 content,
274 span: Some(Span {
275 start: attr_start,
276 end: attr_name_end,
277 }),
278 });
279 }
280 i += 1;
281 }
282 None
283}
284
285fn find_paren_end(tokens: &[Token], lparen_idx: usize) -> Option<usize> {
288 let mut depth: i32 = 0;
289 for tok in &tokens[lparen_idx..] {
290 match tok.kind {
291 TokenKind::LParen => depth += 1,
292 TokenKind::RParen => {
293 depth -= 1;
294 if depth == 0 {
295 return Some(tok.span.end);
296 }
297 }
298 _ => {}
299 }
300 }
301 None
302}
303
304fn field_attr_hover_text(name: &str, ast: Option<&Schema>, offset: usize) -> String {
305 match name {
306 "id" => "**@id** \nMarks this field as the primary key of the model.".to_string(),
307 "unique" => "**@unique** \nAdds a `UNIQUE` constraint on this column.".to_string(),
308 "default" => [
309 "**@default(expr)** ",
310 "Sets the default value for this field when not explicitly provided. \n",
311 "Common expressions: `autoincrement()`, `now()`, `uuid()`, `cuid()`,",
312 " `dbgenerated(...)`, or any literal value.",
313 ].concat(),
314 "map" => "**@map(\"name\")** \nMaps this field to a different physical column name in the database.".to_string(),
315 "store" => [
316 "**@store(json)** \n",
317 "Stores this array field as a JSON value in the database. \n",
318 "Useful for databases without native array support (MySQL, SQLite).",
319 ].concat(),
320 "updatedAt" => [
321 "**@updatedAt** \n",
322 "Marks this `DateTime` field to be automatically set to the current timestamp ",
323 "on every CREATE and UPDATE operation. \n",
324 "The framework manages this value — it is excluded from all user-input types.",
325 ].concat(),
326 "computed" => [
327 "**@computed(expr, Stored | Virtual)** \n",
328 "Declares a database-generated (computed) column. \n\n",
329 "- `expr` — raw SQL expression evaluated by the database (e.g. `price * quantity`, ",
330 "`first_name || ' ' || last_name`) \n",
331 "- `Stored` — value is computed on write and persisted physically \n",
332 "- `Virtual` — value is computed on read (not supported on PostgreSQL) \n\n",
333 "Maps to SQL `GENERATED ALWAYS AS (expr) STORED` (PostgreSQL / MySQL) or ",
334 "`AS (expr) STORED` (SQLite). \n",
335 "Computed fields are **read-only** — they are excluded from all create/update input types.",
336 ].concat(),
337 "check" => [
338 "**@check(expr)** \n",
339 "Adds a SQL `CHECK` constraint on this column. \n\n",
340 "The boolean expression can use SQL-style operators: ",
341 "`=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`. \n\n",
342 "Field-level `@check` can only reference the decorated field itself. \n",
343 "Use `@@check` at the model level to reference multiple fields. \n\n",
344 "**Examples:** \n",
345 "``` \n",
346 "age Int @check(age >= 0 AND age <= 150) \n",
347 "status Status @check(status IN [ACTIVE, PENDING]) \n",
348 "```",
349 ].concat(),
350 "relation" => {
351 let base = concat!(
352 "**@relation** \n",
353 "Defines an explicit foreign-key relation between two models."
354 );
355 if let Some(schema) = ast {
356 if let Some(extra) = relation_hover_details(schema, offset) {
357 return format!("{base} \n\n{extra}");
358 }
359 }
360 base.to_string()
361 }
362 other => format!("**@{other}**"),
363 }
364}
365
366fn model_attr_hover_text(name: &str) -> String {
367 match name {
368 "map" => "**@@map(\"name\")** \nMaps this model to a different physical table name in the database.".to_string(),
369 "id" => "**@@id([fields])** \nDefines a composite primary key spanning multiple fields.".to_string(),
370 "unique" => "**@@unique([fields])** \nDefines a composite unique constraint spanning multiple fields.".to_string(),
371 "index" => "**@@index([fields], type?, name?, map?)** \nCreates a database index on the listed fields. \n\nOptional arguments: \n- `type:` — index access method: `BTree` (default, all DBs), `Hash` (PG/MySQL), `Gin` / `Gist` / `Brin` (PostgreSQL only), `FullText` (MySQL only) \n- `name:` — logical developer name (ignored in DDL) \n- `map:` — physical DDL index name override \n\n**Examples:** \n``` \n@@index([email]) \n@@index([email], type: Hash) \n@@index([content], type: Gin) \n@@index([createdAt], type: Brin, map: \"idx_created\") \n```".to_string(),
372 "check" => [
373 "**@@check(expr)** \n",
374 "Adds a table-level SQL `CHECK` constraint. \n\n",
375 "Unlike field-level `@check`, the expression can reference any scalar field in the model. \n\n",
376 "**Example:** \n",
377 "``` \n",
378 "@@check(start_date < end_date) \n",
379 "@@check(age > 18 OR status IN [MINOR]) \n",
380 "```",
381 ].concat(),
382 other => format!("**@@{other}**"),
383 }
384}
385
386fn relation_hover_details(ast: &Schema, offset: usize) -> Option<String> {
394 for decl in &ast.declarations {
395 if let Declaration::Model(model) = decl {
396 for field in &model.fields {
397 if !span_contains(field.span, offset) {
398 continue;
399 }
400 for attr in &field.attributes {
401 if let FieldAttribute::Relation {
402 name,
403 fields,
404 references,
405 on_delete,
406 on_update,
407 ..
408 } = attr
409 {
410 let target = field_type_name(&field.field_type);
411 let modifier_str = match field.modifier {
412 FieldModifier::Array => "[]",
413 FieldModifier::Optional => "?",
414 FieldModifier::NotNull => "!",
415 FieldModifier::None => "",
416 };
417 let relation_kind = match field.modifier {
418 FieldModifier::Array => "one-to-many",
419 _ if fields.is_some() => "one-to-many",
420 _ => "one-to-one",
421 };
422
423 let mut lines: Vec<String> = vec![format!(
424 "**Type:** `{relation_kind}` · `{}` → `{target}{modifier_str}`",
425 model.name.value
426 )];
427
428 let has_args = name.is_some()
430 || fields.is_some()
431 || references.is_some()
432 || on_delete.is_some()
433 || on_update.is_some();
434
435 if has_args {
436 lines.push(String::new());
437 if let Some(n) = name {
438 lines.push(format!("- **name**: `\"{n}\"` "));
439 }
440 if let Some(fs) = fields {
441 let names: Vec<&str> =
442 fs.iter().map(|f| f.value.as_str()).collect();
443 lines.push(format!("- **fields**: `[{}]`", names.join(", ")));
444 }
445 if let Some(rs) = references {
446 let names: Vec<&str> =
447 rs.iter().map(|r| r.value.as_str()).collect();
448 lines.push(format!("- **references**: `[{}]`", names.join(", ")));
449 }
450 if let Some(od) = on_delete {
451 lines.push(format!("- **onDelete**: `{od}`"));
452 }
453 if let Some(ou) = on_update {
454 lines.push(format!("- **onUpdate**: `{ou}`"));
455 }
456 }
457
458 return Some(lines.join(" \n"));
459 }
460 }
461 }
462 }
463 }
464 None
465}
466
467fn format_field_attrs_short(attrs: &[FieldAttribute]) -> String {
471 attrs
472 .iter()
473 .filter_map(|attr| match attr {
474 FieldAttribute::Id => Some("@id".to_string()),
475 FieldAttribute::Unique => Some("@unique".to_string()),
476 FieldAttribute::Default(expr, _) => {
477 Some(format!("@default({})", crate::formatter::format_expr(expr)))
478 }
479 FieldAttribute::Map(name) => Some(format!("@map(\"{}\")", name)),
480 FieldAttribute::Store { .. } => Some("@store(json)".to_string()),
481 FieldAttribute::UpdatedAt { .. } => Some("@updatedAt".to_string()),
482 FieldAttribute::Computed { expr, kind, .. } => {
483 let kind_str = match kind {
484 ComputedKind::Stored => "Stored",
485 ComputedKind::Virtual => "Virtual",
486 };
487 Some(format!("@computed({}, {})", expr, kind_str))
488 }
489 FieldAttribute::Check { expr, .. } => Some(format!("@check({})", expr)),
490 FieldAttribute::Relation { .. } => None,
491 })
492 .collect::<Vec<_>>()
493 .join(" · ")
494}
495
496fn model_hover_content(
498 model: &crate::ast::ModelDecl,
499 composite_names: &std::collections::HashSet<String>,
500) -> String {
501 let table_name = model.table_name();
502 let mut lines: Vec<String> = vec![format!("**model** `{}`", model.name.value)];
503
504 if table_name != model.name.value {
506 lines.push(format!("**Table:** `{}`", table_name));
507 }
508
509 let composite_count = model
510 .fields
511 .iter()
512 .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if composite_names.contains(n)))
513 .count();
514 let relation_count = model
515 .fields
516 .iter()
517 .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if !composite_names.contains(n)))
518 .count();
519 let scalar_count = model.fields.len() - composite_count - relation_count;
520 let mut count_parts = vec![format!("{} scalar", scalar_count)];
521 if relation_count > 0 {
522 count_parts.push(format!("{} relation", relation_count));
523 }
524 if composite_count > 0 {
525 count_parts.push(format!("{} composite", composite_count));
526 }
527 lines.push(format!("**Fields:** {}", count_parts.join(" · ")));
528 lines.push(String::new());
529
530 for field in &model.fields {
532 let modifier = match field.modifier {
533 FieldModifier::Array => "[]",
534 FieldModifier::Optional => "?",
535 FieldModifier::NotNull => "!",
536 FieldModifier::None => "",
537 };
538 let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
539 let attrs_str = format_field_attrs_short(&field.attributes);
540 if attrs_str.is_empty() {
541 lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
542 } else {
543 lines.push(format!(
544 "- `{}`: `{}` — {}",
545 field.name.value, type_str, attrs_str
546 ));
547 }
548 }
549
550 let extra_attrs: Vec<String> = model
552 .attributes
553 .iter()
554 .filter_map(|attr| match attr {
555 ModelAttribute::Map(_) => None, ModelAttribute::Id(fields) => {
557 let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
558 Some(format!("_@@id([{}])_", fs.join(", ")))
559 }
560 ModelAttribute::Unique(fields) => {
561 let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
562 Some(format!("_@@unique([{}])_", fs.join(", ")))
563 }
564 ModelAttribute::Index {
565 fields,
566 index_type,
567 name,
568 map,
569 } => {
570 let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
571 let mut parts = vec![format!("[{}]", fs.join(", "))];
572 if let Some(t) = index_type {
573 parts.push(format!("type: {}", t.value));
574 }
575 if let Some(n) = name {
576 parts.push(format!("name: \"{}\"", n));
577 }
578 if let Some(m) = map {
579 parts.push(format!("map: \"{}\"", m));
580 }
581 Some(format!("_@@index({})_", parts.join(", ")))
582 }
583 ModelAttribute::Check { expr, .. } => Some(format!("_@@check({})_", expr)),
584 })
585 .collect();
586
587 if !extra_attrs.is_empty() {
588 lines.push(String::new());
589 lines.extend(extra_attrs);
590 }
591
592 lines.join(" \n")
593}
594
595fn composite_type_hover_content(type_decl: &crate::ast::TypeDecl) -> String {
597 let mut lines: Vec<String> = vec![format!("**type** `{}`", type_decl.name.value)];
598 lines.push(format!("**Fields:** {}", type_decl.fields.len()));
599 lines.push(String::new());
600
601 for field in &type_decl.fields {
602 let modifier = match field.modifier {
603 FieldModifier::Array => "[]",
604 FieldModifier::Optional => "?",
605 FieldModifier::NotNull => "!",
606 FieldModifier::None => "",
607 };
608 let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
609 let attrs_str = format_field_attrs_short(&field.attributes);
610 if attrs_str.is_empty() {
611 lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
612 } else {
613 lines.push(format!(
614 "- `{}`: `{}` — {}",
615 field.name.value, type_str, attrs_str
616 ));
617 }
618 }
619
620 lines.join(" \n")
621}
622
623fn field_type_name(ft: &FieldType) -> String {
624 match ft {
625 FieldType::String => "String".to_string(),
626 FieldType::Boolean => "Boolean".to_string(),
627 FieldType::Int => "Int".to_string(),
628 FieldType::BigInt => "BigInt".to_string(),
629 FieldType::Float => "Float".to_string(),
630 FieldType::Decimal { precision, scale } => format!("Decimal({}, {})", precision, scale),
631 FieldType::DateTime => "DateTime".to_string(),
632 FieldType::Bytes => "Bytes".to_string(),
633 FieldType::Json => "Json".to_string(),
634 FieldType::Uuid => "Uuid".to_string(),
635 FieldType::Jsonb => "Jsonb".to_string(),
636 FieldType::Xml => "Xml".to_string(),
637 FieldType::Char { length } => format!("Char({})", length),
638 FieldType::VarChar { length } => format!("VarChar({})", length),
639 FieldType::UserType(name) => name.clone(),
640 }
641}
642
643fn field_type_description(ft: &FieldType) -> &'static str {
644 match ft {
645 FieldType::String => "UTF-8 text string. Maps to `VARCHAR` / `TEXT` in SQL.",
646 FieldType::Boolean => "Boolean value (`true` / `false`). Maps to `BOOLEAN`.",
647 FieldType::Int => "32-bit signed integer. Maps to `INTEGER`.",
648 FieldType::BigInt => "64-bit signed integer. Maps to `BIGINT`.",
649 FieldType::Float => "64-bit IEEE 754 float. Maps to `DOUBLE PRECISION`.",
650 FieldType::Decimal { .. } => "Exact-precision decimal number. Maps to `NUMERIC(p, s)`.",
651 FieldType::DateTime => "Date and time with timezone. Maps to `TIMESTAMPTZ`.",
652 FieldType::Bytes => "Raw binary data. Maps to `BYTEA` / `BLOB`.",
653 FieldType::Json => "JSON document. Maps to `JSONB` (Postgres) or `JSON` (MySQL/SQLite).",
654 FieldType::Uuid => "Universally unique identifier. Maps to `UUID`.",
655 FieldType::Jsonb => "JSONB document (PostgreSQL only). Maps to `JSONB`.",
656 FieldType::Xml => "XML document (PostgreSQL only). Maps to `XML`.",
657 FieldType::Char { .. } => {
658 "Fixed-length character column. Maps to `CHAR(n)` (PostgreSQL and MySQL)."
659 }
660 FieldType::VarChar { .. } => {
661 "Variable-length character column. Maps to `VARCHAR(n)` (PostgreSQL and MySQL)."
662 }
663 FieldType::UserType(_) => "Reference to another model or enum.",
664 }
665}