Files
storybook/src/lsp/completion.rs
Sienna Meridian Satterwhite b3110c8b0f feat(lsp): add completions for type system keywords
Added top-level keyword completions for:
- concept: with semicolon-terminated snippet
- sub_concept: with dot notation snippet
- concept_comparison: with brace-delimited snippet

Added test verifying type system keywords appear in
completions with correct snippet formatting.
2026-02-14 14:32:51 +00:00

775 lines
30 KiB
Rust

//! Autocomplete/completion provider
//!
//! Provides context-aware completion suggestions for:
//! - Keywords (filtered by context)
//! - Entity names (characters, templates, etc.)
//! - Field names (from templates/species when in character block)
//! - Type names (templates/species when after ':')
//! - Enum values
//! - Action names (in behavior trees)
use tower_lsp::lsp_types::{
CompletionItem,
CompletionItemKind,
CompletionList,
CompletionParams,
CompletionResponse,
Documentation,
MarkupContent,
MarkupKind,
};
use super::document::Document;
use crate::syntax::ast::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompletionContext {
/// Top-level of the document
TopLevel,
/// Inside a character/template field block
InFieldBlock,
/// After a colon (expecting a type or value)
AfterColon,
/// Inside a behavior tree
InBehavior,
/// Inside a life arc
InLifeArc,
/// Inside a relationship
InRelationship,
/// Unknown context
Unknown,
}
/// Get completion items at a position
pub fn get_completions(doc: &Document, params: &CompletionParams) -> Option<CompletionResponse> {
let position = params.text_document_position.position;
// Check for field accessor using the specialized function
// It will return Some only if there's an identifier followed by a dot
if let Some(field_items) = get_field_accessor_completions(doc, position) {
return Some(CompletionResponse::List(CompletionList {
is_incomplete: false,
items: field_items,
}));
}
// Convert position to byte offset for context-based completions
let offset = position_to_offset(doc, position.line as usize, position.character as usize)?;
// Check if we're typing a new identifier name after a declaration keyword
if is_typing_declaration_name(&doc.text, offset) {
// Don't show completions when typing a new identifier
return None;
}
// Determine context by analyzing text around cursor
let context = determine_context(&doc.text, offset);
let mut items = Vec::new();
match context {
| CompletionContext::TopLevel => {
// At top level, suggest declaration keywords
items.extend(top_level_keyword_completions());
// Also suggest existing entity names for reference
items.extend(entity_completions(doc));
},
| CompletionContext::InFieldBlock => {
// Inside a field block, suggest fields from the species/templates
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 => {
// After colon, suggest types (templates, species)
items.extend(type_completions(doc));
},
| CompletionContext::InBehavior => {
// In behavior tree, suggest behavior-specific keywords
items.extend(behavior_keyword_completions());
items.extend(behavior_completions(doc)); // Reference to other
// behaviors
},
| CompletionContext::InLifeArc => {
// In life arc, suggest state-related keywords
items.extend(life_arc_keyword_completions());
},
| CompletionContext::InRelationship => {
// In relationship, suggest relationship-specific keywords
items.extend(relationship_keyword_completions());
items.extend(character_completions(doc)); // For participants
},
| CompletionContext::Unknown => {
// When context is unclear, provide all completions
items.extend(all_keyword_completions());
items.extend(entity_completions(doc));
},
}
// Set sort_text for proper ordering: field accessors first (0xxx), then others
// (1xxx)
for item in &mut items {
let detail = item.detail.as_deref().unwrap_or("");
let is_field = detail.contains("field") || detail.contains("trait");
// Field accessors get "0" prefix, others get "1" prefix
let prefix = if is_field { "0" } else { "1" };
item.sort_text = Some(format!("{}{}", prefix, item.label));
}
// Sort by sort_text for consistent ordering
items.sort_by(|a, b| {
let sort_a = a.sort_text.as_deref().unwrap_or(&a.label);
let sort_b = b.sort_text.as_deref().unwrap_or(&b.label);
sort_a.cmp(sort_b)
});
Some(CompletionResponse::Array(items))
}
/// Convert LSP position to byte offset
fn position_to_offset(doc: &Document, line: usize, character: usize) -> Option<usize> {
let line_start = doc.positions.line_offset(line)?;
Some(line_start + character)
}
/// Check if we're typing a new identifier name after a declaration keyword
fn is_typing_declaration_name(text: &str, offset: usize) -> bool {
use crate::syntax::lexer::{
Lexer,
Token,
};
// Get text before cursor (up to 200 chars)
let start = offset.saturating_sub(200);
let before = &text[start..offset.min(text.len())];
// Tokenize using lexer
let lexer = Lexer::new(before);
let tokens: Vec<_> = lexer.collect();
// Check if the last token (or second-to-last if we just typed an identifier)
// is a declaration keyword
if !tokens.is_empty() {
let last_idx = tokens.len() - 1;
// Check last token
if let (_offset, Token::Ident(keyword), _end) = &tokens[last_idx] {
if matches!(
keyword.as_str(),
"character" |
"template" |
"species" |
"behavior" |
"life_arc" |
"relationship" |
"institution" |
"location" |
"enum" |
"schedule"
) {
return true;
}
}
// Check second-to-last token (in case we're in the middle of typing an
// identifier)
if tokens.len() >= 2 {
let second_last_idx = tokens.len() - 2;
if let (_offset, Token::Ident(keyword), _end) = &tokens[second_last_idx] {
if matches!(
keyword.as_str(),
"character" |
"template" |
"species" |
"behavior" |
"life_arc" |
"relationship" |
"institution" |
"location" |
"enum" |
"schedule"
) {
// Make sure the last token is an identifier (not a colon or brace)
if let (_offset, Token::Ident(_), _end) = &tokens[last_idx] {
return true;
}
}
}
}
}
false
}
/// Format a value as its type string for documentation
fn format_value_type(value: &Value) -> String {
match value {
| Value::Identifier(path) => path.join("."),
| Value::Text(_) => "Text".to_string(),
| Value::Number(_) => "Number".to_string(),
| Value::Decimal(_) => "Decimal".to_string(),
| Value::Boolean(_) => "Boolean".to_string(),
| Value::List(items) => {
if items.is_empty() {
"List".to_string()
} else {
format!("[{}]", format_value_type(&items[0]))
}
},
| Value::Object(_) => "Object".to_string(),
| Value::Range(start, end) => {
format!("{}..{}", format_value_type(start), format_value_type(end))
},
| Value::Time(_) => "Time".to_string(),
| Value::Duration(_) => "Duration".to_string(),
| Value::ProseBlock(_) => "ProseBlock".to_string(),
| Value::Override(_) => "Override".to_string(),
| Value::Any => "Any".to_string(),
}
}
/// Get field completions based on the current character/template context
fn get_contextual_field_completions(doc: &Document, offset: usize) -> Option<Vec<CompletionItem>> {
use crate::{
resolve::names::DeclKind,
syntax::ast::Declaration,
};
let ast = doc.ast.as_ref()?;
// Find which declaration contains the cursor offset
for decl in &ast.declarations {
match decl {
| Declaration::Character(character) => {
// Check if cursor is inside this character block
if offset >= character.span.start && offset <= character.span.end {
let mut items = Vec::new();
// Add special keywords
items.push(simple_item(
"from",
"Apply a template",
"from ${1:TemplateName}",
));
items.push(simple_item(
"include",
"Include a template",
"include ${1:TemplateName}",
));
// Add fields from species
if let Some(ref species_name) = character.species {
if let Some(species_entry) = doc.name_table.resolve_name(species_name) {
if species_entry.kind == DeclKind::Species {
for species_decl in &ast.declarations {
if let Declaration::Species(species) = species_decl {
if &species.name == species_name {
for field in &species.fields {
items.push(CompletionItem {
label: format!("{}:", field.name),
kind: Some(CompletionItemKind::FIELD),
detail: Some(format!("({})", species_name)),
insert_text: Some(format!("{}: $0", field.name)),
insert_text_format: Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET),
..Default::default()
});
}
}
}
}
}
}
}
return Some(items);
}
},
| Declaration::Template(template) => {
// Check if cursor is inside this template block
if offset >= template.span.start && offset <= template.span.end {
// Templates can suggest common field patterns
return Some(vec![simple_item(
"include",
"Include a template",
"include ${1:TemplateName}",
)]);
}
},
| _ => {},
}
}
None
}
/// Get field completions when triggered by `.` using lexer
fn get_field_accessor_completions(
doc: &Document,
position: tower_lsp::lsp_types::Position,
) -> Option<Vec<CompletionItem>> {
use crate::{
resolve::names::DeclKind,
syntax::{
ast::Declaration,
lexer::{
Lexer,
Token,
},
},
};
// Lex the line up to the cursor to find the identifier before the dot
let line_offset = doc.positions.line_offset(position.line as usize)?;
let line_end = (line_offset + position.character as usize).min(doc.text.len());
let line_text = &doc.text[line_offset..line_end];
// Lex tokens on this line
let lexer = Lexer::new(line_text);
let tokens: Vec<_> = lexer.collect();
// Check if there's a dot token - if not, this isn't a field accessor
let has_dot = tokens
.iter()
.any(|(_, token, _)| matches!(token, Token::Dot));
if !has_dot {
return None;
}
// Find the last identifier before the last dot
let mut last_ident = None;
for (_start, token, _end) in &tokens {
match token {
| Token::Ident(name) => last_ident = Some(name.clone()),
| Token::Dot => {
// We found a dot - if we have an identifier, that's our target
if last_ident.is_some() {
break;
}
},
| _ => {},
}
}
// If there's a dot but no identifier, return empty list to block keywords
let identifier = match last_ident {
| Some(id) => id,
| None => return Some(Vec::new()),
};
// Look up the identifier - if it fails, still return empty to block keywords
let entry = match doc.name_table.resolve_name(&identifier) {
| Some(e) => e,
| None => return Some(Vec::new()),
};
let ast = match doc.ast.as_ref() {
| Some(a) => a,
| None => return Some(Vec::new()),
};
let mut items = Vec::new();
match entry.kind {
| DeclKind::Character => {
for decl in &ast.declarations {
if let Declaration::Character(character) = decl {
if character.name == identifier {
// Add character's own fields
for field in &character.fields {
let value_type = format_value_type(&field.value);
items.push(CompletionItem {
label: field.name.clone(),
kind: Some(CompletionItemKind::FIELD),
detail: None, // Keep inline display clean
documentation: Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: format!(
"**Field** of `{}`\n\nType: `{}`",
identifier, value_type
),
})),
..Default::default()
});
}
// Add species fields
if let Some(ref species_name) = character.species {
if let Some(species_entry) = doc.name_table.resolve_name(species_name) {
if species_entry.kind == DeclKind::Species {
for decl in &ast.declarations {
if let Declaration::Species(species) = decl {
if &species.name == species_name {
for field in &species.fields {
let value_type =
format_value_type(&field.value);
items.push(CompletionItem {
label: field.name.clone(),
kind: Some(CompletionItemKind::FIELD),
detail: Some(format!("({})", species_name)),
documentation: Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("**Trait** from `{}`\n\nType: `{}`", species_name, value_type),
})),
..Default::default()
});
}
}
}
}
}
}
}
// Add template fields
if let Some(ref template_names) = character.template {
for template_name in template_names {
if let Some(template_entry) =
doc.name_table.resolve_name(template_name)
{
if template_entry.kind == DeclKind::Template {
for decl in &ast.declarations {
if let Declaration::Template(template) = decl {
if &template.name == template_name {
for field in &template.fields {
let value_type =
format_value_type(&field.value);
items.push(CompletionItem {
label: field.name.clone(),
kind: Some(CompletionItemKind::FIELD),
detail: Some(format!("({})", template_name)),
documentation: Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("**Template field** from `{}`\n\nType: `{}`", template_name, value_type),
})),
..Default::default()
});
}
}
}
}
}
}
}
}
break;
}
}
}
},
// For non-character declarations, still return empty list to block keywords
| _ => return Some(Vec::new()),
}
// Always return Some to block keyword completions, even if no fields found
items.sort_by(|a, b| a.label.cmp(&b.label));
Some(items)
}
/// Determine completion context by analyzing tokens around cursor using lexer
fn determine_context(text: &str, offset: usize) -> CompletionContext {
use crate::syntax::lexer::{
Lexer,
Token,
};
// Get text before cursor (up to 500 chars for context)
let start = offset.saturating_sub(500);
let before = &text[start..offset.min(text.len())];
// Tokenize using lexer
let lexer = Lexer::new(before);
let tokens: Vec<_> = lexer.collect();
// Track state by analyzing tokens
let mut nesting_level: i32 = 0;
let mut last_keyword = 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;
}
},
| Token::RBrace => nesting_level = nesting_level.saturating_sub(1),
| Token::Colon => {
// Mark that we've seen a colon
seen_colon_without_brace = true;
},
| Token::Ident(keyword)
if matches!(
keyword.as_str(),
"character" |
"template" |
"species" |
"behavior" |
"life_arc" |
"relationship" |
"institution" |
"location" |
"enum" |
"schedule"
) =>
{
last_keyword = Some(keyword.clone());
seen_colon_without_brace = false;
},
| _ => {},
}
}
// If we saw a colon without a brace after it, we're in type position
if seen_colon_without_brace {
return CompletionContext::AfterColon;
}
// At top level if no nesting
if nesting_level == 0 {
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,
}
}
/// Get entity completions (all symbols)
fn entity_completions(doc: &Document) -> Vec<CompletionItem> {
use crate::resolve::names::DeclKind;
let mut items = Vec::new();
for entry in doc.name_table.all_entries() {
let kind = match entry.kind {
| DeclKind::Character => CompletionItemKind::CLASS,
| DeclKind::Template => CompletionItemKind::INTERFACE,
| DeclKind::LifeArc => CompletionItemKind::FUNCTION,
| DeclKind::Schedule => CompletionItemKind::EVENT,
| DeclKind::Behavior => CompletionItemKind::MODULE,
| DeclKind::Institution => CompletionItemKind::MODULE,
| DeclKind::Relationship => CompletionItemKind::STRUCT,
| DeclKind::Location => CompletionItemKind::CONSTANT,
| DeclKind::Species => CompletionItemKind::CLASS,
};
let name = entry
.qualified_path
.last()
.unwrap_or(&String::new())
.clone();
items.push(CompletionItem {
label: name,
kind: Some(kind),
detail: Some(format!("{:?}", entry.kind)),
..Default::default()
});
}
items
}
/// Get type completions (templates and species)
fn type_completions(doc: &Document) -> Vec<CompletionItem> {
use crate::resolve::names::DeclKind;
let mut items = Vec::new();
for entry in doc.name_table.all_entries() {
match entry.kind {
| DeclKind::Template | DeclKind::Species => {
let name = entry
.qualified_path
.last()
.unwrap_or(&String::new())
.clone();
items.push(CompletionItem {
label: name,
kind: Some(CompletionItemKind::INTERFACE),
detail: Some(format!("{:?}", entry.kind)),
documentation: Some(Documentation::String("Type annotation".to_string())),
..Default::default()
});
},
| _ => {},
}
}
items
}
/// Get behavior completions
fn behavior_completions(doc: &Document) -> Vec<CompletionItem> {
use crate::resolve::names::DeclKind;
let mut items = Vec::new();
for entry in doc.name_table.entries_of_kind(DeclKind::Behavior) {
let name = entry
.qualified_path
.last()
.unwrap_or(&String::new())
.clone();
items.push(CompletionItem {
label: format!("@{}", name),
kind: Some(CompletionItemKind::REFERENCE),
detail: Some("Behavior tree reference".to_string()),
insert_text: Some(format!("@{}", name)),
..Default::default()
});
}
items
}
/// Get character completions
fn character_completions(doc: &Document) -> Vec<CompletionItem> {
use crate::resolve::names::DeclKind;
let mut items = Vec::new();
for entry in doc.name_table.entries_of_kind(DeclKind::Character) {
let name = entry
.qualified_path
.last()
.unwrap_or(&String::new())
.clone();
items.push(CompletionItem {
label: name,
kind: Some(CompletionItemKind::CLASS),
detail: Some("Character".to_string()),
..Default::default()
});
}
items
}
/// Get all keyword completions (fallback)
fn all_keyword_completions() -> Vec<CompletionItem> {
let mut items = top_level_keyword_completions();
items.extend(field_keyword_completions());
items.extend(behavior_keyword_completions());
items.extend(life_arc_keyword_completions());
items.extend(relationship_keyword_completions());
items
}
/// Get top-level declaration keywords
fn top_level_keyword_completions() -> Vec<CompletionItem> {
vec![
keyword_item("character", "Define a character entity", "character ${1:Name}: ${2:Species} {\n $0\n}"),
keyword_item("template", "Define a reusable field template", "template ${1:Name} {\n $0\n}"),
keyword_item("life_arc", "Define a state machine", "life_arc ${1:Name} {\n state ${2:initial} {\n $0\n }\n}"),
keyword_item("schedule", "Define a daily schedule", "schedule ${1:Name} {\n ${2:08:00} -> ${3:09:00}: ${4:block_name} {\n $0\n }\n}"),
keyword_item("behavior", "Define a behavior tree", "behavior ${1:Name} {\n $0\n}"),
keyword_item("institution", "Define an organization", "institution ${1:Name} {\n $0\n}"),
keyword_item("relationship", "Define a relationship", "relationship ${1:Name} {\n $0\n}"),
keyword_item("location", "Define a location", "location ${1:Name} {\n $0\n}"),
keyword_item("species", "Define a species", "species ${1:Name} {\n $0\n}"),
keyword_item("use", "Import declarations", "use ${1:path::to::item};"),
keyword_item("concept", "Define an algebraic data type", "concept ${1:Name};"),
keyword_item("sub_concept", "Define a sub-type", "sub_concept ${1:Parent}.${2:Name} {\n $0\n}"),
keyword_item("concept_comparison", "Pattern match on concepts", "concept_comparison ${1:Name} {\n $0\n}"),
]
}
/// Get field-level keywords
fn field_keyword_completions() -> Vec<CompletionItem> {
vec![
keyword_item("from", "Apply a template", "from ${1:TemplateName}"),
keyword_item("include", "Include a template", "include ${1:TemplateName}"),
keyword_item("strict", "Enforce strict template fields", "strict"),
// Common field names
simple_item("age", "Age field", "age: ${1:0}"),
simple_item("name", "Name field", "name: \"${1:Name}\""),
simple_item("bond", "Bond trait (0.0-1.0)", "bond: ${1:0.5}"),
simple_item("trust", "Trust trait (0.0-1.0)", "trust: ${1:0.5}"),
simple_item("love", "Love trait (0.0-1.0)", "love: ${1:0.5}"),
]
}
/// Get behavior tree keywords
fn behavior_keyword_completions() -> Vec<CompletionItem> {
vec![
keyword_item(
"?",
"Selector node (try options in order)",
"? {\n $0\n}",
),
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}"),
]
}
/// Get life arc keywords
fn life_arc_keyword_completions() -> Vec<CompletionItem> {
vec![
keyword_item(
"state",
"Define a life arc state",
"state ${1:name} {\n $0\n}",
),
keyword_item(
"on",
"Define a transition",
"on ${1:condition} -> ${2:target_state}",
),
]
}
/// Get relationship keywords
fn relationship_keyword_completions() -> Vec<CompletionItem> {
vec![
keyword_item(
"as",
"Define participant role",
"${1:CharacterName} as ${2:role} {\n $0\n}",
),
keyword_item("self", "Reference self in relationships", "self.${1:field}"),
keyword_item("other", "Reference other participant", "other.${1:field}"),
]
}
fn keyword_item(label: &str, detail: &str, snippet: &str) -> CompletionItem {
CompletionItem {
label: label.to_string(),
kind: Some(CompletionItemKind::KEYWORD),
detail: Some(detail.to_string()),
documentation: Some(Documentation::MarkupContent(MarkupContent {
kind: MarkupKind::Markdown,
value: format!("**{}**\n\n{}", label, detail),
})),
insert_text: Some(snippet.to_string()),
insert_text_format: Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET),
..Default::default()
}
}
fn simple_item(label: &str, detail: &str, snippet: &str) -> CompletionItem {
CompletionItem {
label: label.to_string(),
kind: Some(CompletionItemKind::PROPERTY),
detail: Some(detail.to_string()),
insert_text: Some(snippet.to_string()),
insert_text_format: Some(tower_lsp::lsp_types::InsertTextFormat::SNIPPET),
..Default::default()
}
}