Skip to main content

logicaffeine_lsp/
completion.rs

1use tower_lsp::lsp_types::{
2    CompletionItem, CompletionItemKind, CompletionItemTag, CompletionResponse, Position,
3};
4
5use logicaffeine_compile::analysis::VarState;
6use logicaffeine_language::token::TokenType;
7
8use crate::document::DocumentState;
9use crate::index::DefinitionKind;
10
11/// Handle completion request.
12///
13/// Provides context-aware completions based on the preceding tokens.
14pub fn completions(doc: &DocumentState, position: Position) -> Option<CompletionResponse> {
15    let offset = doc.line_index.offset(position);
16
17    // Find the preceding token to determine context
18    let prev_token = doc
19        .tokens
20        .iter()
21        .rev()
22        .find(|t| t.span.end <= offset)?;
23
24    let mut items = Vec::new();
25
26    match &prev_token.kind {
27        // After period or at start of line → statement keywords
28        TokenType::Period | TokenType::Indent | TokenType::Newline => {
29            add_statement_keywords(&mut items);
30        }
31
32        // After "Let x be" or similar → expression completions
33        TokenType::Be => {
34            add_expression_completions(doc, &mut items);
35        }
36
37        // After ":" → type completions
38        TokenType::Colon => {
39            add_type_completions(doc, &mut items);
40        }
41
42        // After possessive ('s) → field completions
43        TokenType::Possessive => {
44            // Look back to find the object before possessive
45            add_field_completions(doc, offset, &mut items);
46        }
47
48        // After "Inspect x:" → variant completions
49        TokenType::Inspect => {
50            add_variant_completions(doc, &mut items);
51        }
52
53        // Default → variables and functions in scope
54        _ => {
55            add_identifier_completions(doc, &mut items);
56        }
57    }
58
59    if items.is_empty() {
60        // Fallback: always offer identifiers + keywords
61        add_identifier_completions(doc, &mut items);
62        add_statement_keywords(&mut items);
63    }
64
65    // Stdlib prelude names ride along in every context, after the locals —
66    // additive, so they can never displace the context-specific items above.
67    add_stdlib_completions(doc, &mut items);
68
69    Some(CompletionResponse::Array(items))
70}
71
72fn add_statement_keywords(items: &mut Vec<CompletionItem>) {
73    // Snippet skeletons are editing mechanics and live here; the teaching
74    // text (detail + documentation) comes from the shared lesson table, so
75    // completion, hover, and the REPL always say the same thing.
76    let keywords = [
77        (TokenType::Let, "Let ${1:name} be ${2:value}."),
78        (TokenType::Set, "Set ${1:name} to ${2:value}."),
79        (TokenType::If, "If ${1:condition}:\n    ${2:body}"),
80        (TokenType::While, "While ${1:condition}:\n    ${2:body}"),
81        (TokenType::Repeat, "Repeat for ${1:item} in ${2:collection}:\n    ${3:body}"),
82        (TokenType::Return, "Return ${1:value}."),
83        (TokenType::Show, "Show ${1:value}."),
84        (TokenType::Give, "Give ${1:value} to ${2:target}."),
85        (TokenType::Push, "Push ${1:value} to ${2:list}."),
86        (TokenType::Call, "Call ${1:function} with ${2:args}."),
87        (TokenType::Inspect, "Inspect ${1:value}:\n    ${2:pattern}:\n        ${3:body}"),
88    ];
89
90    for (kind, snippet) in keywords {
91        let lesson = logicaffeine_language::teach::doc_for(&kind)
92            .expect("every statement-keyword completion is taught");
93        items.push(CompletionItem {
94            label: lesson.name.to_string(),
95            kind: Some(CompletionItemKind::KEYWORD),
96            detail: Some(lesson.what.to_string()),
97            documentation: Some(crate::teach_md::completion_docs(lesson)),
98            insert_text: Some(snippet.to_string()),
99            insert_text_format: Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET),
100            ..Default::default()
101        });
102    }
103}
104
105fn add_expression_completions(doc: &DocumentState, items: &mut Vec<CompletionItem>) {
106    // Add variables in scope
107    add_identifier_completions(doc, items);
108
109    // Add "a new Type" constructor
110    for (name, typedef) in doc.type_registry.iter_types() {
111        let type_name = doc.interner.resolve(*name);
112        if matches!(typedef, logicaffeine_language::analysis::TypeDef::Struct { .. }) {
113            items.push(CompletionItem {
114                label: format!("a new {}", type_name),
115                kind: Some(CompletionItemKind::CONSTRUCTOR),
116                detail: Some(format!("Create a new {} instance", type_name)),
117                ..Default::default()
118            });
119        }
120    }
121}
122
123fn add_type_completions(doc: &DocumentState, items: &mut Vec<CompletionItem>) {
124    // Primitive types — each teaches from the shared lesson table.
125    let primitives = ["Int", "Nat", "Text", "Bool", "Float", "Unit", "Char", "Byte"];
126    for prim in primitives {
127        let lesson = logicaffeine_language::teach::doc_for_primitive(prim)
128            .expect("every offered primitive is taught");
129        items.push(CompletionItem {
130            label: prim.to_string(),
131            kind: Some(CompletionItemKind::TYPE_PARAMETER),
132            detail: Some(lesson.what.to_string()),
133            documentation: Some(crate::teach_md::completion_docs(lesson)),
134            ..Default::default()
135        });
136    }
137
138    // Generic types — same table.
139    let generics = ["List", "Seq", "Map", "Set", "Option", "Result"];
140    for gen in generics {
141        let lesson = logicaffeine_language::teach::doc_for_primitive(gen)
142            .expect("every offered generic is taught");
143        items.push(CompletionItem {
144            label: gen.to_string(),
145            kind: Some(CompletionItemKind::CLASS),
146            detail: Some(lesson.what.to_string()),
147            documentation: Some(crate::teach_md::completion_docs(lesson)),
148            ..Default::default()
149        });
150    }
151
152    // User-defined types from registry
153    for (name, _typedef) in doc.type_registry.iter_types() {
154        let type_name = doc.interner.resolve(*name);
155        items.push(CompletionItem {
156            label: type_name.to_string(),
157            kind: Some(CompletionItemKind::CLASS),
158            detail: Some("User-defined type".to_string()),
159            ..Default::default()
160        });
161    }
162}
163
164fn add_field_completions(doc: &DocumentState, offset: usize, items: &mut Vec<CompletionItem>) {
165    // Try to resolve the type of the object before the possessive
166    if let Some(type_name) = resolve_possessive_type(doc, offset) {
167        // Only offer fields from the resolved type
168        for (name, typedef) in doc.type_registry.iter_types() {
169            let tname = doc.interner.resolve(*name);
170            if tname != type_name {
171                continue;
172            }
173            if let logicaffeine_language::analysis::TypeDef::Struct { fields, .. } = typedef {
174                for field in fields {
175                    let field_name = doc.interner.resolve(field.name);
176                    items.push(CompletionItem {
177                        label: field_name.to_string(),
178                        kind: Some(CompletionItemKind::FIELD),
179                        detail: Some(format!("{}: {}", field_name, crate::hover::format_field_type(&field.ty, &doc.interner))),
180                        ..Default::default()
181                    });
182                }
183            }
184        }
185    }
186
187    // Fall back: if no fields were added, show all fields from all structs
188    if items.iter().all(|i| i.kind != Some(CompletionItemKind::FIELD)) {
189        for (_name, typedef) in doc.type_registry.iter_types() {
190            if let logicaffeine_language::analysis::TypeDef::Struct { fields, .. } = typedef {
191                for field in fields {
192                    let field_name = doc.interner.resolve(field.name);
193                    items.push(CompletionItem {
194                        label: field_name.to_string(),
195                        kind: Some(CompletionItemKind::FIELD),
196                        detail: Some(format!("{}", field_name)),
197                        ..Default::default()
198                    });
199                }
200            }
201        }
202    }
203}
204
205/// Look back from the possessive token to find the object's type name.
206fn resolve_possessive_type(doc: &DocumentState, offset: usize) -> Option<String> {
207    // Find the token right before the possessive
208    let prev_token = doc
209        .tokens
210        .iter()
211        .rev()
212        .find(|t| t.span.end <= offset && !matches!(t.kind, TokenType::Possessive))?;
213    let var_name = doc.source.get(prev_token.span.start..prev_token.span.end)?;
214
215    // Look up the variable's definition to find its type
216    let defs = doc.symbol_index.definitions_of(var_name);
217    let def = defs.first()?;
218
219    // Extract type from detail string like "Let x: Point" or "Let x: Point: inferred"
220    let detail = def.detail.as_ref()?;
221    // Match pattern "Let name: TypeName" or "name: TypeName"
222    let after_colon = detail.rsplit_once(": ")?;
223    let type_name = after_colon.1.trim();
224    // Strip trailing ": inferred" or other suffixes
225    let type_name = type_name.split(':').next().unwrap_or(type_name).trim();
226    if type_name.is_empty() || type_name == "inferred" {
227        return None;
228    }
229    Some(type_name.to_string())
230}
231
232fn add_variant_completions(doc: &DocumentState, items: &mut Vec<CompletionItem>) {
233    // Fall back to showing all variants from all enums (type-aware filtering
234    // would require resolving the Inspect target, which we can do in the future)
235    for (_name, typedef) in doc.type_registry.iter_types() {
236        if let logicaffeine_language::analysis::TypeDef::Enum { variants, .. } = typedef {
237            for variant in variants {
238                let variant_name = doc.interner.resolve(variant.name);
239                items.push(CompletionItem {
240                    label: variant_name.to_string(),
241                    kind: Some(CompletionItemKind::ENUM_MEMBER),
242                    ..Default::default()
243                });
244            }
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::document::DocumentState;
253
254    fn make_doc(source: &str) -> DocumentState {
255        DocumentState::new(source.to_string(), 1)
256    }
257
258    #[test]
259    fn completion_returns_items() {
260        let doc = make_doc("## Main\n    Let x be 5.\n");
261        // Position after the period (end of line 1)
262        let pos = Position { line: 1, character: 18 };
263        let result = completions(&doc, pos);
264        assert!(result.is_some(), "Expected completion response");
265        if let Some(CompletionResponse::Array(items)) = result {
266            assert!(!items.is_empty(), "Expected non-empty completions");
267        }
268    }
269
270    #[test]
271    fn completion_includes_keywords_after_period() {
272        // Position right after the period on line 1
273        // The previous token should be Period, which triggers keyword completions
274        let doc = make_doc("## Main\n    Let x be 5.\n    ");
275        // Position at start of empty line 2 (after Newline/Indent)
276        let pos = Position { line: 2, character: 4 };
277        let result = completions(&doc, pos);
278        if let Some(CompletionResponse::Array(items)) = result {
279            let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
280            // After Newline/Indent, should get keywords OR at least fallback with keywords
281            let has_keywords = labels.contains(&"Let") || labels.contains(&"Show");
282            let has_identifiers = labels.contains(&"x");
283            assert!(
284                has_keywords || has_identifiers,
285                "Should include keywords or identifiers: {:?}",
286                labels
287            );
288        }
289    }
290
291    #[test]
292    fn completion_includes_variables_in_default_context() {
293        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
294        // Position right after "Show" (the "x" token) - the previous token is Show
295        // which triggers the default _ path → add_identifier_completions
296        let pos = Position { line: 2, character: 9 };
297        let result = completions(&doc, pos);
298        if let Some(CompletionResponse::Array(items)) = result {
299            let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
300            assert!(labels.contains(&"x"), "Should include variable 'x': {:?}", labels);
301        }
302    }
303
304    #[test]
305    fn completion_no_crash_empty_doc() {
306        let doc = make_doc("");
307        let pos = Position { line: 0, character: 0 };
308        let result = completions(&doc, pos);
309        // Empty doc has no prev_token, so completions returns None
310        // OR if lexer emits an EOF token, it will still return keywords via fallback
311        // Either way, should not panic
312        match result {
313            None => {} // OK
314            Some(CompletionResponse::Array(items)) => {
315                // Keywords are always valid as a fallback
316                assert!(!items.is_empty(), "If result is Some, it should have items");
317            }
318            _ => panic!("Unexpected response type"),
319        }
320    }
321
322    #[test]
323    fn completion_after_colon_type_completions() {
324        let doc = make_doc("## Main\n    Let x: Int be 5.\n");
325        // Position after the colon — find the colon's position
326        // "Let x:" → colon at character 9, so after it at character 10
327        let pos = Position { line: 1, character: 10 };
328        let result = completions(&doc, pos);
329        if let Some(CompletionResponse::Array(items)) = result {
330            let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
331            let has_types = labels.contains(&"Int") || labels.contains(&"Text") || labels.contains(&"Bool");
332            assert!(has_types, "After colon should include type completions: {:?}", labels);
333        }
334    }
335
336    #[test]
337    fn completion_items_have_correct_kind() {
338        let doc = make_doc("## Main\n    Let x be 5.\n    ");
339        let pos = Position { line: 2, character: 4 };
340        let result = completions(&doc, pos);
341        if let Some(CompletionResponse::Array(items)) = result {
342            let keyword_items: Vec<_> = items.iter()
343                .filter(|i| i.label == "Let" || i.label == "Show")
344                .collect();
345            for item in &keyword_items {
346                assert_eq!(item.kind, Some(CompletionItemKind::KEYWORD),
347                    "'{}' should have KEYWORD kind", item.label);
348            }
349        }
350    }
351
352    #[test]
353    fn completion_snippets_have_snippet_format() {
354        let doc = make_doc("## Main\n    ");
355        let pos = Position { line: 1, character: 4 };
356        let result = completions(&doc, pos);
357        if let Some(CompletionResponse::Array(items)) = result {
358            let keyword_items: Vec<_> = items.iter()
359                .filter(|i| i.label == "Let" || i.label == "If")
360                .collect();
361            for item in &keyword_items {
362                assert_eq!(item.insert_text_format,
363                    Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET),
364                    "'{}' should have SNIPPET format", item.label);
365            }
366        }
367    }
368
369    #[test]
370    fn keyword_completions_teach_with_detail_and_documentation() {
371        let doc = make_doc("## Main\n    ");
372        let pos = Position { line: 1, character: 4 };
373        let Some(CompletionResponse::Array(items)) = completions(&doc, pos) else {
374            panic!("expected completions after indent");
375        };
376        for label in [
377            "Let", "Set", "If", "While", "Repeat", "Return", "Show", "Give", "Push", "Call",
378            "Inspect",
379        ] {
380            let item = items
381                .iter()
382                .find(|i| i.label == label && i.kind == Some(CompletionItemKind::KEYWORD))
383                .unwrap_or_else(|| panic!("keyword completion for {label}"));
384            let lesson = logicaffeine_language::teach::doc_for_word(label)
385                .unwrap_or_else(|| panic!("{label} must be taught"));
386            assert_eq!(
387                item.detail.as_deref(),
388                Some(lesson.what),
389                "{label}: completion detail must be the lesson's one-liner"
390            );
391            assert!(
392                item.documentation.is_some(),
393                "{label}: completion must carry teaching documentation"
394            );
395        }
396    }
397
398    #[test]
399    fn primitive_type_completions_teach_from_the_lesson_table() {
400        let doc = make_doc("## Main\n    Let x: Int be 5.\n");
401        // Position right after the colon.
402        let pos = Position { line: 1, character: 10 };
403        let Some(CompletionResponse::Array(items)) = completions(&doc, pos) else {
404            panic!("expected type completions after colon");
405        };
406        let int_item = items
407            .iter()
408            .find(|i| i.label == "Int")
409            .expect("Int must be offered after a colon");
410        let lesson = logicaffeine_language::teach::doc_for_primitive("Int").unwrap();
411        assert_eq!(
412            int_item.detail.as_deref(),
413            Some(lesson.what),
414            "Int detail must come from the lesson table, not a generic placeholder"
415        );
416        assert!(
417            int_item.documentation.is_some(),
418            "Int completion must carry teaching documentation"
419        );
420    }
421
422    #[test]
423    fn documented_function_completion_carries_its_prose() {
424        let doc = make_doc(
425            "## Note\nDoubles a number.\n\n## To double (n: Int) -> Int:\n    Return n * 2.\n\n## Main\nShow x.\n",
426        );
427        // Default context after "Show".
428        let pos = Position { line: 7, character: 5 };
429        let Some(CompletionResponse::Array(items)) = completions(&doc, pos) else {
430            panic!("expected completions");
431        };
432        let item = items
433            .iter()
434            .find(|i| i.label == "double")
435            .expect("double completes");
436        let Some(tower_lsp::lsp_types::Documentation::MarkupContent(content)) =
437            &item.documentation
438        else {
439            panic!("documented function must carry markdown docs");
440        };
441        assert!(content.value.contains("Doubles a number."), "{}", content.value);
442    }
443
444    #[test]
445    fn stdlib_names_complete_with_their_documentation() {
446        let doc = make_doc("## Main\n    Show x.\n");
447        let pos = Position { line: 1, character: 9 };
448        let Some(CompletionResponse::Array(items)) = completions(&doc, pos) else {
449            panic!("expected completions");
450        };
451        let md5 = items.iter().find(|i| i.label == "md5").expect("md5 offered from stdlib");
452        assert_eq!(md5.kind, Some(CompletionItemKind::FUNCTION));
453        assert!(
454            md5.detail.as_deref().is_some_and(|d| d.contains("md5")),
455            "{:?}",
456            md5.detail
457        );
458        assert!(md5.documentation.is_some(), "stdlib completion teaches");
459    }
460
461    #[test]
462    fn variant_completion_no_crash_with_inspect() {
463        // After "Inspect" keyword, should not crash even without a defined enum
464        let doc = make_doc("## Main\n    Let x be 5.\n    Inspect x:\n");
465        let pos = Position { line: 2, character: 14 };
466        let result = completions(&doc, pos);
467        // Should return something (even if empty or keywords) but not panic
468        assert!(result.is_some(), "Should return some completions after Inspect");
469    }
470
471    #[test]
472    fn completion_moved_variable_has_deprecated_tag() {
473        use logicaffeine_compile::analysis::VarState;
474        let mut doc = make_doc("## Main\n    Let x be 5.\n    Let y be 0.\n    Give x to y.\n    Show x.\n");
475        // Ensure x is marked as Moved
476        doc.ownership_states.insert("x".to_string(), VarState::Moved);
477        // Find completions in some context
478        let pos = Position { line: 4, character: 9 };
479        let result = completions(&doc, pos);
480        if let Some(CompletionResponse::Array(items)) = result {
481            let x_items: Vec<_> = items.iter().filter(|i| i.label == "x").collect();
482            if !x_items.is_empty() {
483                assert_eq!(
484                    x_items[0].deprecated,
485                    Some(true),
486                    "Moved variable 'x' should be marked deprecated"
487                );
488                assert!(
489                    x_items[0].tags.as_ref().map_or(false, |t| t.contains(&CompletionItemTag::DEPRECATED)),
490                    "Moved variable should have DEPRECATED tag"
491                );
492                assert!(
493                    x_items[0].detail.as_ref().map_or(false, |d| d.contains("(moved)")),
494                    "Moved variable detail should indicate moved: {:?}",
495                    x_items[0].detail
496                );
497            }
498        }
499    }
500
501    #[test]
502    fn completion_owned_variable_no_tag() {
503        let doc = make_doc("## Main\n    Let x be 5.\n    Show x.\n");
504        let pos = Position { line: 2, character: 9 };
505        let result = completions(&doc, pos);
506        if let Some(CompletionResponse::Array(items)) = result {
507            let x_items: Vec<_> = items.iter().filter(|i| i.label == "x").collect();
508            if !x_items.is_empty() {
509                assert!(
510                    x_items[0].deprecated.is_none() || x_items[0].deprecated == Some(false),
511                    "Owned variable should not be deprecated"
512                );
513            }
514        }
515    }
516}
517
518fn add_identifier_completions(doc: &DocumentState, items: &mut Vec<CompletionItem>) {
519    for def in &doc.symbol_index.definitions {
520        let kind = match def.kind {
521            DefinitionKind::Variable => CompletionItemKind::VARIABLE,
522            DefinitionKind::Function => CompletionItemKind::FUNCTION,
523            DefinitionKind::Struct => CompletionItemKind::CLASS,
524            DefinitionKind::Enum => CompletionItemKind::ENUM,
525            DefinitionKind::Field => CompletionItemKind::FIELD,
526            DefinitionKind::Parameter => CompletionItemKind::VARIABLE,
527            DefinitionKind::Block => CompletionItemKind::MODULE,
528            DefinitionKind::Variant => CompletionItemKind::ENUM_MEMBER,
529            DefinitionKind::Theorem => CompletionItemKind::CLASS,
530        };
531
532        // Check ownership state for moved variables
533        let is_moved = doc.ownership_states.get(&def.name).map_or(false, |state| {
534            matches!(state, VarState::Moved | VarState::MaybeMoved)
535        });
536
537        let mut detail = def.detail.clone();
538        let mut tags = None;
539        let mut deprecated = None;
540        if is_moved {
541            deprecated = Some(true);
542            tags = Some(vec![CompletionItemTag::DEPRECATED]);
543            detail = Some(format!(
544                "{} (moved)",
545                detail.as_deref().unwrap_or(&def.name)
546            ));
547        }
548
549        items.push(CompletionItem {
550            label: def.name.clone(),
551            kind: Some(kind),
552            detail,
553            documentation: def.doc.clone().map(markdown_docs),
554            deprecated,
555            tags,
556            ..Default::default()
557        });
558    }
559}
560
561/// Stdlib prelude names, documented from their literate `## Note`s.
562/// Declarer wins: a local definition of the same name suppresses the
563/// stdlib entry, mirroring the loader's auto-import rule.
564fn add_stdlib_completions(doc: &DocumentState, items: &mut Vec<CompletionItem>) {
565    for (name, entry) in crate::stdlib_docs::all() {
566        if doc.symbol_index.name_to_defs.contains_key(name) {
567            continue;
568        }
569        items.push(CompletionItem {
570            label: name.to_string(),
571            kind: Some(if entry.is_type {
572                CompletionItemKind::CLASS
573            } else {
574                CompletionItemKind::FUNCTION
575            }),
576            detail: Some(entry.signature.trim_start_matches("## ").to_string()),
577            documentation: Some(tower_lsp::lsp_types::Documentation::MarkupContent(
578                tower_lsp::lsp_types::MarkupContent {
579                    kind: tower_lsp::lsp_types::MarkupKind::Markdown,
580                    value: crate::stdlib_docs::hover_md(name, entry),
581                },
582            )),
583            ..Default::default()
584        });
585    }
586}
587
588/// Plain prose as markdown completion documentation.
589fn markdown_docs(value: String) -> tower_lsp::lsp_types::Documentation {
590    tower_lsp::lsp_types::Documentation::MarkupContent(tower_lsp::lsp_types::MarkupContent {
591        kind: tower_lsp::lsp_types::MarkupKind::Markdown,
592        value,
593    })
594}