fix(lsp): context-aware completions now use correct token variants

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
This commit is contained in:
2026-02-23 21:00:03 +00:00
parent dec79fe9e5
commit dcc27a6988
2 changed files with 136 additions and 41 deletions

View File

@@ -75,12 +75,11 @@ pub fn get_completions(doc: &Document, params: &CompletionParams) -> Option<Comp
items.extend(entity_completions(doc)); items.extend(entity_completions(doc));
}, },
| CompletionContext::InFieldBlock => { | CompletionContext::InFieldBlock => {
// 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) { if let Some(species_fields) = get_contextual_field_completions(doc, offset) {
items.extend(species_fields); items.extend(species_fields);
} else {
// Fallback to generic field keywords if we can't determine context
items.extend(field_keyword_completions());
} }
}, },
| CompletionContext::AfterColon => { | CompletionContext::AfterColon => {
@@ -485,39 +484,35 @@ fn determine_context(text: &str, offset: usize) -> CompletionContext {
// Track state by analyzing tokens // Track state by analyzing tokens
let mut nesting_level: i32 = 0; let mut nesting_level: i32 = 0;
let mut last_keyword = None; let mut last_context: Option<CompletionContext> = None;
let mut seen_colon_without_brace = false; let mut seen_colon_without_brace = false;
for (_offset, token, _end) in &tokens { for (_offset, token, _end) in &tokens {
match token { match token {
| Token::LBrace => { | Token::LBrace => {
nesting_level += 1; nesting_level += 1;
if seen_colon_without_brace { seen_colon_without_brace = false;
// Opening brace after colon - we've entered the block
seen_colon_without_brace = false;
}
}, },
| Token::RBrace => nesting_level = nesting_level.saturating_sub(1), | Token::RBrace => nesting_level = nesting_level.saturating_sub(1),
| Token::Colon => { | Token::Colon => seen_colon_without_brace = true,
// Mark that we've seen a colon | Token::Behavior => {
seen_colon_without_brace = true; last_context = Some(CompletionContext::InBehavior);
seen_colon_without_brace = false;
}, },
| Token::Ident(keyword) | Token::LifeArc => {
if matches!( last_context = Some(CompletionContext::InLifeArc);
keyword.as_str(), seen_colon_without_brace = false;
"character" | },
"template" | | Token::Relationship => {
"species" | last_context = Some(CompletionContext::InRelationship);
"behavior" | seen_colon_without_brace = false;
"life_arc" | },
"relationship" | | Token::Character |
"institution" | Token::Template |
"location" | Token::Species |
"enum" | Token::Institution |
"schedule" Token::Location => {
) => last_context = Some(CompletionContext::InFieldBlock);
{
last_keyword = Some(keyword.clone());
seen_colon_without_brace = false; seen_colon_without_brace = false;
}, },
| _ => {}, | _ => {},
@@ -534,18 +529,7 @@ fn determine_context(text: &str, offset: usize) -> CompletionContext {
return CompletionContext::TopLevel; return CompletionContext::TopLevel;
} }
// Determine context based on last keyword and nesting last_context.unwrap_or(CompletionContext::Unknown)
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,
}
} }
/// Get entity completions (all symbols) /// Get entity completions (all symbols)
@@ -707,6 +691,7 @@ fn field_keyword_completions() -> Vec<CompletionItem> {
/// Get behavior tree keywords /// Get behavior tree keywords
fn behavior_keyword_completions() -> Vec<CompletionItem> { fn behavior_keyword_completions() -> Vec<CompletionItem> {
vec![ vec![
// Symbolic syntax
keyword_item( keyword_item(
"?", "?",
"Selector node (try options in order)", "Selector node (try options in order)",
@@ -715,6 +700,38 @@ fn behavior_keyword_completions() -> Vec<CompletionItem> {
keyword_item(">", "Sequence node (execute in order)", "> {\n $0\n}"), keyword_item(">", "Sequence node (execute in order)", "> {\n $0\n}"),
keyword_item("*", "Repeat node (loop forever)", "* {\n $0\n}"), keyword_item("*", "Repeat node (loop forever)", "* {\n $0\n}"),
simple_item("@", "Subtree reference", "@${1:behavior::name}"), 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}"),
] ]
} }

View File

@@ -110,10 +110,33 @@ mod tests {
if let Some(response) = result { if let Some(response) = result {
match response { match response {
| tower_lsp::lsp_types::CompletionResponse::Array(items) => { | 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 == ">")); 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"), | _ => panic!("Expected array response"),
} }
@@ -136,6 +159,61 @@ mod tests {
// Should have life arc keywords // Should have life arc keywords
assert!(items.iter().any(|item| item.label == "state")); assert!(items.iter().any(|item| item.label == "state"));
assert!(items.iter().any(|item| item.label == "on")); 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, &params);
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"), | _ => panic!("Expected array response"),
} }