From dcc27a69887d8965bb6402e2b4f60f7a31827e24 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Mon, 23 Feb 2026 21:00:03 +0000 Subject: [PATCH] fix(lsp): context-aware completions now use correct token variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Logos lexer produces dedicated token variants for all keywords (Token::Behavior, Token::LifeArc, etc.) — Token::Ident is only for user-defined identifiers. determine_context() was matching against Token::Ident(keyword), so last_keyword was always None and every nested block returned CompletionContext::Unknown, causing all_keyword_completions() to dump top-level declaration keywords everywhere. Changes: - determine_context(): match actual token variants (Token::Behavior, Token::LifeArc, Token::Relationship, Token::Character, etc.) and track last_context directly instead of a String keyword - behavior_keyword_completions(): add all word-based BT keywords (choose, then, repeat, if, when, invert, retry, timeout, cooldown, succeed_always, fail_always) - InFieldBlock arm: always include field_keyword_completions() so generic fields (age, bond, etc.) appear even without a species - Tests: add negative assertions confirming top-level keywords are absent inside behavior/life_arc blocks; add new test test_behavior_no_toplevel_keywords for nested block isolation --- src/lsp/completion.rs | 97 ++++++++++++++++++++++--------------- src/lsp/completion_tests.rs | 80 +++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 41 deletions(-) diff --git a/src/lsp/completion.rs b/src/lsp/completion.rs index 75eaae4..22a2c5d 100644 --- a/src/lsp/completion.rs +++ b/src/lsp/completion.rs @@ -75,12 +75,11 @@ pub fn get_completions(doc: &Document, params: &CompletionParams) -> Option { - // Inside a field block, suggest fields from the species/templates + // Always include generic field keywords (age, bond, from, etc.) + items.extend(field_keyword_completions()); + // Also include contextual species/template fields if available if let Some(species_fields) = get_contextual_field_completions(doc, offset) { items.extend(species_fields); - } else { - // Fallback to generic field keywords if we can't determine context - items.extend(field_keyword_completions()); } }, | CompletionContext::AfterColon => { @@ -485,39 +484,35 @@ fn determine_context(text: &str, offset: usize) -> CompletionContext { // Track state by analyzing tokens let mut nesting_level: i32 = 0; - let mut last_keyword = None; + let mut last_context: Option = None; let mut seen_colon_without_brace = false; for (_offset, token, _end) in &tokens { match token { | Token::LBrace => { nesting_level += 1; - if seen_colon_without_brace { - // Opening brace after colon - we've entered the block - seen_colon_without_brace = false; - } + seen_colon_without_brace = false; }, | Token::RBrace => nesting_level = nesting_level.saturating_sub(1), - | Token::Colon => { - // Mark that we've seen a colon - seen_colon_without_brace = true; + | Token::Colon => seen_colon_without_brace = true, + | Token::Behavior => { + last_context = Some(CompletionContext::InBehavior); + seen_colon_without_brace = false; }, - | Token::Ident(keyword) - if matches!( - keyword.as_str(), - "character" | - "template" | - "species" | - "behavior" | - "life_arc" | - "relationship" | - "institution" | - "location" | - "enum" | - "schedule" - ) => - { - last_keyword = Some(keyword.clone()); + | Token::LifeArc => { + last_context = Some(CompletionContext::InLifeArc); + seen_colon_without_brace = false; + }, + | Token::Relationship => { + last_context = Some(CompletionContext::InRelationship); + seen_colon_without_brace = false; + }, + | Token::Character | + Token::Template | + Token::Species | + Token::Institution | + Token::Location => { + last_context = Some(CompletionContext::InFieldBlock); seen_colon_without_brace = false; }, | _ => {}, @@ -534,18 +529,7 @@ fn determine_context(text: &str, offset: usize) -> CompletionContext { return CompletionContext::TopLevel; } - // Determine context based on last keyword and nesting - match last_keyword.as_deref() { - | Some("behavior") if nesting_level > 0 => CompletionContext::InBehavior, - | Some("life_arc") if nesting_level > 0 => CompletionContext::InLifeArc, - | Some("relationship") if nesting_level > 0 => CompletionContext::InRelationship, - | Some("character" | "template" | "species" | "institution" | "location") - if nesting_level > 0 => - { - CompletionContext::InFieldBlock - }, - | _ => CompletionContext::Unknown, - } + last_context.unwrap_or(CompletionContext::Unknown) } /// Get entity completions (all symbols) @@ -707,6 +691,7 @@ fn field_keyword_completions() -> Vec { /// Get behavior tree keywords fn behavior_keyword_completions() -> Vec { vec![ + // Symbolic syntax keyword_item( "?", "Selector node (try options in order)", @@ -715,6 +700,38 @@ fn behavior_keyword_completions() -> Vec { keyword_item(">", "Sequence node (execute in order)", "> {\n $0\n}"), keyword_item("*", "Repeat node (loop forever)", "* {\n $0\n}"), simple_item("@", "Subtree reference", "@${1:behavior::name}"), + // Word-based syntax + keyword_item( + "choose", + "Selector — try options in order", + "choose {\n $0\n}", + ), + keyword_item("then", "Sequence — execute in order", "then {\n $0\n}"), + keyword_item("repeat", "Repeat node", "repeat {\n $0\n}"), + keyword_item( + "if", + "Conditional decorator", + "if(${1:condition}) {\n $0\n}", + ), + keyword_item("when", "Condition node", "when(${1:condition})"), + keyword_item("invert", "Invert result", "invert {\n $0\n}"), + keyword_item("retry", "Retry decorator", "retry(${1:3}) {\n $0\n}"), + keyword_item( + "timeout", + "Timeout decorator", + "timeout(${1:5s}) {\n $0\n}", + ), + keyword_item( + "cooldown", + "Cooldown decorator", + "cooldown(${1:10s}) {\n $0\n}", + ), + keyword_item( + "succeed_always", + "Always succeed", + "succeed_always {\n $0\n}", + ), + keyword_item("fail_always", "Always fail", "fail_always {\n $0\n}"), ] } diff --git a/src/lsp/completion_tests.rs b/src/lsp/completion_tests.rs index fb46a45..c1b5d9f 100644 --- a/src/lsp/completion_tests.rs +++ b/src/lsp/completion_tests.rs @@ -110,10 +110,33 @@ mod tests { if let Some(response) = result { match response { | tower_lsp::lsp_types::CompletionResponse::Array(items) => { - // Should have behavior tree keywords + // Should have symbolic behavior tree keywords assert!(items.iter().any(|item| item.label == "?")); assert!(items.iter().any(|item| item.label == ">")); assert!(items.iter().any(|item| item.label == "*")); + // Should have word-based behavior tree keywords + assert!(items.iter().any(|item| item.label == "choose")); + assert!(items.iter().any(|item| item.label == "then")); + assert!(items.iter().any(|item| item.label == "repeat")); + assert!(items.iter().any(|item| item.label == "if")); + assert!(items.iter().any(|item| item.label == "when")); + // Should NOT have top-level declaration keywords + assert!( + !items.iter().any(|item| item.label == "institution"), + "institution should not appear inside behavior block" + ); + assert!( + !items.iter().any(|item| item.label == "species"), + "species should not appear inside behavior block" + ); + assert!( + !items.iter().any(|item| item.label == "character"), + "character should not appear inside behavior block" + ); + assert!( + !items.iter().any(|item| item.label == "location"), + "location should not appear inside behavior block" + ); }, | _ => panic!("Expected array response"), } @@ -136,6 +159,61 @@ mod tests { // Should have life arc keywords assert!(items.iter().any(|item| item.label == "state")); assert!(items.iter().any(|item| item.label == "on")); + // Should NOT have top-level declaration keywords + assert!( + !items.iter().any(|item| item.label == "institution"), + "institution should not appear inside life_arc block" + ); + assert!( + !items.iter().any(|item| item.label == "behavior"), + "behavior should not appear inside life_arc block" + ); + assert!( + !items.iter().any(|item| item.label == "character"), + "character should not appear inside life_arc block" + ); + }, + | _ => panic!("Expected array response"), + } + } + } + + #[test] + fn test_behavior_no_toplevel_keywords() { + // Cursor inside nested `then` block — context should remain InBehavior + let source = "behavior Test {\n then {\n \n }\n}"; + let doc = Document::new(source.to_string()); + // Position inside nested then block (line 2, after spaces) + let params = make_params(2, 8); + + let result = completion::get_completions(&doc, ¶ms); + assert!(result.is_some()); + + if let Some(response) = result { + match response { + | tower_lsp::lsp_types::CompletionResponse::Array(items) => { + // Behavior keywords should still be present at any nesting depth + assert!( + items.iter().any(|item| item.label == "?"), + "? should appear in nested behavior block" + ); + assert!( + items.iter().any(|item| item.label == "choose"), + "choose should appear in nested behavior block" + ); + // Top-level keywords must not appear + assert!( + !items.iter().any(|item| item.label == "institution"), + "institution must not appear in nested behavior block" + ); + assert!( + !items.iter().any(|item| item.label == "species"), + "species must not appear in nested behavior block" + ); + assert!( + !items.iter().any(|item| item.label == "character"), + "character must not appear in nested behavior block" + ); }, | _ => panic!("Expected array response"), }