Skip to main content

nautilus_schema/analysis/
hover.rs

1//! Hover documentation for `.nautilus` schema files.
2
3use 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/// Information to display when hovering over a token.
11#[derive(Debug, Clone, PartialEq)]
12pub struct HoverInfo {
13    /// Markdown-formatted documentation string.
14    pub content: String,
15    /// Span of the token the hover applies to (for range highlighting).
16    pub span: Option<Span>,
17}
18
19/// Returns hover documentation for the symbol at byte `offset` in `source`.
20///
21/// Looks up the innermost AST node whose span contains `offset` and returns
22/// relevant documentation:
23/// - Scalar types -> SQL mapping and description.
24/// - Identifiers matching a model name -> model summary.
25/// - Identifiers matching an enum name -> enum variant list.
26/// - Field declarations -> field type and modifiers.
27pub fn hover(source: &str, offset: usize) -> Option<HoverInfo> {
28    let result = analyze(source);
29    hover_with_analysis(source, &result, offset)
30}
31
32/// Returns hover documentation for the symbol at `offset` using a previously
33/// computed [`AnalysisResult`].
34pub 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
192/// Hover documentation for datasource/generator config fields.
193pub 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
236/// Returns hover info if `offset` falls on a `@attr` or `@@attr` token
237/// (including its parenthesised argument list, if any).
238fn 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
295/// Walk tokens from the `(` at `lparen_idx` and return the byte-end of the
296/// matching `)`.
297fn 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
396/// Extracts a rich Markdown summary of the `@relation(...)` attribute on the
397/// field whose span contains `offset`.
398///
399/// Shows:
400/// - Inferred relation type (one-to-many / one-to-one)
401/// - `ParentModel -> TargetType` arrow
402/// - All explicit arguments: name, fields, references, onDelete, onUpdate
403fn 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
476/// Formats field-level attributes as an inline string, e.g.
477/// `@id · @default(uuid()) · @map("user_id")`.
478/// `@relation` is omitted — it has its own dedicated hover.
479fn 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
505/// Builds the full Markdown hover content for a `model` declaration.
506fn 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
601/// Builds the full Markdown hover content for a `type` declaration.
602fn 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}