Skip to main content

logicaffeine_lsp/
document.rs

1use std::collections::HashMap;
2
3use tower_lsp::lsp_types::{Diagnostic, Range, Url};
4
5use logicaffeine_base::Interner;
6use logicaffeine_compile::analysis::VarState;
7use logicaffeine_language::{
8    analysis::{TypeRegistry, PolicyRegistry},
9    token::Token,
10};
11
12use crate::index::SymbolIndex;
13use crate::line_index::LineIndex;
14use crate::pipeline;
15
16/// Per-document state: source text, analysis results, and cached diagnostics.
17pub struct DocumentState {
18    pub source: String,
19    pub version: i32,
20    pub line_index: LineIndex,
21
22    // Analysis results (rebuilt on each change)
23    pub tokens: Vec<Token>,
24    pub interner: Interner,
25    pub diagnostics: Vec<Diagnostic>,
26    pub symbol_index: SymbolIndex,
27    pub type_registry: TypeRegistry,
28    pub policy_registry: PolicyRegistry,
29    pub ownership_states: HashMap<String, VarState>,
30}
31
32impl DocumentState {
33    /// Create a new document state from source text.
34    ///
35    /// Pass a `uri` to enable `DiagnosticRelatedInformation` on analysis errors.
36    pub fn new(source: String, version: i32) -> Self {
37        Self::with_uri(source, version, None)
38    }
39
40    /// Create a new document state with a document URI for richer diagnostics.
41    pub fn with_uri(source: String, version: i32, uri: Option<&Url>) -> Self {
42        let line_index = LineIndex::new(&source);
43
44        let analysis = pipeline::analyze(&source);
45        let diagnostics = build_diagnostics(&analysis, &line_index, uri);
46
47        DocumentState {
48            source,
49            version,
50            line_index,
51            tokens: analysis.tokens,
52            interner: analysis.interner,
53            diagnostics,
54            symbol_index: analysis.symbol_index,
55            type_registry: analysis.type_registry,
56            policy_registry: analysis.policy_registry,
57            ownership_states: analysis.ownership_states,
58        }
59    }
60
61}
62
63/// Apply one LSP content change to a document's text.
64///
65/// A `None` range is a whole-document replacement; a `Some` range is an
66/// incremental edit whose positions are in UTF-16 code units, converted to
67/// byte offsets through a fresh [`LineIndex`] over the current text.
68pub fn apply_content_change(text: &mut String, range: Option<Range>, new_text: &str) {
69    match range {
70        None => {
71            text.clear();
72            text.push_str(new_text);
73        }
74        Some(range) => {
75            let line_index = LineIndex::new(text);
76            let start = line_index.offset(range.start);
77            let end = line_index.offset(range.end);
78            text.replace_range(start..end, new_text);
79        }
80    }
81}
82
83/// Convert every error class one analysis pass produced into LSP diagnostics.
84fn build_diagnostics(
85    analysis: &pipeline::AnalysisResult,
86    line_index: &LineIndex,
87    uri: Option<&Url>,
88) -> Vec<Diagnostic> {
89    let mut diagnostics = crate::diagnostics::convert_errors(
90        &analysis.errors,
91        &analysis.tokens,
92        &analysis.interner,
93        line_index,
94        uri,
95    );
96    diagnostics.extend(crate::diagnostics::convert_analysis_errors(
97        &analysis.escape_errors,
98        &analysis.tokens,
99        &analysis.interner,
100        line_index,
101        uri,
102    ));
103    diagnostics.extend(crate::diagnostics::convert_analysis_errors(
104        &analysis.ownership_errors,
105        &analysis.tokens,
106        &analysis.interner,
107        line_index,
108        uri,
109    ));
110    diagnostics.extend(crate::diagnostics::unused_variable_hints(
111        &analysis.symbol_index,
112        line_index,
113    ));
114    diagnostics.extend(crate::diagnostics::shadowing_warnings(
115        &analysis.symbol_index,
116        line_index,
117        uri,
118    ));
119    diagnostics.extend(crate::diagnostics::unused_function_hints(
120        &analysis.symbol_index,
121        line_index,
122    ));
123    diagnostics
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn new_document_parses_source() {
132        let doc = DocumentState::new("## Main\n    Let x be 5.\n    Show x.\n".to_string(), 1);
133        assert_eq!(doc.version, 1);
134        assert!(doc.diagnostics.is_empty(), "Valid source should have no diagnostics: {:?}", doc.diagnostics);
135        assert!(!doc.tokens.is_empty());
136        assert!(!doc.symbol_index.definitions.is_empty());
137    }
138
139    #[test]
140    fn fresh_snapshot_replaces_analysis() {
141        let doc = DocumentState::new("## Main\n    Let x be 5.\n".to_string(), 1);
142        assert_eq!(doc.symbol_index.definitions_of("x").len(), 1);
143        assert_eq!(doc.symbol_index.definitions_of("y").len(), 0);
144
145        // Documents are immutable snapshots: an edit produces a new one.
146        let doc = DocumentState::new("## Main\n    Let y be 10.\n".to_string(), 2);
147        assert_eq!(doc.version, 2);
148        assert_eq!(doc.symbol_index.definitions_of("y").len(), 1);
149    }
150
151    #[test]
152    fn empty_document() {
153        let doc = DocumentState::new("".to_string(), 0);
154        assert_eq!(doc.version, 0);
155        assert_eq!(doc.source, "");
156    }
157
158    #[test]
159    fn document_source_stored() {
160        let source = "## Main\n    Let x be 5.\n";
161        let doc = DocumentState::new(source.to_string(), 1);
162        assert_eq!(doc.source, source);
163    }
164
165    #[test]
166    fn snapshot_of_broken_source_carries_diagnostics() {
167        let doc = DocumentState::new("## Main\n    Let x be 5.\n    Show x.\n".to_string(), 1);
168        assert!(doc.diagnostics.is_empty(), "Valid source should have no diagnostics");
169        let doc = DocumentState::new("## Main\n    Let be.\n".to_string(), 2);
170        assert!(!doc.diagnostics.is_empty(), "Invalid source should produce diagnostics");
171    }
172
173    #[test]
174    fn line_index_matches_snapshot_source() {
175        let doc = DocumentState::new("line0\nline1\nline2\nline3\nline4\n".to_string(), 2);
176        let pos = doc.line_index.position(doc.source.len().saturating_sub(2));
177        assert_eq!(pos.line, 4, "In a 5-line doc, near-end should be line 4");
178    }
179
180    #[test]
181    fn document_with_move_error_has_diagnostics() {
182        // Give x to y moves x; Show x afterward is use-after-move
183        let source = "## Main\n    Let x be 5.\n    Let y be 0.\n    Give x to y.\n    Show x.\n";
184        let doc = DocumentState::new(source.to_string(), 1);
185        let move_diags: Vec<_> = doc.diagnostics.iter()
186            .filter(|d| {
187                let is_move_code = d.code.as_ref().map_or(false, |c| {
188                    matches!(c, tower_lsp::lsp_types::NumberOrString::String(s) if s == "use-after-move")
189                });
190                let is_move_msg = d.message.contains("after") && d.message.contains("move");
191                is_move_code || is_move_msg
192            })
193            .collect();
194        assert!(
195            !move_diags.is_empty(),
196            "Document with use-after-move should have ownership diagnostics. All diags: {:?}",
197            doc.diagnostics
198        );
199    }
200
201    #[test]
202    fn document_ownership_states_available() {
203        let source = "## Main\n    Let x be 5.\n    Show x.\n";
204        let doc = DocumentState::new(source.to_string(), 1);
205        assert!(
206            !doc.ownership_states.is_empty(),
207            "Document should have ownership states for variables"
208        );
209    }
210}