//! Hover documentation for `.nautilus` schema files.
use super::{analyze, span_contains, AnalysisResult};
use crate::ast::{
ComputedKind, Declaration, FieldAttribute, FieldModifier, FieldType, ModelAttribute, Schema,
};
use crate::span::Span;
use crate::token::{Token, TokenKind};
/// Information to display when hovering over a token.
#[derive(Debug, Clone, PartialEq)]
pub struct HoverInfo {
/// Markdown-formatted documentation string.
pub content: String,
/// Span of the token the hover applies to (for range highlighting).
pub span: Option<Span>,
}
/// Returns hover documentation for the symbol at byte `offset` in `source`.
///
/// Looks up the innermost AST node whose span contains `offset` and returns
/// relevant documentation:
/// - Scalar types -> SQL mapping and description.
/// - Identifiers matching a model name -> model summary.
/// - Identifiers matching an enum name -> enum variant list.
/// - Field declarations -> field type and modifiers.
pub fn hover(source: &str, offset: usize) -> Option<HoverInfo> {
let result = analyze(source);
hover_with_analysis(source, &result, offset)
}
/// Returns hover documentation for the symbol at `offset` using a previously
/// computed [`AnalysisResult`].
pub fn hover_with_analysis(
_source: &str,
result: &AnalysisResult,
offset: usize,
) -> Option<HoverInfo> {
let ast = result.ast.as_ref()?;
if let Some(h) = attribute_hover_at(&result.tokens, offset, Some(ast)) {
return Some(h);
}
for decl in &ast.declarations {
if !span_contains(decl.span(), offset) {
continue;
}
match decl {
Declaration::Model(model) => {
for field in &model.fields {
if span_contains(field.span, offset) {
let modifier = match field.modifier {
FieldModifier::Array => "[]",
FieldModifier::Optional => "?",
FieldModifier::NotNull => "!",
FieldModifier::None => "",
};
let type_str =
format!("{}{}", field_type_name(&field.field_type), modifier);
if field.has_relation_attribute() {
let base = format!("**{}**: `{}`", field.name.value, type_str);
let extra = relation_hover_details(ast, offset).unwrap_or_default();
let content = if extra.is_empty() {
base
} else {
format!("{base} \n\n{extra}")
};
return Some(HoverInfo {
content,
span: Some(field.span),
});
}
let attrs_str = format_field_attrs_short(&field.attributes);
let detail = field_type_description(&field.field_type);
let nullability = match field.modifier {
FieldModifier::Optional => Some("nullable"),
FieldModifier::NotNull => Some("not null"),
_ => None,
};
let mut content = format!("**{}**: `{}`", field.name.value, type_str);
if !attrs_str.is_empty() {
content.push_str(&format!(" \n{}", attrs_str));
}
if let Some(hint) = nullability {
content.push_str(&format!(" \n_{}_", hint));
}
if !detail.is_empty() {
content.push_str(&format!(" \n{}", detail));
}
return Some(HoverInfo {
content,
span: Some(field.span),
});
}
}
let composite_names: std::collections::HashSet<String> =
ast.types().map(|t| t.name.value.clone()).collect();
return Some(HoverInfo {
content: model_hover_content(model, &composite_names),
span: Some(model.span),
});
}
Declaration::Enum(enum_decl) => {
let variants: Vec<&str> = enum_decl
.variants
.iter()
.map(|v| v.name.value.as_str())
.collect();
return Some(HoverInfo {
content: format!(
"**enum** `{}` \n**Variants ({}):** {} \n",
enum_decl.name.value,
variants.len(),
variants
.iter()
.map(|v| format!("`{v}`"))
.collect::<Vec<_>>()
.join(" · ")
),
span: Some(enum_decl.span),
});
}
Declaration::Datasource(ds) => {
for field in &ds.fields {
if span_contains(field.span, offset) {
return Some(HoverInfo {
content: config_field_hover(&field.name.value),
span: Some(field.span),
});
}
}
return Some(HoverInfo {
content: format!("**datasource** `{}`", ds.name.value),
span: Some(ds.span),
});
}
Declaration::Generator(gen) => {
for field in &gen.fields {
if span_contains(field.span, offset) {
return Some(HoverInfo {
content: config_field_hover(&field.name.value),
span: Some(field.span),
});
}
}
return Some(HoverInfo {
content: format!("**generator** `{}`", gen.name.value),
span: Some(gen.span),
});
}
Declaration::Type(type_decl) => {
for field in &type_decl.fields {
if span_contains(field.span, offset) {
let modifier = match field.modifier {
FieldModifier::Array => "[]",
FieldModifier::Optional => "?",
FieldModifier::NotNull => "!",
FieldModifier::None => "",
};
let type_str =
format!("{}{}", field_type_name(&field.field_type), modifier);
let attrs_str = format_field_attrs_short(&field.attributes);
let mut content = format!("**{}**: `{}`", field.name.value, type_str);
if !attrs_str.is_empty() {
content.push_str(&format!(" \n{}", attrs_str));
}
return Some(HoverInfo {
content,
span: Some(field.span),
});
}
}
return Some(HoverInfo {
content: composite_type_hover_content(type_decl),
span: Some(type_decl.span),
});
}
}
}
None
}
/// Hover documentation for datasource/generator config fields.
pub fn config_field_hover(key: &str) -> String {
match key {
"provider" => concat!(
"**provider** \n",
"Specifies the database provider or code-generator target. \n\n",
"Datasource values: `\"postgresql\"`, `\"mysql\"`, `\"sqlite\"` \n",
"Generator values: `\"nautilus-client-rs\"`, `\"nautilus-client-py\"`, `\"nautilus-client-js\"`",
).to_string(),
"url" => concat!(
"**url** \n",
"Database connection URL. \n\n",
"Supports the `env(\"VAR\")` helper to read from environment variables.",
).to_string(),
"output" => concat!(
"**output** \n",
"Output directory path for generated client files. \n\n",
"Relative paths are resolved from the schema file location.",
).to_string(),
"interface" => concat!(
"**interface** \n",
"Controls whether the generated client uses a synchronous or asynchronous API. \n\n",
"- `\"sync\"` *(default)* — blocking API; safe to call from any context. \n",
"- `\"async\"` — `async/await` API; requires an async runtime.",
).to_string(),
"recursive_type_depth" => concat!(
"**recursive_type_depth** \n",
"*(Python client only)* Depth of recursive include TypedDicts generated for the Python client. \n\n",
"Default: `5`. \n\n",
"Each depth level adds a `{Model}IncludeRecursive{N}` type and the corresponding \n",
"`FindMany{Target}ArgsFrom{Source}Recursive{N}` typed-dict classes. \n",
"At the maximum depth the `include` field is omitted to prevent infinite type recursion. \n\n",
"Example: `recursive_type_depth = 3`",
).to_string(),
other => format!("**{other}**"),
}
}
/// Returns hover info if `offset` falls on a `@attr` or `@@attr` token
/// (including its parenthesised argument list, if any).
fn attribute_hover_at(tokens: &[Token], offset: usize, ast: Option<&Schema>) -> Option<HoverInfo> {
let n = tokens.len();
let mut i = 0;
while i < n {
let tok = &tokens[i];
let is_double = tok.kind == TokenKind::AtAt;
let is_single = tok.kind == TokenKind::At;
if !is_double && !is_single {
i += 1;
continue;
}
let ident_i = match (i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline)) {
Some(j) => j,
None => {
i += 1;
continue;
}
};
let ident_tok = &tokens[ident_i];
let attr_name = match &ident_tok.kind {
TokenKind::Ident(name) => name.clone(),
_ => {
i += 1;
continue;
}
};
let attr_start = tok.span.start;
let attr_name_end = ident_tok.span.end;
let lparen_i = (ident_i + 1..n).find(|&j| !matches!(tokens[j].kind, TokenKind::Newline));
let full_end = if lparen_i.map(|j| &tokens[j].kind) == Some(&TokenKind::LParen) {
find_paren_end(tokens, lparen_i.unwrap()).unwrap_or(attr_name_end)
} else {
attr_name_end
};
if offset >= attr_start && offset <= full_end {
let content = if is_double {
model_attr_hover_text(&attr_name)
} else {
field_attr_hover_text(&attr_name, ast, offset)
};
return Some(HoverInfo {
content,
span: Some(Span {
start: attr_start,
end: attr_name_end,
}),
});
}
i += 1;
}
None
}
/// Walk tokens from the `(` at `lparen_idx` and return the byte-end of the
/// matching `)`.
fn find_paren_end(tokens: &[Token], lparen_idx: usize) -> Option<usize> {
let mut depth: i32 = 0;
for tok in &tokens[lparen_idx..] {
match tok.kind {
TokenKind::LParen => depth += 1,
TokenKind::RParen => {
depth -= 1;
if depth == 0 {
return Some(tok.span.end);
}
}
_ => {}
}
}
None
}
fn field_attr_hover_text(name: &str, ast: Option<&Schema>, offset: usize) -> String {
match name {
"id" => "**@id** \nMarks this field as the primary key of the model.".to_string(),
"unique" => "**@unique** \nAdds a `UNIQUE` constraint on this column.".to_string(),
"default" => [
"**@default(expr)** ",
"Sets the default value for this field when not explicitly provided. \n",
"Common expressions: `autoincrement()`, `now()`, `uuid()`,",
" enum variants, or literal values.",
].concat(),
"map" => "**@map(\"name\")** \nMaps this field to a different physical column name in the database.".to_string(),
"store" => [
"**@store(json)** \n",
"Stores this array field as a JSON value in the database. \n",
"Useful for databases without native array support (MySQL, SQLite).",
].concat(),
"updatedAt" => [
"**@updatedAt** \n",
"Marks this `DateTime` field to be automatically set to the current timestamp ",
"on every CREATE and UPDATE operation. \n",
"The framework manages this value — it is excluded from all user-input types.",
].concat(),
"computed" => [
"**@computed(expr, Stored | Virtual)** \n",
"Declares a database-generated (computed) column. \n\n",
"- `expr` — raw SQL expression evaluated by the database (e.g. `price * quantity`, ",
"`first_name || ' ' || last_name`) \n",
"- `Stored` — value is computed on write and persisted physically \n",
"- `Virtual` — value is computed on read (not supported on PostgreSQL) \n\n",
"Maps to SQL `GENERATED ALWAYS AS (expr) STORED` (PostgreSQL / MySQL) or ",
"`AS (expr) STORED` (SQLite). \n",
"Computed fields are **read-only** — they are excluded from all create/update input types.",
].concat(),
"check" => [
"**@check(expr)** \n",
"Adds a SQL `CHECK` constraint on this column. \n\n",
"The boolean expression can use SQL-style operators: ",
"`=`, `!=`, `<`, `>`, `<=`, `>=`, `AND`, `OR`, `NOT`, `IN`. \n\n",
"Field-level `@check` can only reference the decorated field itself. \n",
"Use `@@check` at the model level to reference multiple fields. \n\n",
"**Examples:** \n",
"``` \n",
"age Int @check(age >= 0 AND age <= 150) \n",
"status Status @check(status IN [ACTIVE, PENDING]) \n",
"```",
].concat(),
"relation" => {
let base = concat!(
"**@relation** \n",
"Defines an explicit foreign-key relation between two models."
);
if let Some(schema) = ast {
if let Some(extra) = relation_hover_details(schema, offset) {
return format!("{base} \n\n{extra}");
}
}
base.to_string()
}
other => format!("**@{other}**"),
}
}
fn model_attr_hover_text(name: &str) -> String {
match name {
"map" => "**@@map(\"name\")** \nMaps this model to a different physical table name in the database.".to_string(),
"id" => "**@@id([fields])** \nDefines a composite primary key spanning multiple fields.".to_string(),
"unique" => "**@@unique([fields])** \nDefines a composite unique constraint spanning multiple fields.".to_string(),
"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(),
"check" => [
"**@@check(expr)** \n",
"Adds a table-level SQL `CHECK` constraint. \n\n",
"Unlike field-level `@check`, the expression can reference any scalar field in the model. \n\n",
"**Example:** \n",
"``` \n",
"@@check(start_date < end_date) \n",
"@@check(age > 18 OR status IN [MINOR]) \n",
"```",
].concat(),
other => format!("**@@{other}**"),
}
}
/// Extracts a rich Markdown summary of the `@relation(...)` attribute on the
/// field whose span contains `offset`.
///
/// Shows:
/// - Inferred relation type (one-to-many / one-to-one)
/// - `ParentModel -> TargetType` arrow
/// - All explicit arguments: name, fields, references, onDelete, onUpdate
fn relation_hover_details(ast: &Schema, offset: usize) -> Option<String> {
for decl in &ast.declarations {
if let Declaration::Model(model) = decl {
for field in &model.fields {
if !span_contains(field.span, offset) {
continue;
}
for attr in &field.attributes {
if let FieldAttribute::Relation {
name,
fields,
references,
on_delete,
on_update,
..
} = attr
{
let target = field_type_name(&field.field_type);
let modifier_str = match field.modifier {
FieldModifier::Array => "[]",
FieldModifier::Optional => "?",
FieldModifier::NotNull => "!",
FieldModifier::None => "",
};
let relation_kind = match field.modifier {
FieldModifier::Array => "one-to-many",
_ if fields.is_some() => "one-to-many",
_ => "one-to-one",
};
let mut lines: Vec<String> = vec![format!(
"**Type:** `{relation_kind}` · `{}` -> `{target}{modifier_str}`",
model.name.value
)];
let has_args = name.is_some()
|| fields.is_some()
|| references.is_some()
|| on_delete.is_some()
|| on_update.is_some();
if has_args {
lines.push(String::new());
if let Some(n) = name {
lines.push(format!("- **name**: `\"{n}\"` "));
}
if let Some(fs) = fields {
let names: Vec<&str> =
fs.iter().map(|f| f.value.as_str()).collect();
lines.push(format!("- **fields**: `[{}]`", names.join(", ")));
}
if let Some(rs) = references {
let names: Vec<&str> =
rs.iter().map(|r| r.value.as_str()).collect();
lines.push(format!("- **references**: `[{}]`", names.join(", ")));
}
if let Some(od) = on_delete {
lines.push(format!("- **onDelete**: `{od}`"));
}
if let Some(ou) = on_update {
lines.push(format!("- **onUpdate**: `{ou}`"));
}
}
return Some(lines.join(" \n"));
}
}
}
}
}
None
}
/// Formats field-level attributes as an inline string, e.g.
/// `@id · @default(uuid()) · @map("user_id")`.
/// `@relation` is omitted — it has its own dedicated hover.
fn format_field_attrs_short(attrs: &[FieldAttribute]) -> String {
attrs
.iter()
.filter_map(|attr| match attr {
FieldAttribute::Id => Some("@id".to_string()),
FieldAttribute::Unique => Some("@unique".to_string()),
FieldAttribute::Default(expr, _) => {
Some(format!("@default({})", crate::formatter::format_expr(expr)))
}
FieldAttribute::Map(name) => Some(format!("@map(\"{}\")", name)),
FieldAttribute::Store { .. } => Some("@store(json)".to_string()),
FieldAttribute::UpdatedAt { .. } => Some("@updatedAt".to_string()),
FieldAttribute::Computed { expr, kind, .. } => {
let kind_str = match kind {
ComputedKind::Stored => "Stored",
ComputedKind::Virtual => "Virtual",
};
Some(format!("@computed({}, {})", expr, kind_str))
}
FieldAttribute::Check { expr, .. } => Some(format!("@check({})", expr)),
FieldAttribute::Relation { .. } => None,
})
.collect::<Vec<_>>()
.join(" · ")
}
/// Builds the full Markdown hover content for a `model` declaration.
fn model_hover_content(
model: &crate::ast::ModelDecl,
composite_names: &std::collections::HashSet<String>,
) -> String {
let table_name = model.table_name();
let mut lines: Vec<String> = vec![format!("**model** `{}`", model.name.value)];
if table_name != model.name.value {
lines.push(format!("**Table:** `{}`", table_name));
}
let composite_count = model
.fields
.iter()
.filter(|f| matches!(&f.field_type, FieldType::UserType(n) if composite_names.contains(n)))
.count();
let relation_count = model
.fields
.iter()
.filter(|f| matches!(&f.field_type, FieldType::UserType(n) if !composite_names.contains(n)))
.count();
let scalar_count = model.fields.len() - composite_count - relation_count;
let mut count_parts = vec![format!("{} scalar", scalar_count)];
if relation_count > 0 {
count_parts.push(format!("{} relation", relation_count));
}
if composite_count > 0 {
count_parts.push(format!("{} composite", composite_count));
}
lines.push(format!("**Fields:** {}", count_parts.join(" · ")));
lines.push(String::new());
for field in &model.fields {
let modifier = match field.modifier {
FieldModifier::Array => "[]",
FieldModifier::Optional => "?",
FieldModifier::NotNull => "!",
FieldModifier::None => "",
};
let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
let attrs_str = format_field_attrs_short(&field.attributes);
if attrs_str.is_empty() {
lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
} else {
lines.push(format!(
"- `{}`: `{}` — {}",
field.name.value, type_str, attrs_str
));
}
}
let extra_attrs: Vec<String> = model
.attributes
.iter()
.filter_map(|attr| match attr {
ModelAttribute::Map(_) => None,
ModelAttribute::Id(fields) => {
let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
Some(format!("_@@id([{}])_", fs.join(", ")))
}
ModelAttribute::Unique(fields) => {
let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
Some(format!("_@@unique([{}])_", fs.join(", ")))
}
ModelAttribute::Index {
fields,
index_type,
name,
map,
} => {
let fs: Vec<&str> = fields.iter().map(|f| f.value.as_str()).collect();
let mut parts = vec![format!("[{}]", fs.join(", "))];
if let Some(t) = index_type {
parts.push(format!("type: {}", t.value));
}
if let Some(n) = name {
parts.push(format!("name: \"{}\"", n));
}
if let Some(m) = map {
parts.push(format!("map: \"{}\"", m));
}
Some(format!("_@@index({})_", parts.join(", ")))
}
ModelAttribute::Check { expr, .. } => Some(format!("_@@check({})_", expr)),
})
.collect();
if !extra_attrs.is_empty() {
lines.push(String::new());
lines.extend(extra_attrs);
}
lines.join(" \n")
}
/// Builds the full Markdown hover content for a `type` declaration.
fn composite_type_hover_content(type_decl: &crate::ast::TypeDecl) -> String {
let mut lines: Vec<String> = vec![format!("**type** `{}`", type_decl.name.value)];
lines.push(format!("**Fields:** {}", type_decl.fields.len()));
lines.push(String::new());
for field in &type_decl.fields {
let modifier = match field.modifier {
FieldModifier::Array => "[]",
FieldModifier::Optional => "?",
FieldModifier::NotNull => "!",
FieldModifier::None => "",
};
let type_str = format!("{}{}", field_type_name(&field.field_type), modifier);
let attrs_str = format_field_attrs_short(&field.attributes);
if attrs_str.is_empty() {
lines.push(format!("- `{}`: `{}`", field.name.value, type_str));
} else {
lines.push(format!(
"- `{}`: `{}` — {}",
field.name.value, type_str, attrs_str
));
}
}
lines.join(" \n")
}
fn field_type_name(ft: &FieldType) -> String {
match ft {
FieldType::String => "String".to_string(),
FieldType::Boolean => "Boolean".to_string(),
FieldType::Int => "Int".to_string(),
FieldType::BigInt => "BigInt".to_string(),
FieldType::Float => "Float".to_string(),
FieldType::Decimal { precision, scale } => format!("Decimal({}, {})", precision, scale),
FieldType::DateTime => "DateTime".to_string(),
FieldType::Bytes => "Bytes".to_string(),
FieldType::Json => "Json".to_string(),
FieldType::Uuid => "Uuid".to_string(),
FieldType::Jsonb => "Jsonb".to_string(),
FieldType::Xml => "Xml".to_string(),
FieldType::Char { length } => format!("Char({})", length),
FieldType::VarChar { length } => format!("VarChar({})", length),
FieldType::UserType(name) => name.clone(),
}
}
fn field_type_description(ft: &FieldType) -> &'static str {
match ft {
FieldType::String => "UTF-8 text string. Maps to `VARCHAR` / `TEXT` in SQL.",
FieldType::Boolean => "Boolean value (`true` / `false`). Maps to `BOOLEAN`.",
FieldType::Int => "32-bit signed integer. Maps to `INTEGER`.",
FieldType::BigInt => "64-bit signed integer. Maps to `BIGINT`.",
FieldType::Float => "64-bit IEEE 754 float. Maps to `DOUBLE PRECISION`.",
FieldType::Decimal { .. } => "Exact-precision decimal number. Maps to `NUMERIC(p, s)`.",
FieldType::DateTime => "Date and time with timezone. Maps to `TIMESTAMPTZ`.",
FieldType::Bytes => "Raw binary data. Maps to `BYTEA` / `BLOB`.",
FieldType::Json => "JSON document. Maps to `JSONB` (Postgres) or `JSON` (MySQL/SQLite).",
FieldType::Uuid => "Universally unique identifier. Maps to `UUID`.",
FieldType::Jsonb => "JSONB document (PostgreSQL only). Maps to `JSONB`.",
FieldType::Xml => "XML document (PostgreSQL only). Maps to `XML`.",
FieldType::Char { .. } => {
"Fixed-length character column. Maps to `CHAR(n)` (PostgreSQL and MySQL)."
}
FieldType::VarChar { .. } => {
"Variable-length character column. Maps to `VARCHAR(n)` (PostgreSQL and MySQL)."
}
FieldType::UserType(_) => "Reference to another model or enum.",
}
}