Skip to main content

nautilus_schema/analysis/
mod.rs

1//! Top-level analysis API for `.nautilus` schema files.
2//!
3//! This module exposes a stable public contract that editor tooling (LSP servers,
4//! CLI linters, etc.) can call without duplicating parsing or validation logic.
5//!
6//! # Quick Start
7//!
8//! ```ignore
9//! use nautilus_schema::analysis::analyze;
10//!
11//! let result = analyze(source);
12//! for diag in &result.diagnostics {
13//!     println!("{:?} — {}", diag.severity, diag.message);
14//! }
15//! if let Some(ir) = &result.ir {
16//!     println!("{} models", ir.models.len());
17//! }
18//! ```
19
20pub mod completion;
21pub mod goto_definition;
22pub mod hover;
23pub mod semantic_tokens;
24
25pub use completion::{completion, CompletionItem, CompletionKind};
26pub use goto_definition::goto_definition;
27pub use hover::{config_field_hover, hover, HoverInfo};
28pub use semantic_tokens::{semantic_tokens, SemanticKind, SemanticToken};
29
30use crate::ast::Schema;
31use crate::diagnostic::Diagnostic;
32use crate::ir::SchemaIr;
33use crate::span::Span;
34use crate::token::{Token, TokenKind};
35use crate::validator::validate_all;
36use crate::{Lexer, Parser};
37
38/// The result of a full analysis pass over a `.nautilus` source string.
39///
40/// All three fields may be present simultaneously:
41/// - `ast` is `Some` even when there are parse errors (partial AST from error recovery).
42/// - `ir`  is `Some` only when there are **no** validation errors.
43/// - `diagnostics` is non-empty whenever any problem was found.
44#[derive(Debug, Clone)]
45pub struct AnalysisResult {
46    /// Parsed AST (partial when parse errors occurred).
47    pub ast: Option<Schema>,
48    /// Fully validated IR (only present when `diagnostics` is empty).
49    pub ir: Option<SchemaIr>,
50    /// All problems found: lex errors + parse errors + validation errors.
51    pub diagnostics: Vec<Diagnostic>,
52    /// Token stream produced by the lexer (best-effort; may be incomplete on lex errors).
53    pub tokens: Vec<Token>,
54}
55
56/// Analyzes a `.nautilus` schema source string end-to-end.
57///
58/// Runs the full pipeline (lex → parse → validate) and collects **all**
59/// diagnostics, not just the first one.  The returned [`AnalysisResult`]
60/// contains the best-effort AST, the validated IR (when error-free), and
61/// the complete list of diagnostics.
62pub fn analyze(source: &str) -> AnalysisResult {
63    let mut diagnostics: Vec<Diagnostic> = Vec::new();
64
65    let mut lexer = Lexer::new(source);
66    let mut tokens: Vec<Token> = Vec::new();
67    let mut lex_ok = true;
68
69    loop {
70        match lexer.next_token() {
71            Ok(tok) => {
72                let is_eof = matches!(tok.kind, TokenKind::Eof);
73                tokens.push(tok);
74                if is_eof {
75                    break;
76                }
77            }
78            Err(e) => {
79                // For a single bad character the lexer has already advanced
80                // past it, so we can record the error and keep going.
81                // For errors that leave the lexer in an indeterminate state
82                // (unterminated strings, invalid numbers) we stop early.
83                let recoverable = matches!(e, crate::SchemaError::UnexpectedCharacter(..));
84                diagnostics.push(Diagnostic::from(e));
85                if !recoverable {
86                    lex_ok = false;
87                    break;
88                }
89            }
90        }
91    }
92
93    if !lex_ok {
94        return AnalysisResult {
95            ast: None,
96            ir: None,
97            diagnostics,
98            tokens,
99        };
100    }
101
102    let mut parser = Parser::new(&tokens, source);
103    let parse_result = parser.parse_schema();
104
105    // Collect non-fatal recovery errors
106    for e in parser.take_errors() {
107        diagnostics.push(Diagnostic::from(e));
108    }
109
110    let ast = match parse_result {
111        Ok(schema) => schema,
112        Err(e) => {
113            diagnostics.push(Diagnostic::from(e));
114            return AnalysisResult {
115                ast: None,
116                ir: None,
117                diagnostics,
118                tokens,
119            };
120        }
121    };
122
123    let (ir, val_errors) = validate_all(ast.clone());
124    for e in val_errors {
125        diagnostics.push(Diagnostic::from(e));
126    }
127
128    AnalysisResult {
129        ast: Some(ast),
130        ir,
131        diagnostics,
132        tokens,
133    }
134}
135
136/// Returns `true` if `offset` falls within `span` (inclusive both ends).
137pub(super) fn span_contains(span: Span, offset: usize) -> bool {
138    offset >= span.start && offset <= span.end
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    const VALID_SCHEMA: &str = r#"
146datasource db {
147  provider = "postgresql"
148  url      = "postgresql://localhost/mydb"
149}
150
151model User {
152  id    Int    @id
153  email String @unique
154  name  String
155}
156
157enum Role {
158  Admin
159  User
160}
161"#;
162
163    #[test]
164    fn analyze_valid_schema() {
165        let r = analyze(VALID_SCHEMA);
166        assert!(r.diagnostics.is_empty(), "unexpected: {:?}", r.diagnostics);
167        assert!(r.ast.is_some());
168        assert!(r.ir.is_some());
169    }
170
171    #[test]
172    fn analyze_validation_error_returns_diagnostic() {
173        // @id missing — relation field with no @id on Id field
174        let src = r#"
175model Broken {
176  id   Int
177  name String
178}
179"#;
180        let r = analyze(src);
181        // No validation error for a model with no @id (it's a soft error in some systems,
182        // but nautilus-schema requires it).  Check diagnostics are populated.
183        assert!(r.ast.is_some());
184        // Diagnostics may or may not fire; just ensure no panic.
185        let _ = r.diagnostics;
186    }
187
188    #[test]
189    fn analyze_lex_error() {
190        let src = "model User { id # Int }";
191        let r = analyze(src);
192        assert!(!r.diagnostics.is_empty());
193    }
194
195    #[test]
196    fn completion_top_level() {
197        let items = completion("", 0);
198        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
199        assert!(labels.contains(&"model"));
200        assert!(labels.contains(&"enum"));
201        assert!(labels.contains(&"datasource"));
202        assert!(labels.contains(&"generator"));
203    }
204
205    #[test]
206    fn completion_inside_model_returns_scalar_types() {
207        // cursor is inside the model block (after the opening brace)
208        let src = "model User {\n  \n}";
209        let offset = src.find("\n  \n").unwrap() + 3; // inside the body
210        let items = completion(src, offset);
211        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
212        assert!(labels.contains(&"String"), "got: {:?}", labels);
213        assert!(labels.contains(&"Int"), "got: {:?}", labels);
214    }
215
216    #[test]
217    fn completion_after_at_returns_field_attrs() {
218        let src = "model User {\n  id Int @\n}";
219        let offset = src.find('@').unwrap() + 1; // right after `@`
220        let items = completion(src, offset);
221        assert!(
222            items
223                .iter()
224                .any(|i| i.kind == CompletionKind::FieldAttribute),
225            "got: {:?}",
226            items
227        );
228    }
229
230    #[test]
231    fn hover_on_field_returns_type_info() {
232        let src = VALID_SCHEMA;
233        // mid-point of the `email` field line
234        let offset = src.find("email").unwrap() + 2;
235        let h = hover(src, offset);
236        assert!(h.is_some(), "hover returned None");
237        let info = h.unwrap();
238        assert!(info.content.contains("email") || info.content.contains("String"));
239    }
240
241    #[test]
242    fn goto_definition_resolves_user_type() {
243        let src = r#"
244model Post {
245  id       Int  @id
246  authorId Int
247  author   User @relation(fields: [authorId], references: [id])
248}
249
250model User {
251  id    Int    @id
252  email String @unique
253}
254"#;
255        // Offset on `User` in the `author` field
256        let offset = src.find("author   User").unwrap() + "author   ".len() + 1;
257        let span = goto_definition(src, offset);
258        assert!(span.is_some(), "goto_definition returned None");
259        // The span should point into the User model declaration
260        let target = span.unwrap();
261        assert!(
262            &src[target.start..target.end].contains("User"),
263            "span does not point to User: {:?}",
264            &src[target.start..target.end]
265        );
266    }
267}