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, completion_with_analysis, CompletionItem, CompletionKind};
26pub use goto_definition::{goto_definition, goto_definition_with_analysis};
27pub use hover::{config_field_hover, hover, hover_with_analysis, 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    for e in parser.take_errors() {
106        diagnostics.push(Diagnostic::from(e));
107    }
108
109    let ast = match parse_result {
110        Ok(schema) => schema,
111        Err(e) => {
112            diagnostics.push(Diagnostic::from(e));
113            return AnalysisResult {
114                ast: None,
115                ir: None,
116                diagnostics,
117                tokens,
118            };
119        }
120    };
121
122    let (ir, val_errors) = validate_all(ast.clone());
123    for e in val_errors {
124        diagnostics.push(Diagnostic::from(e));
125    }
126
127    AnalysisResult {
128        ast: Some(ast),
129        ir,
130        diagnostics,
131        tokens,
132    }
133}
134
135/// Returns `true` if `offset` falls within `span` (inclusive both ends).
136pub(super) fn span_contains(span: Span, offset: usize) -> bool {
137    offset >= span.start && offset <= span.end
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    const VALID_SCHEMA: &str = r#"
145datasource db {
146  provider = "postgresql"
147  url      = "postgresql://localhost/mydb"
148}
149
150model User {
151  id    Int    @id
152  email String @unique
153  name  String
154}
155
156enum Role {
157  Admin
158  User
159}
160"#;
161
162    #[test]
163    fn analyze_valid_schema() {
164        let r = analyze(VALID_SCHEMA);
165        assert!(r.diagnostics.is_empty(), "unexpected: {:?}", r.diagnostics);
166        assert!(r.ast.is_some());
167        assert!(r.ir.is_some());
168    }
169
170    #[test]
171    fn analyze_validation_error_returns_diagnostic() {
172        let src = r#"
173model Broken {
174  id   Int
175  name String
176}
177"#;
178        let r = analyze(src);
179        assert!(r.ast.is_some());
180        let _ = r.diagnostics;
181    }
182
183    #[test]
184    fn analyze_lex_error() {
185        let src = "model User { id # Int }";
186        let r = analyze(src);
187        assert!(!r.diagnostics.is_empty());
188    }
189
190    #[test]
191    fn completion_top_level() {
192        let items = completion("", 0);
193        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
194        assert!(labels.contains(&"model"));
195        assert!(labels.contains(&"enum"));
196        assert!(labels.contains(&"datasource"));
197        assert!(labels.contains(&"generator"));
198    }
199
200    #[test]
201    fn completion_inside_model_returns_scalar_types() {
202        let src = "model User {\n  \n}";
203        let offset = src.find("\n  \n").unwrap() + 3;
204        let items = completion(src, offset);
205        let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
206        assert!(labels.contains(&"String"), "got: {:?}", labels);
207        assert!(labels.contains(&"Int"), "got: {:?}", labels);
208    }
209
210    #[test]
211    fn completion_after_at_returns_field_attrs() {
212        let src = "model User {\n  id Int @\n}";
213        let offset = src.find('@').unwrap() + 1;
214        let items = completion(src, offset);
215        assert!(
216            items
217                .iter()
218                .any(|i| i.kind == CompletionKind::FieldAttribute),
219            "got: {:?}",
220            items
221        );
222    }
223
224    #[test]
225    fn hover_on_field_returns_type_info() {
226        let src = VALID_SCHEMA;
227        let offset = src.find("email").unwrap() + 2;
228        let h = hover(src, offset);
229        assert!(h.is_some(), "hover returned None");
230        let info = h.unwrap();
231        assert!(info.content.contains("email") || info.content.contains("String"));
232    }
233
234    #[test]
235    fn goto_definition_resolves_user_type() {
236        let src = r#"
237model Post {
238  id       Int  @id
239  authorId Int
240  author   User @relation(fields: [authorId], references: [id])
241}
242
243model User {
244  id    Int    @id
245  email String @unique
246}
247"#;
248        let offset = src.find("author   User").unwrap() + "author   ".len() + 1;
249        let span = goto_definition(src, offset);
250        assert!(span.is_some(), "goto_definition returned None");
251        let target = span.unwrap();
252        assert!(
253            &src[target.start..target.end].contains("User"),
254            "span does not point to User: {:?}",
255            &src[target.start..target.end]
256        );
257    }
258}