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 "output" => concat!(
207 "**output** \n",
208 "Output directory path for generated client files. \n\n",
209 "Relative paths are resolved from the schema file location.",
210 ).to_string(),
211 "interface" => concat!(
212 "**interface** \n",
213 "Controls whether the generated client uses a synchronous or asynchronous API. \n\n",
214 "- `\"sync\"` *(default)* — blocking API; safe to call from any context. \n",
215 "- `\"async\"` — `async/await` API; requires an async runtime.",
216 ).to_string(),
217 "recursive_type_depth" => concat!(
218 "**recursive_type_depth** \n",
219 "*(Python client only)* Depth of recursive include TypedDicts generated for the Python client. \n\n",
220 "Default: `5`. \n\n",
221 "Each depth level adds a `{Model}IncludeRecursive{N}` type and the corresponding \n",
222 "`FindMany{Target}ArgsFrom{Source}Recursive{N}` typed-dict classes. \n",
223 "At the maximum depth the `include` field is omitted to prevent infinite type recursion. \n\n",
224 "Example: `recursive_type_depth = 3`",
225 ).to_string(),
226 other => format!("**{other}**"),
227 }
228}
229
230fn attribute_hover_at(tokens: &[Token], offset: usize, ast: Option<&Schema>) -> Option<HoverInfo> {
233 let n = tokens.len();
234 let mut i = 0;
235 while i < n {
236 let tok = &tokens[i];
237 let is_double = tok.kind == TokenKind::AtAt;
238 let is_single = tok.kind == TokenKind::At;
239 if !is_double && !is_single {
240 i += 1;
241 continue;
242 }
243
244 let ident_i = match (i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline)) {
245 Some(j) => j,
246 None => {
247 i += 1;
248 continue;
249 }
250 };
251 let ident_tok = &tokens[ident_i];
252 let attr_name = match &ident_tok.kind {
253 TokenKind::Ident(name) => name.clone(),
254 _ => {
255 i += 1;
256 continue;
257 }
258 };
259
260 let attr_start = tok.span.start;
261 let attr_name_end = ident_tok.span.end;
262
263 let lparen_i = (ident_i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline));
264 let full_end = if lparen_i.map(|j| &tokens[j].kind) == Some(&TokenKind::LParen) {
265 find_paren_end(tokens, lparen_i.unwrap()).unwrap_or(attr_name_end)
266 } else {
267 attr_name_end
268 };
269
270 if offset >= attr_start && offset <= full_end {
271 let content = if is_double {
272 model_attr_hover_text(&attr_name)
273 } else {
274 field_attr_hover_text(&attr_name, ast, offset)
275 };
276 return Some(HoverInfo {
277 content,
278 span: Some(Span {
279 start: attr_start,
280 end: attr_name_end,
281 }),
282 });
283 }
284 i += 1;
285 }
286 None
287}
288
289fn find_paren_end(tokens: &[Token], lparen_idx: usize) -> Option<usize> {
292 let mut depth: i32 = 0;
293 for tok in &tokens[lparen_idx..] {
294 match tok.kind {
295 TokenKind::LParen => depth += 1,
296 TokenKind::RParen => {
297 depth -= 1;
298 if depth == 0 {
299 return Some(tok.span.end);
300 }
301 }
302 _ => {}
303 }
304 }
305 None
306}
307
308fn field_attr_hover_text(name: &str, ast: Option<&Schema>, offset: usize) -> String {
309 match name {
310 "id" => "**@id** \nMarks this field as the primary key of the model.".to_string(),
311 "unique" => "**@unique** \nAdds a `UNIQUE` constraint on this column.".to_string(),
312 "default" => [
313 "**@default(expr)** ",
314 "Sets the default value for this field when not explicitly provided. \n",
315 "Common expressions: `autoincrement()`, `now()`, `uuid()`,",
316 " enum variants, or literal values.",
317 ].concat(),
318 "map" => "**@map(\"name\")** \nMaps this field to a different physical column name in the database.".to_string(),
319 "store" => [
320 "**@store(json)** \n",
321 "Stores this array field as a JSON value in the database. \n",
322 "Useful for databases without native array support (MySQL, SQLite).",
323 ].concat(),
324 "updatedAt" => [
325 "**@updatedAt** \n",
326 "Marks this `DateTime` field to be automatically set to the current timestamp ",
327 "on every CREATE and UPDATE operation. \n",
328 "The framework manages this value — it is excluded from all user-input types.",
329 ].concat(),
330 "computed" => [
331 "**@computed(expr, Stored | Virtual)** \n",
332 "Declares a database-generated (computed) column. \n\n",
333 "- `expr` — raw SQL expression evaluated by the database (e.g. `price * quantity`, ",
334 "`first_name || ' ' || last_name`) \n",
335 "- `Stored` — value is computed on write and persisted physically \n",
336 "- `Virtual` — value is computed on read (not supported on PostgreSQL) \n\n",
337 "Maps to SQL `GENERATED ALWAYS AS (expr) STORED` (PostgreSQL / MySQL) or ",
338 "`AS (expr) STORED` (SQLite). \n",
339 "Computed fields are **read-only** — they are excluded from all create/update input types.",
340 ].concat(),
341 "check" => [
342 "**@check(expr)** \n",
343 "Adds a SQL `CHECK` constraint on this column. \n\n",
344 "The boolean expression can use SQL-style operators: ",
345 "`=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`. \n\n",
346 "Field-level `@check` can only reference the decorated field itself. \n",
347 "Use `@@check` at the model level to reference multiple fields. \n\n",
348 "**Examples:** \n",
349 "``` \n",
350 "age Int @check(age >= 0 AND age <= 150) \n",
351 "status Status @check(status IN [ACTIVE, PENDING]) \n",
352 "```",
353 ].concat(),
354 "relation" => {
355 let base = concat!(
356 "**@relation** \n",
357 "Defines an explicit foreign-key relation between two models."
358 );
359 if let Some(schema) = ast {
360 if let Some(extra) = relation_hover_details(schema, offset) {
361 return format!("{base} \n\n{extra}");
362 }
363 }
364 base.to_string()
365 }
366 other => format!("**@{other}**"),
367 }
368}
369
370fn model_attr_hover_text(name: &str) -> String {
371 match name {
372 "map" => "**@@map(\"name\")** \nMaps this model to a different physical table name in the database.".to_string(),
373 "id" => "**@@id([fields])** \nDefines a composite primary key spanning multiple fields.".to_string(),
374 "unique" => "**@@unique([fields])** \nDefines a composite unique constraint spanning multiple fields.".to_string(),
375 "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(),
376 "check" => [
377 "**@@check(expr)** \n",
378 "Adds a table-level SQL `CHECK` constraint. \n\n",
379 "Unlike field-level `@check`, the expression can reference any scalar field in the model. \n\n",
380 "**Example:** \n",
381 "``` \n",
382 "@@check(start_date < end_date) \n",
383 "@@check(age > 18 OR status IN [MINOR]) \n",
384 "```",
385 ].concat(),
386 other => format!("**@@{other}**"),
387 }
388}
389
390fn relation_hover_details(ast: &Schema, offset: usize) -> Option<String> {
398 for decl in &ast.declarations {
399 if let Declaration::Model(model) = decl {
400 for field in &model.fields {
401 if !span_contains(field.span, offset) {
402 continue;
403 }
404 for attr in &field.attributes {
405 if let FieldAttribute::Relation {
406 name,
407 fields,
408 references,
409 on_delete,
410 on_update,
411 ..
412 } = attr
413 {
414 let target = field_type_name(&field.field_type);
415 let modifier_str = match field.modifier {
416 FieldModifier::Array => "[]",
417 FieldModifier::Optional => "?",
418 FieldModifier::NotNull => "!",
419 FieldModifier::None => "",
420 };
421 let relation_kind = match field.modifier {
422 FieldModifier::Array => "one-to-many",
423 _ if fields.is_some() => "one-to-many",
424 _ => "one-to-one",
425 };
426
427 let mut lines: Vec<String> = vec![format!(
428 "**Type:** `{relation_kind}` · `{}` -> `{target}{modifier_str}`",
429 model.name.value
430 )];
431
432 let has_args = name.is_some()
433 || fields.is_some()
434 || references.is_some()
435 || on_delete.is_some()
436 || on_update.is_some();
437
438 if has_args {
439 lines.push(String::new());
440 if let Some(n) = name {
441 lines.push(format!("- **name**: `\"{n}\"` "));
442 }
443 if let Some(fs) = fields {
444 let names: Vec<&str> =
445 fs.iter().map(|f| f.value.as_str()).collect();
446 lines.push(format!("- **fields**: `[{}]`", names.join(", ")));
447 }
448 if let Some(rs) = references {
449 let names: Vec<&str> =
450 rs.iter().map(|r| r.value.as_str()).collect();
451 lines.push(format!("- **references**: `[{}]`", names.join(", ")));
452 }
453 if let Some(od) = on_delete {
454 lines.push(format!("- **onDelete**: `{od}`"));
455 }
456 if let Some(ou) = on_update {
457 lines.push(format!("- **onUpdate**: `{ou}`"));
458 }
459 }
460
461 return Some(lines.join(" \n"));
462 }
463 }
464 }
465 }
466 }
467 None
468}
469
470fn format_field_attrs_short(attrs: &[FieldAttribute]) -> String {
474 attrs
475 .iter()
476 .filter_map(|attr| match attr {
477 FieldAttribute::Id => Some("@id".to_string()),
478 FieldAttribute::Unique => Some("@unique".to_string()),
479 FieldAttribute::Default(expr, _) => {
480 Some(format!("@default({})", crate::formatter::format_expr(expr)))
481 }
482 FieldAttribute::Map(name) => Some(format!("@map(\"{}\")", name)),
483 FieldAttribute::Store { .. } => Some("@store(json)".to_string()),
484 FieldAttribute::UpdatedAt { .. } => Some("@updatedAt".to_string()),
485 FieldAttribute::Computed { expr, kind, .. } => {
486 let kind_str = match kind {
487 ComputedKind::Stored => "Stored",
488 ComputedKind::Virtual => "Virtual",
489 };
490 Some(format!("@computed({}, {})", expr, kind_str))
491 }
492 FieldAttribute::Check { expr, .. } => Some(format!("@check({})", expr)),
493 FieldAttribute::Relation { .. } => None,
494 })
495 .collect::<Vec<_>>()
496 .join(" · ")
497}
498
499fn model_hover_content(
501 model: &crate::ast::ModelDecl,
502 composite_names: &std::collections::HashSet<String>,
503) -> String {
504 let table_name = model.table_name();
505 let mut lines: Vec<String> = vec![format!("**model** `{}`", model.name.value)];
506
507 if table_name != model.name.value {
508 lines.push(format!("**Table:** `{}`", table_name));
509 }
510
511 let composite_count = model
512 .fields
513 .iter()
514 .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if composite_names.contains(n)))
515 .count();
516 let relation_count = model
517 .fields
518 .iter()
519 .filter(|f| matches!(&f.field_type, FieldType::UserType(n) if !composite_names.contains(n)))
520 .count();
521 let scalar_count = model.fields.len() - composite_count - relation_count;
522 let mut count_parts = vec![format!("{} scalar", scalar_count)];
523 if relation_count > 0 {
524 count_parts.push(format!("{} relation", relation_count));
525 }
526 if composite_count > 0 {
527 count_parts.push(format!("{} composite", composite_count));
528 }
529 lines.push(format!("**Fields:** {}", count_parts.join(" · ")));
530 lines.push(String::new());
531
532 for field in &model.fields {
533 let modifier = match field.modifier {
534 FieldModifier::Array => "[]",
535 FieldModifier::Optional => "?",
536 FieldModifier::NotNull => "!",
537 FieldModifier::None => "",
538 };
539 let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
540 let attrs_str = format_field_attrs_short(&field.attributes);
541 if attrs_str.is_empty() {
542 lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
543 } else {
544 lines.push(format!(
545 "- `{}`: `{}` — {}",
546 field.name.value, type_str, attrs_str
547 ));
548 }
549 }
550
551 let extra_attrs: Vec<String> = model
552 .attributes
553 .iter()
554 .filter_map(|attr| match attr {
555 ModelAttribute::Map(_) => None,
556 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}