//! 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 { 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); } } } }