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
612 lines
19 KiB
Rust
612 lines
19 KiB
Rust
//! Comprehensive test suite for LSP server functionality
|
|
|
|
use document::Document;
|
|
use tower_lsp::lsp_types::*;
|
|
|
|
use super::*;
|
|
|
|
// Test data fixtures
|
|
const SAMPLE_STORYBOOK: &str = r#"
|
|
species Human {
|
|
intelligence: high
|
|
lifespan: 80
|
|
}
|
|
|
|
character Alice: Human {
|
|
age: 7
|
|
---backstory
|
|
A curious girl who loves adventures
|
|
---
|
|
}
|
|
|
|
template Child {
|
|
age: 5..12
|
|
guardian: Human
|
|
}
|
|
|
|
character Bob: Human from Child {
|
|
age: 10
|
|
guardian: Alice
|
|
}
|
|
|
|
life_arc Growing {
|
|
state child {
|
|
on enter {
|
|
age: 5
|
|
}
|
|
}
|
|
state teen {
|
|
on enter {
|
|
age: 13
|
|
}
|
|
}
|
|
state adult {
|
|
on enter {
|
|
age: 18
|
|
}
|
|
}
|
|
}
|
|
|
|
schedule DailyRoutine {
|
|
08:00 -> 09:00: breakfast {
|
|
activity: eating
|
|
}
|
|
09:00 -> 12:00: school {
|
|
activity: learning
|
|
}
|
|
}
|
|
|
|
relationship Friendship {
|
|
Alice as friend {
|
|
bond_strength: 5
|
|
}
|
|
Bob as friend {
|
|
bond_strength: 5
|
|
}
|
|
}
|
|
"#;
|
|
|
|
#[cfg(test)]
|
|
mod document_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_document_creation() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
|
|
if !doc.parse_errors.is_empty() {
|
|
eprintln!("Parse errors:");
|
|
for err in &doc.parse_errors {
|
|
eprintln!(" - {}", err.message);
|
|
}
|
|
}
|
|
|
|
assert_eq!(doc.text, SAMPLE_STORYBOOK);
|
|
assert!(doc.ast.is_some(), "AST should be parsed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_document_with_errors() {
|
|
let invalid = "character { invalid syntax }";
|
|
let doc = Document::new(invalid.to_string());
|
|
|
|
assert!(doc.ast.is_none(), "Invalid syntax should not produce AST");
|
|
assert!(!doc.parse_errors.is_empty(), "Should have parse errors");
|
|
}
|
|
|
|
#[test]
|
|
fn test_document_update() {
|
|
let mut doc = Document::new("character Alice {}".to_string());
|
|
|
|
doc.update("character Bob {}".to_string());
|
|
|
|
assert_eq!(doc.text, "character Bob {}");
|
|
assert!(doc.name_table.resolve_name("Bob").is_some());
|
|
assert!(doc.name_table.resolve_name("Alice").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_symbol_extraction() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
|
|
assert!(doc.name_table.resolve_name("Alice").is_some());
|
|
assert!(doc.name_table.resolve_name("Bob").is_some());
|
|
assert!(doc.name_table.resolve_name("Child").is_some());
|
|
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("Friendship").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_symbol_kinds() {
|
|
use crate::resolve::names::DeclKind;
|
|
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
|
|
let alice = doc.name_table.resolve_name("Alice").unwrap();
|
|
assert_eq!(alice.kind, DeclKind::Character);
|
|
|
|
let child = doc.name_table.resolve_name("Child").unwrap();
|
|
assert_eq!(child.kind, DeclKind::Template);
|
|
|
|
let growing = doc.name_table.resolve_name("Growing").unwrap();
|
|
assert_eq!(growing.kind, DeclKind::LifeArc);
|
|
}
|
|
|
|
#[test]
|
|
fn test_word_at_offset() {
|
|
let doc = Document::new("character Alice {}".to_string());
|
|
|
|
// Test finding "character" keyword
|
|
let word = doc.word_at_offset(5);
|
|
assert_eq!(word, Some("character".to_string()));
|
|
|
|
// Test finding "Alice" identifier
|
|
let word = doc.word_at_offset(12);
|
|
assert_eq!(word, Some("Alice".to_string()));
|
|
|
|
// Test whitespace returns None
|
|
let word = doc.word_at_offset(9);
|
|
assert_eq!(word, None);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod position_tests {
|
|
use crate::position::PositionTracker;
|
|
|
|
#[test]
|
|
fn test_position_tracking_single_line() {
|
|
let mut tracker = PositionTracker::new("hello world");
|
|
|
|
assert_eq!(tracker.offset_to_position(0), (0, 0));
|
|
assert_eq!(tracker.offset_to_position(6), (0, 6));
|
|
assert_eq!(tracker.offset_to_position(11), (0, 11));
|
|
}
|
|
|
|
#[test]
|
|
fn test_position_tracking_multiline() {
|
|
let mut tracker = PositionTracker::new("line 1\nline 2\nline 3");
|
|
|
|
// Start of first line
|
|
assert_eq!(tracker.offset_to_position(0), (0, 0));
|
|
|
|
// Start of second line (after \n at offset 6)
|
|
assert_eq!(tracker.offset_to_position(7), (1, 0));
|
|
|
|
// Start of third line (after \n at offset 13)
|
|
assert_eq!(tracker.offset_to_position(14), (2, 0));
|
|
|
|
// Middle of second line
|
|
assert_eq!(tracker.offset_to_position(10), (1, 3));
|
|
}
|
|
|
|
#[test]
|
|
fn test_line_count() {
|
|
let tracker = PositionTracker::new("line 1\nline 2\nline 3");
|
|
assert_eq!(tracker.line_count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_line_offset() {
|
|
let tracker = PositionTracker::new("line 1\nline 2\nline 3");
|
|
|
|
assert_eq!(tracker.line_offset(0), Some(0));
|
|
assert_eq!(tracker.line_offset(1), Some(7));
|
|
assert_eq!(tracker.line_offset(2), Some(14));
|
|
assert_eq!(tracker.line_offset(3), None);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod hover_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_hover_keywords() {
|
|
// Test character keyword
|
|
let hover = hover::get_hover_info("character Alice {}", 0, 5);
|
|
assert!(hover.is_some());
|
|
let hover = hover.unwrap();
|
|
if let HoverContents::Markup(content) = hover.contents {
|
|
assert!(content.value.contains("character"));
|
|
assert!(content.value.contains("Defines a character entity"));
|
|
}
|
|
|
|
// Test template keyword
|
|
let hover = hover::get_hover_info("template Child {}", 0, 2);
|
|
assert!(hover.is_some());
|
|
|
|
// Test life_arc keyword
|
|
let hover = hover::get_hover_info("life_arc Growing {}", 0, 5);
|
|
assert!(hover.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_hover_non_keyword() {
|
|
let hover = hover::get_hover_info("character Alice {}", 0, 12);
|
|
assert!(hover.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_hover_invalid_position() {
|
|
let hover = hover::get_hover_info("character Alice {}", 0, 100);
|
|
assert!(hover.is_none());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod completion_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_keyword_completions() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
let params = CompletionParams {
|
|
text_document_position: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position {
|
|
line: 0,
|
|
character: 0,
|
|
},
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
context: None,
|
|
};
|
|
|
|
let result = completion::get_completions(&doc, ¶ms);
|
|
assert!(result.is_some());
|
|
|
|
if let Some(CompletionResponse::Array(items)) = result {
|
|
// Should have keyword completions
|
|
assert!(items.iter().any(|item| item.label == "character"));
|
|
assert!(items.iter().any(|item| item.label == "template"));
|
|
assert!(items.iter().any(|item| item.label == "life_arc"));
|
|
|
|
// Should have entity completions from document
|
|
assert!(items.iter().any(|item| item.label == "Alice"));
|
|
assert!(items.iter().any(|item| item.label == "Bob"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_completion_includes_snippets() {
|
|
let doc = Document::new("".to_string());
|
|
let params = CompletionParams {
|
|
text_document_position: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position::default(),
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
context: None,
|
|
};
|
|
|
|
let result = completion::get_completions(&doc, ¶ms);
|
|
|
|
if let Some(CompletionResponse::Array(items)) = result {
|
|
// Check that character completion has a snippet
|
|
let character_item = items.iter().find(|item| item.label == "character");
|
|
assert!(character_item.is_some());
|
|
let character_item = character_item.unwrap();
|
|
assert!(character_item.insert_text.is_some());
|
|
assert_eq!(
|
|
character_item.insert_text_format,
|
|
Some(InsertTextFormat::SNIPPET)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod formatting_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_basic_formatting() {
|
|
let doc = Document::new("character Alice{age:7}".to_string());
|
|
let options = FormattingOptions {
|
|
tab_size: 4,
|
|
insert_spaces: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = formatting::format_document(&doc, &options);
|
|
assert!(result.is_some());
|
|
|
|
let edits = result.unwrap();
|
|
assert_eq!(edits.len(), 1);
|
|
|
|
let formatted = &edits[0].new_text;
|
|
eprintln!("Formatted output:\n{}", formatted);
|
|
assert!(formatted.contains("character Alice {") || formatted.contains("character Alice{"));
|
|
assert!(formatted.contains("age: 7") || formatted.contains("age:7"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_formatting_indentation() {
|
|
let doc = Document::new("character Alice {\nage: 7\n}".to_string());
|
|
let options = FormattingOptions {
|
|
tab_size: 4,
|
|
insert_spaces: true,
|
|
..Default::default()
|
|
};
|
|
|
|
let result = formatting::format_document(&doc, &options);
|
|
assert!(result.is_some());
|
|
|
|
let formatted = &result.unwrap()[0].new_text;
|
|
// Check that age is indented with 4 spaces
|
|
assert!(formatted.contains(" age: 7"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_formatting_preserves_prose() {
|
|
let doc = Document::new(
|
|
"character Alice {\n---backstory\nSome irregular spacing\n---\n}".to_string(),
|
|
);
|
|
let options = FormattingOptions::default();
|
|
|
|
let result = formatting::format_document(&doc, &options);
|
|
let formatted = &result.unwrap()[0].new_text;
|
|
|
|
// Prose content should be preserved exactly
|
|
assert!(formatted.contains("Some irregular spacing"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_formatting_needed() {
|
|
let already_formatted = "character Alice {\n age: 7\n}\n";
|
|
let doc = Document::new(already_formatted.to_string());
|
|
let options = FormattingOptions::default();
|
|
|
|
let result = formatting::format_document(&doc, &options);
|
|
// Should return None if no changes needed
|
|
assert!(result.is_none());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod symbols_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_extract_symbols_from_ast() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
let ast = doc.ast.as_ref().unwrap();
|
|
let mut positions = doc.positions.clone();
|
|
|
|
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
|
|
|
|
// Should have top-level declarations
|
|
assert!(symbols.iter().any(|s| s.name == "Alice"));
|
|
assert!(symbols.iter().any(|s| s.name == "Child"));
|
|
assert!(symbols.iter().any(|s| s.name == "Bob"));
|
|
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 == "Friendship"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_symbol_hierarchy() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
let ast = doc.ast.as_ref().unwrap();
|
|
let mut positions = doc.positions.clone();
|
|
|
|
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
|
|
|
|
// Alice should have children (fields)
|
|
let alice = symbols.iter().find(|s| s.name == "Alice").unwrap();
|
|
assert!(alice.children.is_some());
|
|
let children = alice.children.as_ref().unwrap();
|
|
assert!(children.iter().any(|c| c.name == "age"));
|
|
assert!(children.iter().any(|c| c.name == "backstory"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_symbol_kinds() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
let ast = doc.ast.as_ref().unwrap();
|
|
let mut positions = doc.positions.clone();
|
|
|
|
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
|
|
|
|
let alice = symbols.iter().find(|s| s.name == "Alice").unwrap();
|
|
assert_eq!(alice.kind, SymbolKind::CLASS);
|
|
|
|
let child = symbols.iter().find(|s| s.name == "Child").unwrap();
|
|
assert_eq!(child.kind, SymbolKind::INTERFACE);
|
|
}
|
|
|
|
#[test]
|
|
fn test_life_arc_states() {
|
|
let doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
let ast = doc.ast.as_ref().unwrap();
|
|
let mut positions = doc.positions.clone();
|
|
|
|
let symbols = symbols::extract_symbols_from_ast(ast, &mut positions);
|
|
|
|
let growing = symbols.iter().find(|s| s.name == "Growing").unwrap();
|
|
assert!(growing.children.is_some());
|
|
let states = growing.children.as_ref().unwrap();
|
|
assert!(states.iter().any(|s| s.name == "child"));
|
|
assert!(states.iter().any(|s| s.name == "teen"));
|
|
assert!(states.iter().any(|s| s.name == "adult"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod definition_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_goto_definition_character() {
|
|
let mut doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
|
|
// Find position of "Alice" in "character Alice"
|
|
let alice_offset = doc.text.find("character Alice").unwrap() + "character ".len();
|
|
let (line, col) = doc.positions.offset_to_position(alice_offset);
|
|
|
|
let params = GotoDefinitionParams {
|
|
text_document_position_params: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position {
|
|
line: line as u32,
|
|
character: col as u32,
|
|
},
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
};
|
|
|
|
let uri = Url::parse("file:///test.sb").unwrap();
|
|
let result = definition::get_definition(&doc, ¶ms, &uri);
|
|
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_goto_definition_not_found() {
|
|
let doc = Document::new("character Alice {}".to_string());
|
|
|
|
let params = GotoDefinitionParams {
|
|
text_document_position_params: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position {
|
|
line: 0,
|
|
character: 0, // On "character" keyword, not a symbol
|
|
},
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
};
|
|
|
|
let uri = Url::parse("file:///test.sb").unwrap();
|
|
let result = definition::get_definition(&doc, ¶ms, &uri);
|
|
|
|
assert!(result.is_none());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod references_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_find_references() {
|
|
let source = "character Alice {}\ncharacter Bob { friend: Alice }";
|
|
let mut doc = Document::new(source.to_string());
|
|
|
|
// Find position of first "Alice"
|
|
let alice_offset = source.find("Alice").unwrap();
|
|
let (line, col) = doc.positions.offset_to_position(alice_offset);
|
|
|
|
let params = ReferenceParams {
|
|
text_document_position: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position {
|
|
line: line as u32,
|
|
character: col as u32,
|
|
},
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
context: ReferenceContext {
|
|
include_declaration: true,
|
|
},
|
|
};
|
|
|
|
let uri = Url::parse("file:///test.sb").unwrap();
|
|
let result = references::find_references(&doc, ¶ms, &uri);
|
|
|
|
assert!(result.is_some());
|
|
let locations = result.unwrap();
|
|
// Should find both occurrences of "Alice"
|
|
assert_eq!(locations.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_find_references_word_boundaries() {
|
|
let source = "character Alice {}\ncharacter Alicia {}";
|
|
let mut doc = Document::new(source.to_string());
|
|
|
|
let alice_offset = source.find("Alice").unwrap();
|
|
let (line, col) = doc.positions.offset_to_position(alice_offset);
|
|
|
|
let params = ReferenceParams {
|
|
text_document_position: TextDocumentPositionParams {
|
|
text_document: TextDocumentIdentifier {
|
|
uri: Url::parse("file:///test.sb").unwrap(),
|
|
},
|
|
position: Position {
|
|
line: line as u32,
|
|
character: col as u32,
|
|
},
|
|
},
|
|
work_done_progress_params: WorkDoneProgressParams::default(),
|
|
partial_result_params: PartialResultParams::default(),
|
|
context: ReferenceContext {
|
|
include_declaration: true,
|
|
},
|
|
};
|
|
|
|
let uri = Url::parse("file:///test.sb").unwrap();
|
|
let result = references::find_references(&doc, ¶ms, &uri);
|
|
|
|
let locations = result.unwrap();
|
|
// Should only find "Alice", not "Alicia"
|
|
assert_eq!(locations.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod integration_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_full_workflow() {
|
|
// Create a document
|
|
let mut doc = Document::new(SAMPLE_STORYBOOK.to_string());
|
|
|
|
// Verify parsing worked
|
|
assert!(doc.ast.is_some());
|
|
assert!(doc.parse_errors.is_empty());
|
|
|
|
// Verify symbols were extracted
|
|
assert!(doc.name_table.all_entries().count() > 0);
|
|
|
|
// Test updating document
|
|
doc.update("character NewChar {}".to_string());
|
|
assert!(doc.name_table.resolve_name("NewChar").is_some());
|
|
|
|
// Verify new AST was parsed
|
|
assert!(doc.ast.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_error_recovery() {
|
|
let invalid = "character { invalid }";
|
|
let doc = Document::new(invalid.to_string());
|
|
|
|
// Should handle errors gracefully
|
|
assert!(doc.ast.is_none());
|
|
assert!(!doc.parse_errors.is_empty());
|
|
|
|
// Symbols should be empty for invalid document
|
|
assert_eq!(doc.name_table.all_entries().count(), 0);
|
|
}
|
|
}
|