Files
storybook/tests/tree_sitter_integration.rs
Sienna Meridian Satterwhite 9c18bfa028 feat(lang): rename concept_comparison to definition (v0.3.2)
Renames the `concept_comparison` keyword to `definition` across the
entire codebase for better readability and conciseness.

Changes:
- Tree-sitter grammar: `concept_comparison` node → `definition`
- Tree-sitter queries: highlights, outline, and indents updated
- Zed extension highlights.scm updated to match
- Lexer: `Token::ConceptComparison` → `Token::Definition`
- Parser: `ConceptComparisonDecl` rule → `DefinitionDecl`
- AST: `Declaration::ConceptComparison` → `Declaration::Definition`,
  `ConceptComparisonDecl` struct → `DefinitionDecl`
- All Rust source files updated (validate, names, convert, references,
  semantic_tokens, symbols, code_actions, hover, completion)
- `validate_concept_comparison_patterns` → `validate_definition_patterns`
- Example file and test corpus updated
- Spec docs: created SBIR-v0.3.2-SPEC.md, updated TYPE-SYSTEM.md,
  README.md, SBIR-CHANGELOG.md, SBIR-v0.3.1-SPEC.md
2026-02-23 20:37:52 +00:00

493 lines
15 KiB
Rust

//! Tree-sitter Grammar Integration Tests
//!
//! These tests validate that:
//! 1. The tree-sitter grammar builds correctly
//! 2. All queries (highlights, injections, etc.) are valid
//! 3. Example files parse without errors
//! 4. The Zed extension builds successfully
//!
//! These are acceptance tests that catch integration issues between
//! the Storybook language and its tree-sitter representation.
use std::{
path::{
Path,
PathBuf,
},
process::Command,
};
fn tree_sitter_dir() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("tree-sitter-storybook");
path
}
fn zed_extension_dir() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("zed-storybook");
path
}
fn examples_dir() -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("examples");
path
}
// ============================================================================
// Tree-sitter Grammar Build Tests
// ============================================================================
#[test]
fn test_tree_sitter_grammar_generates() {
let output = Command::new("tree-sitter")
.arg("generate")
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter generate. Is tree-sitter-cli installed?");
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"tree-sitter generate failed. Grammar may have syntax errors."
);
// Verify generated files exist
let parser_c = tree_sitter_dir().join("src").join("parser.c");
assert!(
parser_c.exists(),
"parser.c not generated at {:?}",
parser_c
);
}
#[test]
fn test_tree_sitter_grammar_builds() {
let output = Command::new("tree-sitter")
.arg("generate")
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter generate");
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"tree-sitter generate failed. Grammar may not compile."
);
}
// ============================================================================
// Query Validation Tests
// ============================================================================
#[test]
fn test_highlights_query_valid() {
let output = Command::new("tree-sitter")
.arg("test")
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter test");
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"tree-sitter test failed. Queries (highlights.scm, etc.) may have errors."
);
}
#[test]
fn test_queries_reference_valid_nodes() {
// This test verifies that all node types referenced in queries
// actually exist in the grammar. This catches issues like:
// - Using "any" instead of (any_type)
// - Referencing removed or renamed nodes
// - Typos in node names
let highlights_scm = tree_sitter_dir().join("queries").join("highlights.scm");
assert!(
highlights_scm.exists(),
"highlights.scm not found at {:?}",
highlights_scm
);
let content = std::fs::read_to_string(&highlights_scm).expect("Failed to read highlights.scm");
// Check for common mistakes
let problematic_patterns = vec![
(
"\"any\"",
"Should use (any_type) instead of \"any\" string literal",
),
(
"\"Number\"",
"Number is not a keyword, should use a type node",
),
(
"\"Decimal\"",
"Decimal is not a keyword, should use a type node",
),
("\"Text\"", "Text is not a keyword, should use a type node"),
(
"\"Boolean\"",
"Boolean is not a keyword, should use a type node",
),
];
for (pattern, msg) in problematic_patterns {
if content.contains(pattern) {
// Check if it's in a comment
let lines: Vec<&str> = content.lines().collect();
for line in lines {
if line.contains(pattern) && !line.trim_start().starts_with(';') {
panic!(
"highlights.scm contains problematic pattern '{}': {}",
pattern, msg
);
}
}
}
}
}
// ============================================================================
// Example File Parsing Tests
// ============================================================================
#[test]
fn test_baker_family_example_parses() {
let baker_family = examples_dir().join("baker-family");
assert!(baker_family.exists(), "baker-family example not found");
// Find all .sb files
let sb_files = find_sb_files(&baker_family);
assert!(
!sb_files.is_empty(),
"No .sb files found in baker-family example"
);
for file in &sb_files {
let output = Command::new("tree-sitter")
.arg("parse")
.arg(file)
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter parse");
if !output.status.success() {
eprintln!("Failed to parse: {:?}", file);
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"Failed to parse {:?} with tree-sitter",
file
);
// Check for ERROR nodes in the parse tree
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("ERROR") {
eprintln!("Parse tree for {:?}:", file);
eprintln!("{}", stdout);
panic!("Parse tree contains ERROR nodes for {:?}", file);
}
}
}
#[test]
fn test_all_examples_parse() {
let sb_files = find_sb_files(&examples_dir());
assert!(!sb_files.is_empty(), "No .sb files found in examples/");
let mut failures = Vec::new();
for file in &sb_files {
let output = Command::new("tree-sitter")
.arg("parse")
.arg(file)
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter parse");
if !output.status.success() {
failures.push((file.clone(), "Parse command failed".to_string()));
continue;
}
// Check for ERROR nodes
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("ERROR") {
failures.push((file.clone(), "Parse tree contains ERROR nodes".to_string()));
}
}
if !failures.is_empty() {
eprintln!("Failed to parse {} files:", failures.len());
for (file, reason) in &failures {
eprintln!(" - {:?}: {}", file, reason);
}
panic!("{} files failed to parse with tree-sitter", failures.len());
}
}
// ============================================================================
// Specific Language Feature Tests
// ============================================================================
#[test]
fn test_type_system_keywords_parse() {
let test_file = tree_sitter_dir()
.join("test")
.join("corpus")
.join("type_system.txt");
if !test_file.exists() {
panic!(
"Type system test corpus not found at {:?}. Please create it.",
test_file
);
}
let _test_file = tree_sitter_dir()
.join("test")
.join("corpus")
.join("type_system.txt");
let output = Command::new("tree-sitter")
.arg("test")
.arg("--file-name")
.arg("type_system.txt")
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter test");
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(output.status.success(), "Type system test corpus failed");
}
#[test]
fn test_any_type_highlights_correctly() {
// Create a minimal test file with any_type usage
let test_content = r#"
definition SkillLevel {
Novice: { skill: any },
Expert: { skill: Tier is Expert }
}
"#;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test_any_type.sb");
std::fs::write(&test_file, test_content).expect("Failed to write test file");
let output = Command::new("tree-sitter")
.arg("parse")
.arg(&test_file)
.current_dir(tree_sitter_dir())
.output()
.expect("Failed to run tree-sitter parse");
std::fs::remove_file(&test_file).ok(); // Clean up
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(output.status.success(), "Failed to parse any_type usage");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("any_type"),
"Parse tree should contain any_type node"
);
assert!(
!stdout.contains("ERROR"),
"Parse tree should not contain ERROR nodes"
);
}
// ============================================================================
// Zed Extension Build Tests
// ============================================================================
#[test]
fn test_zed_highlights_query_not_overridden() {
// This test ensures the Zed extension doesn't have a local highlights.scm
// that overrides the grammar's version. Local overrides create a maintenance
// burden and can easily diverge from the source of truth.
//
// The preferred approach is to let Zed use the fetched grammar's queries.
// If a local override is needed (for Zed-specific customizations), this test
// will fail and remind us to keep them in sync.
let grammar_highlights = tree_sitter_dir().join("queries").join("highlights.scm");
let zed_highlights = zed_extension_dir()
.join("languages")
.join("storybook")
.join("highlights.scm");
// Verify grammar version exists
assert!(
grammar_highlights.exists(),
"Grammar highlights.scm not found at {:?}",
grammar_highlights
);
// If Zed has a local override, it MUST match the grammar version exactly
if zed_highlights.exists() {
let grammar_content = std::fs::read_to_string(&grammar_highlights)
.expect("Failed to read grammar highlights");
let zed_content =
std::fs::read_to_string(&zed_highlights).expect("Failed to read zed highlights");
assert_eq!(
grammar_content,
zed_content,
"\n\nZed extension has a local highlights.scm that differs from the grammar version.\n\
This causes the query to fail validation against the fetched grammar.\n\
\n\
To fix:\n\
1. Delete zed-storybook/languages/storybook/highlights.scm (preferred)\n\
2. Or sync it: cp tree-sitter-storybook/queries/highlights.scm zed-storybook/languages/storybook/\n\
\n\
Grammar version: {:?}\n\
Zed version: {:?}\n",
grammar_highlights,
zed_highlights
);
}
// If no local override exists, the test passes - Zed will use the fetched
// grammar's version
}
#[test]
#[ignore] // Only run with --ignored flag as this is slow
fn test_zed_extension_builds() {
let build_script = zed_extension_dir().join("build-extension.sh");
if !build_script.exists() {
panic!("build-extension.sh not found at {:?}", build_script);
}
let output = Command::new("bash")
.arg(&build_script)
.current_dir(zed_extension_dir())
.output()
.expect("Failed to run build-extension.sh");
if !output.status.success() {
eprintln!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(output.status.success(), "build-extension.sh failed");
// Verify the extension.wasm was created
let wasm = zed_extension_dir().join("extension.wasm");
assert!(wasm.exists(), "extension.wasm not created at {:?}", wasm);
}
#[test]
fn test_extension_toml_has_valid_revision() {
let extension_toml = zed_extension_dir().join("extension.toml");
let content = std::fs::read_to_string(&extension_toml).expect("Failed to read extension.toml");
// Check that rev is set to a valid commit SHA
let rev_line = content
.lines()
.find(|line| line.starts_with("rev = "))
.expect("No 'rev' field found in extension.toml");
let rev = rev_line
.split('"')
.nth(1)
.expect("Could not parse rev value");
assert_eq!(
rev.len(),
40,
"Revision should be a 40-character git SHA, got: {}",
rev
);
// Verify the commit exists
let output = Command::new("git")
.arg("cat-file")
.arg("-t")
.arg(rev)
.current_dir(env!("CARGO_MANIFEST_DIR"))
.output()
.expect("Failed to run git cat-file");
if !output.status.success() {
eprintln!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
}
assert!(
output.status.success(),
"Revision {} does not exist in git history",
rev
);
let obj_type = String::from_utf8_lossy(&output.stdout);
assert_eq!(
obj_type.trim(),
"commit",
"Revision {} is not a commit",
rev
);
}
// ============================================================================
// Helper Functions
// ============================================================================
fn find_sb_files(dir: &Path) -> Vec<PathBuf> {
let mut sb_files = Vec::new();
if !dir.exists() {
return sb_files;
}
visit_dirs(dir, &mut |path| {
if path.extension().and_then(|s| s.to_str()) == Some("sb") {
sb_files.push(path.to_path_buf());
}
});
sb_files
}
fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&Path)) {
if dir.is_dir() {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
visit_dirs(&path, cb);
} else {
cb(&path);
}
}
}
}