Skip to main content

leekscript_document/
lib.rs

1//! Document-level analysis: single entry point for parsing, analysis, definition map, and doc maps.
2//!
3//! Use [`DocumentAnalysis::new`] to run parsing and analysis (with optional include tree and
4//! signature roots) and get diagnostics, type map, scope store, definition map, doc maps, and
5//! class hierarchy in one place.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use sipha::line_index::LineIndex;
11use sipha::red::SyntaxNode;
12use sipha::types::IntoSyntaxKind;
13
14use leekscript_analysis::{
15    analyze, analyze_with_include_tree, analyze_with_signatures, build_scope_extents,
16    class_decl_info, function_decl_info, scope_at_offset, var_decl_info, ResolvedSymbol, ScopeId,
17    ScopeStore, VarDeclKind,
18};
19use leekscript_core::doc_comment::{build_doc_map, DocComment};
20use leekscript_core::syntax::Kind;
21use leekscript_core::{
22    build_include_tree, parse, parse_error_to_diagnostics, parse_recovering_multi, IncludeTree,
23    Type,
24};
25use sipha_analysis::collect_definitions;
26
27/// Kind of root-level symbol for definition map (matches scope seeding order).
28#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
29pub enum RootSymbolKind {
30    Class,
31    Function,
32    Global,
33}
34
35fn is_top_level(node: &SyntaxNode, root: &SyntaxNode) -> bool {
36    for anc in node.ancestors(root) {
37        if let Some(Kind::NodeBlock | Kind::NodeFunctionDecl | Kind::NodeClassDecl) =
38            anc.kind_as::<Kind>()
39        {
40            return false;
41        }
42    }
43    true
44}
45
46/// Build (name, kind) -> (path, start, end) for root-level symbols (includes first, then main).
47#[must_use]
48pub fn build_definition_map(
49    tree: &IncludeTree,
50    main_path: &Path,
51) -> HashMap<(String, RootSymbolKind), (PathBuf, u32, u32)> {
52    let mut roots: Vec<(PathBuf, SyntaxNode)> = tree
53        .includes
54        .iter()
55        .filter_map(|(path, child)| child.root.as_ref().map(|r| (path.clone(), r.clone())))
56        .collect();
57    if let Some(ref root) = tree.root {
58        roots.push((main_path.to_path_buf(), root.clone()));
59    }
60    collect_definitions(&roots, |node, root| {
61        if !is_top_level(node, root) {
62            return None;
63        }
64        if node.kind_as::<Kind>() == Some(Kind::NodeClassDecl) {
65            return class_decl_info(node)
66                .map(|info| (info.name, RootSymbolKind::Class, info.name_span));
67        }
68        if node.kind_as::<Kind>() == Some(Kind::NodeFunctionDecl) {
69            return function_decl_info(node)
70                .map(|info| (info.name, RootSymbolKind::Function, info.name_span));
71        }
72        if node.kind_as::<Kind>() == Some(Kind::NodeVarDecl) {
73            if let Some(info) = var_decl_info(node) {
74                if info.kind == VarDeclKind::Global {
75                    return Some((info.name, RootSymbolKind::Global, info.name_span));
76                }
77            }
78        }
79        None
80    })
81}
82
83/// Find the declaration node span (for `doc_map` lookup) that contains the given name span.
84#[must_use]
85pub fn decl_span_for_name_span(
86    root: &SyntaxNode,
87    name_start: u32,
88    name_end: u32,
89) -> Option<(u32, u32)> {
90    for node in root.find_all_nodes(Kind::NodeClassDecl.into_syntax_kind()) {
91        if let Some(info) = class_decl_info(&node) {
92            if info.name_span.start == name_start && info.name_span.end == name_end {
93                let r = node.text_range();
94                return Some((r.start, r.end));
95            }
96        }
97    }
98    for node in root.find_all_nodes(Kind::NodeFunctionDecl.into_syntax_kind()) {
99        if let Some(info) = function_decl_info(&node) {
100            if info.name_span.start == name_start && info.name_span.end == name_end {
101                let r = node.text_range();
102                return Some((r.start, r.end));
103            }
104        }
105    }
106    for node in root.find_all_nodes(Kind::NodeVarDecl.into_syntax_kind()) {
107        if !is_top_level(&node, root) {
108            continue;
109        }
110        if let Some(info) = var_decl_info(&node) {
111            if info.kind == VarDeclKind::Global
112                && info.name_span.start == name_start
113                && info.name_span.end == name_end
114            {
115                let r = node.text_range();
116                return Some((r.start, r.end));
117            }
118        }
119    }
120    None
121}
122
123/// Build map `class_name` -> `superclass_name` from the AST (for visibility: subclass can see protected).
124#[must_use]
125pub fn build_class_super(root: Option<&SyntaxNode>) -> HashMap<String, String> {
126    let mut map = HashMap::new();
127    let root = match root {
128        Some(r) => r,
129        None => return map,
130    };
131    for node in root.find_all_nodes(Kind::NodeClassDecl.into_syntax_kind()) {
132        if let Some(info) = class_decl_info(&node) {
133            if let Some(super_name) = info.super_class {
134                map.insert(info.name, super_name);
135            }
136        }
137    }
138    map
139}
140
141/// Options for building a [`DocumentAnalysis`].
142///
143/// Use [`DocumentAnalysis::new_with_options`] to run parsing and analysis with these options.
144/// Keeps the API extensible (e.g. `max_parse_errors`, "skip analysis") without breaking callers.
145#[derive(Default)]
146pub struct DocumentAnalysisOptions<'a> {
147    /// Source code of the main document.
148    pub source: &'a str,
149    /// When set and [`build_include_tree`](leekscript_core::build_include_tree) succeeds, analysis uses the include tree.
150    pub main_path: Option<&'a Path>,
151    /// Parsed signature roots (e.g. from [`parse_signatures`](leekscript_core::parse_signatures)) to seed the scope.
152    pub signature_roots: &'a [SyntaxNode],
153    /// Previous syntax root for incremental reparse; when `Some`, parsing reuses it when applicable.
154    pub existing_root: Option<SyntaxNode>,
155    /// Max parse errors to collect in recovery mode (default: 64).
156    pub max_parse_errors: Option<usize>,
157    /// When set, function/global name -> (path, 0-based line) from .sig files for hover links (e.g. getCellX, getCellY).
158    pub sig_definition_locations: Option<&'a HashMap<String, (PathBuf, u32)>>,
159}
160
161/// Result of document-level analysis: source, AST, diagnostics, scope, types, definition map, doc maps, class hierarchy.
162#[derive(Debug)]
163pub struct DocumentAnalysis {
164    /// Main document source (what the client has).
165    pub source: String,
166    /// Line index for the main document.
167    pub line_index: LineIndex,
168    /// Program root (main file); None if parse failed.
169    pub root: Option<SyntaxNode>,
170    /// When Some, the document has includes; tree holds main + included files.
171    pub include_tree: Option<IncludeTree>,
172    /// Path of the main document (when file URI), for go-to-def on include.
173    pub main_path: Option<PathBuf>,
174    /// Semantic and parse diagnostics.
175    pub diagnostics: Vec<sipha::error::SemanticDiagnostic>,
176    /// Map from expression span (start, end) to inferred type.
177    pub type_map: HashMap<(u32, u32), leekscript_core::Type>,
178    pub scope_store: ScopeStore,
179    /// Scope extent (`ScopeId`, (`start_byte`, `end_byte`)) for `scope_at_offset`.
180    pub scope_extents: Vec<(ScopeId, (u32, u32))>,
181    /// (name, kind) -> (path, `start_byte`, `end_byte`) for root-level symbols.
182    pub definition_map: HashMap<(String, RootSymbolKind), (PathBuf, u32, u32)>,
183    /// Map from declaration (`start_byte`, `end_byte`) to parsed Doxygen-style documentation.
184    pub doc_map: HashMap<(u32, u32), DocComment>,
185    /// When Some (with `include_tree`), `doc_map` per included file path.
186    pub include_doc_maps: Option<HashMap<PathBuf, HashMap<(u32, u32), DocComment>>>,
187    /// Class name -> superclass name (for visibility: subclass can see protected).
188    pub class_super: HashMap<String, String>,
189    /// When Some, function/global name -> (path, 0-based line) from .sig files for hover/definition links.
190    pub sig_definition_locations: Option<HashMap<String, (PathBuf, u32)>>,
191}
192
193impl DocumentAnalysis {
194    /// Run parsing and analysis from options.
195    ///
196    /// When `main_path` is set and [`build_include_tree`](leekscript_core::build_include_tree) succeeds, uses
197    /// the include tree and analyzes with included files and `signature_roots`. Otherwise parses a
198    /// single file (or uses `existing_root` when provided) and optionally uses `signature_roots`.
199    #[must_use]
200    pub fn new_with_options(options: &DocumentAnalysisOptions<'_>) -> Self {
201        let DocumentAnalysisOptions {
202            source,
203            main_path,
204            signature_roots,
205            existing_root,
206            max_parse_errors,
207            sig_definition_locations,
208        } = options;
209        let max_errors = max_parse_errors.unwrap_or(PARSE_RECOVERY_MAX_ERRORS);
210        let mut diagnostics = Vec::new();
211        let mut type_map = HashMap::new();
212        let mut scope_store = ScopeStore::new();
213        let mut scope_extents = vec![];
214        let mut definition_map = HashMap::new();
215        let mut doc_map = HashMap::new();
216        let mut include_doc_maps: Option<HashMap<PathBuf, HashMap<(u32, u32), DocComment>>> = None;
217        let mut include_tree: Option<IncludeTree> = None;
218        let mut main_path_buf: Option<PathBuf> = main_path.map(Path::to_path_buf);
219        let mut root: Option<SyntaxNode> = None;
220        let mut source_owned = (*source).to_string();
221
222        match main_path {
223            Some(path) => match build_include_tree(source, Some(path)) {
224                Ok(tree) => {
225                    let result = analyze_with_include_tree(&tree, signature_roots);
226                    diagnostics = result.diagnostics;
227                    type_map = result.type_map;
228                    scope_store = result.scope_store;
229                    let len = tree.source.len() as u32;
230                    scope_extents = match &tree.root {
231                        Some(r) => build_scope_extents(r, &result.scope_id_sequence, len as usize),
232                        _ => vec![(ScopeId(0), (0, len))],
233                    };
234                    if tree.root.is_none() {
235                        if let Err(parse_err) = parse(&tree.source) {
236                            diagnostics
237                                .extend(parse_error_to_diagnostics(&parse_err, &tree.source));
238                        }
239                    }
240                    definition_map = build_definition_map(&tree, path);
241                    doc_map = tree.root.as_ref().map(build_doc_map).unwrap_or_default();
242                    let mut inc_doc = HashMap::new();
243                    for (p, child) in &tree.includes {
244                        if let Some(ref inc_root) = child.root {
245                            inc_doc.insert(p.clone(), build_doc_map(inc_root));
246                        }
247                    }
248                    include_doc_maps = Some(inc_doc);
249                    include_tree = Some(tree.clone());
250                    main_path_buf = Some(path.to_path_buf());
251                    source_owned = tree.source.clone();
252                    root = tree.root.clone();
253                }
254                Err(_) => {
255                    single_file_analysis(
256                        source,
257                        signature_roots,
258                        existing_root.clone(),
259                        max_errors,
260                        &mut diagnostics,
261                        &mut type_map,
262                        &mut scope_store,
263                        &mut scope_extents,
264                        &mut root,
265                    );
266                }
267            },
268            None => {
269                single_file_analysis(
270                    source,
271                    signature_roots,
272                    existing_root.clone(),
273                    max_errors,
274                    &mut diagnostics,
275                    &mut type_map,
276                    &mut scope_store,
277                    &mut scope_extents,
278                    &mut root,
279                );
280            }
281        }
282
283        if doc_map.is_empty() && root.is_some() && include_tree.is_none() {
284            doc_map = build_doc_map(root.as_ref().unwrap());
285        }
286
287        let class_super = build_class_super(root.as_ref());
288
289        let line_index = LineIndex::new(source_owned.as_bytes());
290
291        Self {
292            source: source_owned,
293            line_index,
294            root,
295            include_tree,
296            main_path: main_path_buf,
297            diagnostics,
298            type_map,
299            scope_store,
300            scope_extents,
301            definition_map,
302            doc_map,
303            include_doc_maps,
304            class_super,
305            sig_definition_locations: sig_definition_locations.cloned(),
306        }
307    }
308
309    /// Run parsing and analysis for the given source.
310    ///
311    /// Convenience wrapper around [`Self::new_with_options`]. When `main_path` is `Some` and
312    /// `build_include_tree` succeeds, uses the include tree; otherwise parses a single file (or uses
313    /// `existing_root` when provided, e.g. from incremental reparse).
314    #[must_use]
315    pub fn new(
316        source: &str,
317        main_path: Option<&Path>,
318        signature_roots: &[SyntaxNode],
319        existing_root: Option<SyntaxNode>,
320        sig_definition_locations: Option<HashMap<String, (PathBuf, u32)>>,
321    ) -> Self {
322        Self::new_with_options(&DocumentAnalysisOptions {
323            source,
324            main_path,
325            signature_roots,
326            existing_root,
327            max_parse_errors: None,
328            sig_definition_locations: sig_definition_locations.as_ref(),
329        })
330    }
331
332    /// Resolve the symbol at the given byte offset (e.g. variable, function, class, global).
333    /// Returns `None` if there is no root, no token at offset, or the identifier does not resolve.
334    #[must_use]
335    pub fn symbol_at_offset(&self, byte_offset: u32) -> Option<ResolvedSymbol> {
336        let root = self.root.as_ref()?;
337        let token = root.token_at_offset(byte_offset)?;
338        if token.kind_as::<Kind>() != Some(Kind::TokIdent) {
339            return None;
340        }
341        let name = token.text().to_string();
342        let scope_id = scope_at_offset(&self.scope_extents, byte_offset);
343        self.scope_store.resolve(scope_id, &name)
344    }
345
346    /// Type at the given byte offset. Looks up the node at offset in the type map, then walks
347    /// ancestors until a type is found.
348    #[must_use]
349    pub fn type_at_offset(&self, byte_offset: u32) -> Option<Type> {
350        let root = self.root.as_ref()?;
351        let node = root.node_at_offset(byte_offset)?;
352        let range = node.text_range();
353        let key = (range.start, range.end);
354        self.type_map.get(&key).cloned().or_else(|| {
355            for anc in node.ancestors(root) {
356                let r = anc.text_range();
357                if let Some(t) = self.type_map.get(&(r.start, r.end)) {
358                    return Some(t.clone());
359                }
360            }
361            None
362        })
363    }
364
365    /// Definition span for a root-level symbol: `(path, start_byte, end_byte)`.
366    /// Returns `None` if the name/kind is not in the definition map.
367    #[must_use]
368    pub fn definition_span_for(
369        &self,
370        name: &str,
371        kind: RootSymbolKind,
372    ) -> Option<(PathBuf, u32, u32)> {
373        self.definition_map.get(&(name.to_string(), kind)).cloned()
374    }
375
376    /// Build minimal document state with only source and line index (no parse/analysis).
377    /// Used by the LSP to update the document buffer immediately on `did_change` so that
378    /// subsequent changes are applied to the correct base; analysis overwrites this when it completes.
379    #[must_use]
380    pub fn minimal(source: String) -> Self {
381        let line_index = LineIndex::new(source.as_bytes());
382        let len = source.len() as u32;
383        Self {
384            source,
385            line_index,
386            root: None,
387            include_tree: None,
388            main_path: None,
389            diagnostics: Vec::new(),
390            type_map: HashMap::new(),
391            scope_store: ScopeStore::new(),
392            scope_extents: vec![(ScopeId(0), (0, len))],
393            definition_map: HashMap::new(),
394            doc_map: HashMap::new(),
395            include_doc_maps: None,
396            class_super: HashMap::new(),
397            sig_definition_locations: None,
398        }
399    }
400
401    /// Like [`Self::minimal`] but keeps the given root so the next incremental reparse can reuse it.
402    /// Use when reparse succeeded and analysis will run async; keeps the tree available for the next edit.
403    #[must_use]
404    pub fn minimal_with_root(source: String, root: SyntaxNode) -> Self {
405        let line_index = LineIndex::new(source.as_bytes());
406        let len = source.len() as u32;
407        Self {
408            source,
409            line_index,
410            root: Some(root),
411            include_tree: None,
412            main_path: None,
413            diagnostics: Vec::new(),
414            type_map: HashMap::new(),
415            scope_store: ScopeStore::new(),
416            scope_extents: vec![(ScopeId(0), (0, len))],
417            definition_map: HashMap::new(),
418            doc_map: HashMap::new(),
419            include_doc_maps: None,
420            class_super: HashMap::new(),
421            sig_definition_locations: None,
422        }
423    }
424
425    /// Build document state from source using parse only (no semantic analysis).
426    /// Use when full analysis panics so the LSP can still provide syntax highlighting and basic features.
427    #[must_use]
428    pub fn from_parse_only(source: &str) -> Self {
429        let mut diagnostics = Vec::new();
430        let root = match parse_recovering_multi(source, PARSE_RECOVERY_MAX_ERRORS) {
431            Ok(output) => output.syntax_root(source.as_bytes()),
432            Err(recover) => {
433                for parse_err in &recover.errors {
434                    diagnostics.extend(parse_error_to_diagnostics(parse_err, source));
435                }
436                recover.partial.syntax_root(source.as_bytes())
437            }
438        };
439        let source_owned = source.to_string();
440        let line_index = LineIndex::new(source_owned.as_bytes());
441        let scope_extents = vec![(ScopeId(0), (0, source.len() as u32))];
442        let doc_map = root.as_ref().map(build_doc_map).unwrap_or_default();
443        let class_super = build_class_super(root.as_ref());
444        Self {
445            source: source_owned,
446            line_index,
447            root,
448            include_tree: None,
449            main_path: None,
450            diagnostics,
451            type_map: HashMap::new(),
452            scope_store: ScopeStore::new(),
453            scope_extents,
454            definition_map: HashMap::new(),
455            doc_map,
456            include_doc_maps: None,
457            class_super,
458            sig_definition_locations: None,
459        }
460    }
461}
462
463/// Max parse errors to collect in recovery mode before stopping.
464const PARSE_RECOVERY_MAX_ERRORS: usize = 64;
465
466fn single_file_analysis(
467    source: &str,
468    signature_roots: &[SyntaxNode],
469    existing_root: Option<SyntaxNode>,
470    max_parse_errors: usize,
471    diagnostics: &mut Vec<sipha::error::SemanticDiagnostic>,
472    type_map: &mut HashMap<(u32, u32), leekscript_core::Type>,
473    scope_store: &mut ScopeStore,
474    scope_extents: &mut Vec<(ScopeId, (u32, u32))>,
475    root: &mut Option<SyntaxNode>,
476) {
477    let parsed = if let Some(r) = existing_root {
478        Some(r)
479    } else {
480        match parse_recovering_multi(source, max_parse_errors) {
481            Ok(output) => output.syntax_root(source.as_bytes()),
482            Err(recover) => {
483                for parse_err in &recover.errors {
484                    diagnostics.extend(parse_error_to_diagnostics(parse_err, source));
485                }
486                recover.partial.syntax_root(source.as_bytes())
487            }
488        }
489    };
490
491    if let Some(ref r) = parsed {
492        let result = if signature_roots.is_empty() {
493            analyze(r)
494        } else {
495            analyze_with_signatures(r, signature_roots)
496        };
497        diagnostics.extend(result.diagnostics);
498        *type_map = result.type_map;
499        *scope_store = result.scope_store;
500        *scope_extents = build_scope_extents(r, &result.scope_id_sequence, source.len());
501        *root = Some(r.clone());
502    } else {
503        *scope_extents = vec![(ScopeId(0), (0, source.len() as u32))];
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use std::path::Path;
510
511    use leekscript_core::{build_include_tree, parse};
512
513    use super::{
514        build_class_super, build_definition_map, decl_span_for_name_span, DocumentAnalysis,
515        RootSymbolKind,
516    };
517
518    #[test]
519    fn build_definition_map_main_only() {
520        let source = r#"
521global integer x = 1;
522function f(integer a) -> integer { return a; }
523class C { }
524"#;
525        let main_path = Path::new("main.leek");
526        let tree = build_include_tree(source, Some(main_path)).expect("build_include_tree");
527        let map = build_definition_map(&tree, main_path);
528        assert!(map.contains_key(&("x".to_string(), RootSymbolKind::Global)));
529        assert!(map.contains_key(&("f".to_string(), RootSymbolKind::Function)));
530        assert!(map.contains_key(&("C".to_string(), RootSymbolKind::Class)));
531        for (_, (path, start, end)) in &map {
532            assert_eq!(path, main_path);
533            assert!(end > start);
534        }
535    }
536
537    #[test]
538    fn build_class_super_single_inheritance() {
539        let source = "class Child extends Parent { }";
540        let root = parse(source).unwrap().expect("parse");
541        let map = build_class_super(Some(&root));
542        assert_eq!(map.get("Child"), Some(&"Parent".to_string()));
543        assert_eq!(map.len(), 1);
544    }
545
546    #[test]
547    fn build_class_super_none_for_no_extends() {
548        let source = "class C { }";
549        let root = parse(source).unwrap().expect("parse");
550        let map = build_class_super(Some(&root));
551        assert!(map.is_empty());
552    }
553
554    #[test]
555    fn build_class_super_none_root() {
556        let map = build_class_super(None);
557        assert!(map.is_empty());
558    }
559
560    #[test]
561    fn document_analysis_empty_source() {
562        let analysis = DocumentAnalysis::new("", None, &[], None, None);
563        assert_eq!(analysis.source, "");
564        assert!(!analysis.scope_extents.is_empty());
565    }
566
567    #[test]
568    fn document_analysis_incomplete_syntax_does_not_panic() {
569        let source = "var x = ";
570        let analysis = DocumentAnalysis::new(source, None, &[], None, None);
571        let _ = &analysis.source;
572        let _ = &analysis.scope_extents;
573        let _ = &analysis.scope_store;
574    }
575
576    #[test]
577    fn document_analysis_unclosed_brace_does_not_panic() {
578        let source = "function f() { return 1; ";
579        let analysis = DocumentAnalysis::new(source, None, &[], None, None);
580        let _ = &analysis.diagnostics;
581        let _ = &analysis.scope_extents;
582    }
583
584    #[test]
585    fn document_analysis_symbol_at_offset_no_root_returns_none() {
586        let analysis = DocumentAnalysis::new("", None, &[], None, None);
587        assert!(analysis.symbol_at_offset(0).is_none());
588    }
589
590    #[test]
591    fn document_analysis_type_at_offset_no_root_returns_none() {
592        let analysis = DocumentAnalysis::new("", None, &[], None, None);
593        assert!(analysis.type_at_offset(0).is_none());
594    }
595
596    #[test]
597    fn decl_span_for_name_span_class() {
598        let source = "class Foo { }";
599        let root = parse(source).unwrap().expect("parse");
600        // "Foo" is at offset 6, length 3
601        let decl_span = decl_span_for_name_span(&root, 6, 9);
602        assert!(decl_span.is_some());
603        let (start, end) = decl_span.unwrap();
604        assert!(end > start);
605        assert!(start <= 6 && end >= 9);
606    }
607
608    #[test]
609    fn decl_span_for_name_span_function() {
610        let source = "function bar() { }";
611        let root = parse(source).unwrap().expect("parse");
612        // "bar" is at offset 9, length 3
613        let decl_span = decl_span_for_name_span(&root, 9, 12);
614        assert!(decl_span.is_some());
615        let (start, end) = decl_span.unwrap();
616        assert!(end > start);
617    }
618
619    #[test]
620    fn decl_span_for_name_span_unknown_returns_none() {
621        let source = "var x = 1;";
622        let root = parse(source).unwrap().expect("parse");
623        let decl_span = decl_span_for_name_span(&root, 4, 5); // "x" - var decl not class/function name
624        assert!(decl_span.is_none());
625    }
626}