feat(type-system): implement concept_comparison with pattern matching

Added complete support for the new type system syntax including:

- concept: Base type declarations
- sub_concept: Enum and record sub-type definitions
- concept_comparison: Compile-time pattern matching with conditional guards

Parser changes:
- Added VariantPattern, FieldCondition, and Condition AST nodes
- Implemented "is" keyword for pattern matching (e.g., "CupType is Glass or CupType is Plastic")
- Added Value::Any variant to support universal type matching
- Disambiguated enum-like vs record-like sub_concept syntax

LSP updates:
- Added Value::Any match arms across code_actions, completion, hover, inlay_hints, and semantic_tokens
- Type inference and formatting support for Any values

Example fixes:
- Fixed syntax error in baker-family behaviors (missing closing brace in nested if)
- Removed deprecated core_enums.sb file
This commit is contained in:
2026-02-14 09:28:20 +00:00
parent 6e3b35e68f
commit 25d59d6107
30 changed files with 8639 additions and 6536 deletions

View File

@@ -272,7 +272,6 @@ fn create_missing_symbol(
| DeclKind::Character => format!("character {} {{}}\n\n", symbol_name),
| DeclKind::Template => format!("template {} {{}}\n\n", symbol_name),
| DeclKind::Species => format!("species {} {{}}\n\n", symbol_name),
| DeclKind::Enum => format!("enum {} {{}}\n\n", symbol_name),
| DeclKind::Location => format!("location {} {{}}\n\n", symbol_name),
| _ => continue,
};
@@ -300,7 +299,6 @@ fn create_missing_symbol(
| DeclKind::Character => "character",
| DeclKind::Template => "template",
| DeclKind::Species => "species",
| DeclKind::Enum => "enum",
| DeclKind::Location => "location",
| _ => continue,
};
@@ -522,7 +520,6 @@ fn remove_unused_symbol(
| DeclKind::Character => "character",
| DeclKind::Template => "template",
| DeclKind::Species => "species",
| DeclKind::Enum => "enum",
| DeclKind::Location => "location",
| _ => "symbol",
};
@@ -1276,7 +1273,6 @@ fn sort_declarations(uri: &Url, doc: &Document, _range: Range) -> Option<CodeAct
| Declaration::Template(t) => templates.push((t.name.clone(), decl)),
| Declaration::Species(s) => species.push((s.name.clone(), decl)),
| Declaration::Character(c) => characters.push((c.name.clone(), decl)),
| Declaration::Enum(e) => enums.push((e.name.clone(), decl)),
| Declaration::Location(l) => locations.push((l.name.clone(), decl)),
| Declaration::Institution(i) => institutions.push((i.name.clone(), decl)),
| Declaration::Relationship(r) => relationships.push((r.name.clone(), decl)),
@@ -1696,7 +1692,6 @@ fn get_declaration_name(decl: &crate::syntax::ast::Declaration) -> String {
| Declaration::Template(t) => t.name.clone(),
| Declaration::Species(s) => s.name.clone(),
| Declaration::Character(c) => c.name.clone(),
| Declaration::Enum(e) => e.name.clone(),
| Declaration::Location(l) => l.name.clone(),
| Declaration::Institution(i) => i.name.clone(),
| Declaration::Relationship(r) => r.name.clone(),
@@ -1717,7 +1712,6 @@ fn get_declaration_type_name(decl: &crate::syntax::ast::Declaration) -> &'static
| Declaration::Template(_) => "Template",
| Declaration::Species(_) => "Species",
| Declaration::Character(_) => "Character",
| Declaration::Enum(_) => "Enum",
| Declaration::Location(_) => "Location",
| Declaration::Institution(_) => "Institution",
| Declaration::Relationship(_) => "Relationship",
@@ -1951,7 +1945,6 @@ fn get_declaration_span(decl: &crate::syntax::ast::Declaration) -> Span {
| Declaration::Template(t) => t.span.clone(),
| Declaration::Species(s) => s.span.clone(),
| Declaration::Character(c) => c.span.clone(),
| Declaration::Enum(e) => e.span.clone(),
| Declaration::Location(l) => l.span.clone(),
| Declaration::Institution(i) => i.span.clone(),
| Declaration::Relationship(r) => r.span.clone(),
@@ -2109,58 +2102,13 @@ fn fix_trait_out_of_range(
/// Fix unknown behavior action
fn fix_unknown_behavior_action(
uri: &Url,
_uri: &Url,
_doc: &Document,
diagnostic: &Diagnostic,
_diagnostic: &Diagnostic,
) -> Vec<CodeActionOrCommand> {
let mut actions = Vec::new();
// Extract action name from diagnostic message
let action_name = extract_quoted_name(&diagnostic.message);
if action_name.is_none() {
return actions;
}
let action_name = action_name.unwrap();
// Offer to create an enum with this action
// Insert at beginning of file
let enum_text = format!("enum Action {{\n {}\n}}\n\n", action_name);
let mut changes = HashMap::new();
changes.insert(
uri.clone(),
vec![TextEdit {
range: Range {
start: Position {
line: 0,
character: 0,
},
end: Position {
line: 0,
character: 0,
},
},
new_text: enum_text,
}],
);
let action = CodeAction {
title: format!("Create Action enum with '{}'", action_name),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![diagnostic.clone()]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
document_changes: None,
change_annotations: None,
}),
command: None,
is_preferred: Some(true),
disabled: None,
data: None,
};
actions.push(CodeActionOrCommand::CodeAction(action));
actions
// TODO: Re-implement using concept/sub_concept system instead of enum
// For now, return no code actions
Vec::new()
}
/// Fix schedule overlap
@@ -2451,6 +2399,7 @@ fn get_default_value_for_type(field_type: &crate::syntax::ast::Value) -> String
| Value::Duration(_) => String::from("0h"),
| Value::ProseBlock(_) => String::from("@tag \"\""),
| Value::Override(_) => String::from("Base {}"),
| Value::Any => String::from("any"),
}
}
@@ -2485,6 +2434,7 @@ fn format_value(value: &crate::syntax::ast::Value) -> String {
// Format override as "BaseTemplate { overrides }"
format!("{} {{ ... }}", o.base.join("."))
},
| Value::Any => String::from("any"),
}
}
@@ -2511,6 +2461,7 @@ fn infer_type_from_value(value: &crate::syntax::ast::Value) -> String {
| Value::Duration(_) => String::from("Duration"),
| Value::ProseBlock(_) => String::from("ProseBlock"),
| Value::Override(_) => String::from("Override"),
| Value::Any => String::from("Any"),
}
}

View File

@@ -1300,62 +1300,6 @@ character Alice {
.any(|t| t.contains("Change to minimum value (0)")));
}
#[test]
fn test_fix_unknown_behavior_action() {
let source = r#"
behavior tree {
Run
}
"#;
let uri = Url::parse("file:///test.sb").unwrap();
let documents = make_documents(vec![("file:///test.sb", source)]);
let diagnostic = make_diagnostic(
"unknown action 'Run'",
Range {
start: Position {
line: 2,
character: 2,
},
end: Position {
line: 2,
character: 5,
},
},
);
let params = CodeActionParams {
text_document: TextDocumentIdentifier { uri: uri.clone() },
range: diagnostic.range,
context: CodeActionContext {
diagnostics: vec![diagnostic.clone()],
only: None,
trigger_kind: None,
},
work_done_progress_params: Default::default(),
partial_result_params: Default::default(),
};
let result = get_code_actions(&documents, &params);
assert!(result.is_some());
let actions = result.unwrap();
let titles: Vec<String> = actions
.iter()
.filter_map(|a| {
if let tower_lsp::lsp_types::CodeActionOrCommand::CodeAction(action) = a {
Some(action.title.clone())
} else {
None
}
})
.collect();
assert!(titles
.iter()
.any(|t| t.contains("Create Action enum with 'Run'")));
}
#[test]
fn test_fix_schedule_overlap() {
let source = r#"

View File

@@ -228,6 +228,7 @@ fn format_value_type(value: &Value) -> String {
| Value::Duration(_) => "Duration".to_string(),
| Value::ProseBlock(_) => "ProseBlock".to_string(),
| Value::Override(_) => "Override".to_string(),
| Value::Any => "Any".to_string(),
}
}
@@ -564,7 +565,6 @@ fn entity_completions(doc: &Document) -> Vec<CompletionItem> {
| DeclKind::Relationship => CompletionItemKind::STRUCT,
| DeclKind::Location => CompletionItemKind::CONSTANT,
| DeclKind::Species => CompletionItemKind::CLASS,
| DeclKind::Enum => CompletionItemKind::ENUM,
};
let name = entry
@@ -682,7 +682,6 @@ fn top_level_keyword_completions() -> Vec<CompletionItem> {
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("enum", "Define an enumeration", "enum ${1:Name} {\n ${2:Value1}\n ${3:Value2}\n}"),
keyword_item("use", "Import declarations", "use ${1:path::to::item};"),
]
}

View File

@@ -219,8 +219,7 @@ impl Document {
Token::Institution |
Token::Relationship |
Token::Location |
Token::Species |
Token::Enum
Token::Species
);
let is_identifier = matches!(tok, Token::Ident(_));
@@ -246,8 +245,7 @@ impl Document {
Token::Institution |
Token::Relationship |
Token::Location |
Token::Species |
Token::Enum => {
Token::Species => {
decl_keyword = Some(token);
break;
},
@@ -268,7 +266,6 @@ impl Document {
| Token::Relationship => "relationship",
| Token::Location => "location",
| Token::Species => "species",
| Token::Enum => "enum",
| _ => "declaration",
};
let decl_name =

View File

@@ -116,7 +116,6 @@ character Alice { age: 8 }
species Human {}
character Alice: Human {}
template Child {}
enum Mood { Happy, Sad }
location Home {}
relationship Friends { Alice as friend {} Bob as friend {} }
"#;
@@ -125,7 +124,6 @@ relationship Friends { Alice as friend {} Bob as friend {} }
assert!(doc.name_table.resolve_name("Human").is_some());
assert!(doc.name_table.resolve_name("Alice").is_some());
assert!(doc.name_table.resolve_name("Child").is_some());
assert!(doc.name_table.resolve_name("Mood").is_some());
assert!(doc.name_table.resolve_name("Home").is_some());
assert!(doc.name_table.resolve_name("Friends").is_some());
}

View File

@@ -92,7 +92,6 @@ fn get_token_documentation(token: &Token) -> Option<&'static str> {
Token::Relationship => Some("**relationship** - Defines a multi-party relationship\n\nSyntax: `relationship Name { ... }`"),
Token::Location => Some("**location** - Defines a place or setting\n\nSyntax: `location Name { ... }`"),
Token::Species => Some("**species** - Defines a species with templates\n\nSyntax: `species Name { ... }`"),
Token::Enum => Some("**enum** - Defines an enumeration type\n\nSyntax: `enum Name { ... }`"),
Token::Use => Some("**use** - Imports declarations from other files\n\nSyntax: `use path::to::item;`"),
Token::From => Some("**from** - Applies templates to a character\n\nSyntax: `character Name from Template { ... }`"),
Token::Include => Some("**include** - Includes another template\n\nSyntax: `include TemplateName`"),
@@ -171,7 +170,6 @@ fn get_declaration_name(decl: &Declaration) -> Option<String> {
| Declaration::Character(c) => Some(c.name.clone()),
| Declaration::Template(t) => Some(t.name.clone()),
| Declaration::Species(s) => Some(s.name.clone()),
| Declaration::Enum(e) => Some(e.name.clone()),
| Declaration::Location(l) => Some(l.name.clone()),
| Declaration::Institution(i) => Some(i.name.clone()),
| Declaration::Relationship(r) => Some(r.name.clone()),
@@ -191,7 +189,6 @@ fn format_declaration_hover(decl: &Declaration, _kind: &DeclKind) -> Hover {
| Declaration::Character(c) => format_character_hover(c),
| Declaration::Template(t) => format_template_hover(t),
| Declaration::Species(s) => format_species_hover(s),
| Declaration::Enum(e) => format_enum_hover(e),
| Declaration::Location(l) => format_location_hover(l),
| Declaration::Institution(i) => format_institution_hover(i),
| Declaration::Relationship(r) => format_relationship_hover(r),
@@ -319,21 +316,6 @@ fn format_species_hover(s: &crate::syntax::ast::Species) -> String {
content
}
/// Format enum hover information
fn format_enum_hover(e: &crate::syntax::ast::EnumDecl) -> String {
let mut content = format!("**enum** `{}`\n\n", e.name);
if !e.variants.is_empty() {
content.push_str("**Variants:**\n");
for variant in &e.variants {
content.push_str(&format!("- `{}`\n", variant));
}
content.push('\n');
}
content
}
/// Format location hover information
fn format_location_hover(l: &crate::syntax::ast::Location) -> String {
let mut content = format!("**location** `{}`\n\n", l.name);
@@ -567,6 +549,7 @@ fn format_value_as_type(value: &Value) -> String {
| Value::Duration(_) => "Duration".to_string(),
| Value::ProseBlock(_) => "ProseBlock".to_string(),
| Value::Override(_) => "Override".to_string(),
| Value::Any => "Any".to_string(),
}
}
@@ -597,6 +580,7 @@ fn format_value_preview(value: &Value) -> String {
| Value::Duration(duration) => format_duration(duration),
| Value::ProseBlock(prose) => format!("*prose ({} chars)*", prose.content.len()),
| Value::Override(override_val) => format!("*{} overrides*", override_val.overrides.len()),
| Value::Any => "any".to_string(),
}
}

View File

@@ -178,24 +178,6 @@ fn test_hover_on_species_keyword() {
}
}
#[test]
fn test_hover_on_enum_keyword() {
let source = "enum Emotion { Happy, Sad, Angry }";
let hover = get_hover_info(source, 0, 2);
assert!(hover.is_some());
let hover = hover.unwrap();
match hover.contents {
| HoverContents::Markup(MarkupContent { value, .. }) => {
assert!(value.contains("enum"));
assert!(value.contains("enumeration"));
},
| _ => panic!("Expected markup content"),
}
}
#[test]
fn test_hover_on_use_keyword() {
let source = "use characters::Alice;";

View File

@@ -154,6 +154,7 @@ fn add_type_hint(
| Value::Duration(_) => false, // Duration format is clear
| Value::ProseBlock(_) => false, // Prose is obvious
| Value::Override(_) => true, // Show what's being overridden
| Value::Any => true, // Show Any type marker
};
if !should_hint {
@@ -201,5 +202,6 @@ fn infer_value_type(value: &Value) -> String {
| Value::Duration(_) => "Duration".to_string(),
| Value::ProseBlock(_) => "Prose".to_string(),
| Value::Override(_) => "Override".to_string(),
| Value::Any => "Any".to_string(),
}
}

View File

@@ -249,35 +249,6 @@ pub fn get_semantic_tokens(doc: &Document) -> Option<SemanticTokensResult> {
highlight_field(&mut builder, field);
}
},
| Declaration::Enum(enum_decl) => {
// Highlight enum name as ENUM
builder.add_token(
enum_decl.span.start_line,
enum_decl.span.start_col,
enum_decl.name.len(),
token_type_index(SemanticTokenType::ENUM),
0,
);
// Find and highlight enum variants using the lexer
let variant_positions = find_identifiers_in_span(
&doc.text,
enum_decl.span.start,
enum_decl.span.end,
&enum_decl.variants,
);
for (offset, variant_name) in variant_positions {
let (line, col) = positions.offset_to_position(offset);
builder.add_token(
line,
col,
variant_name.len(),
token_type_index(SemanticTokenType::ENUM_MEMBER),
0,
);
}
},
| Declaration::Institution(institution) => {
// Highlight institution name as STRUCT
builder.add_token(
@@ -477,6 +448,9 @@ fn highlight_value(builder: &mut SemanticTokensBuilder, value: &Value) {
| Value::Time(_) | Value::Duration(_) => {
// Time/duration literals are already highlighted by the grammar
},
| Value::Any => {
// Any keyword is already highlighted by the grammar
},
}
}

View File

@@ -89,12 +89,6 @@ fn extract_declaration_symbol(
s.span.clone(),
extract_field_symbols(&s.fields, positions),
),
| Declaration::Enum(e) => (
e.name.clone(),
SymbolKind::ENUM,
e.span.clone(),
extract_variant_symbols(&e.variants, positions),
),
| Declaration::Use(_) => return None, // Use statements don't create symbols
| Declaration::Concept(_) |
Declaration::SubConcept(_) |
@@ -282,46 +276,3 @@ fn extract_block_symbols(
})
.collect()
}
/// Extract symbols from enum variants (simple string list)
#[allow(deprecated)]
fn extract_variant_symbols(
variants: &[String],
_positions: &PositionTracker,
) -> Vec<DocumentSymbol> {
// For enum variants, we don't have span information for individual variants
// since they're just strings. Return an empty vec for now.
// In the future, we could enhance the parser to track variant spans.
variants
.iter()
.enumerate()
.map(|(i, variant)| DocumentSymbol {
name: variant.clone(),
detail: None,
kind: SymbolKind::ENUM_MEMBER,
tags: None,
deprecated: None,
range: Range {
start: Position {
line: i as u32,
character: 0,
},
end: Position {
line: i as u32,
character: variant.len() as u32,
},
},
selection_range: Range {
start: Position {
line: i as u32,
character: 0,
},
end: Position {
line: i as u32,
character: variant.len() as u32,
},
},
children: None,
})
.collect()
}

View File

@@ -12,15 +12,8 @@ species Human {
lifespan: 80
}
enum Mood {
Happy,
Sad,
Angry
}
character Alice: Human {
age: 7
mood: Happy
---backstory
A curious girl who loves adventures
---
@@ -122,7 +115,6 @@ mod document_tests {
assert!(doc.name_table.resolve_name("Growing").is_some());
assert!(doc.name_table.resolve_name("DailyRoutine").is_some());
assert!(doc.name_table.resolve_name("Human").is_some());
assert!(doc.name_table.resolve_name("Mood").is_some());
assert!(doc.name_table.resolve_name("Friendship").is_some());
}
@@ -399,7 +391,6 @@ mod symbols_tests {
assert!(symbols.iter().any(|s| s.name == "Growing"));
assert!(symbols.iter().any(|s| s.name == "DailyRoutine"));
assert!(symbols.iter().any(|s| s.name == "Human"));
assert!(symbols.iter().any(|s| s.name == "Mood"));
assert!(symbols.iter().any(|s| s.name == "Friendship"));
}
@@ -432,9 +423,6 @@ mod symbols_tests {
let child = symbols.iter().find(|s| s.name == "Child").unwrap();
assert_eq!(child.kind, SymbolKind::INTERFACE);
let mood = symbols.iter().find(|s| s.name == "Mood").unwrap();
assert_eq!(mood.kind, SymbolKind::ENUM);
}
#[test]