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