diff --git a/Cargo.lock b/Cargo.lock index ecf5751..d68234a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -32,6 +41,16 @@ version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -64,6 +83,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.2" @@ -424,6 +449,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "ryu" version = "1.0.20" @@ -476,6 +530,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -483,6 +538,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "slab" version = "0.4.11" @@ -510,6 +571,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "syn" version = "2.0.106" @@ -548,6 +615,36 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-php" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "unicode-ident" version = "1.0.19" @@ -764,6 +861,9 @@ dependencies = [ name = "zed_php" version = "0.4.10" dependencies = [ + "serde_json", + "tree-sitter", + "tree-sitter-php", "zed_extension_api", ] diff --git a/Cargo.toml b/Cargo.toml index c662249..86876bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,12 @@ crate-type = ["cdylib"] [dependencies] zed_extension_api = "0.7.0" + +[dev-dependencies] +# Exercise the `.scm` runnable queries and `tasks.json` the same way Zed does: +# the native tree-sitter engine and the pinned PHP grammar. `5b5627f` (the +# grammar commit in extension.toml) is the `tree-sitter-php` 0.24.2 release, so +# this crate parses identically to the editor. +tree-sitter = "0.25" +tree-sitter-php = "0.24" +serde_json = "1" diff --git a/tests/runnables.rs b/tests/runnables.rs new file mode 100644 index 0000000..52bd9ae --- /dev/null +++ b/tests/runnables.rs @@ -0,0 +1,177 @@ +//! Query-level tests for `languages/php/runnables.scm`, run with `cargo test`. +//! +//! Mirrors the approach used by `zed-extensions/java`: run the shipped `.scm` +//! over PHP source with the native tree-sitter engine and the pinned grammar, +//! under Zed's 64-match limit (see `support`). These cover the PHPUnit and Pest +//! runnables that ship on `main`. +//! +//! The `#[ignore]`d tests at the bottom document cases `main` gets wrong — it +//! tags things as PHPUnit that most likely are not. They assert the intended +//! behaviour, so they fail today and are skipped in CI; run them with +//! `cargo test -- --ignored`. These gaps are addressed by the Testo runnables +//! work on a separate branch. + +mod support; + +use support::{run_query, Run}; + +const SCM: &str = "languages/php/runnables.scm"; + +fn tags_for<'a>(runs: &'a [Run], text: &str) -> Vec<&'a str> { + runs.iter() + .filter(|r| r.text == text) + .map(|r| r.tag.as_deref().unwrap_or("(none)")) + .collect() +} + +fn has(runs: &[Run], text: &str, tag: &str) -> bool { + runs.iter() + .any(|r| r.text == text && r.tag.as_deref() == Some(tag)) +} + +// --- PHPUnit: what `main` detects ---------------------------------------- + +#[test] +fn phpunit_naming_convention() { + let src = r#" a run icon on the method name. + assert_eq!(tags_for(&runs, "testAdds"), vec!["phpunit-test"]); + // `*Test` class -> a run-all icon on the class name. + assert!(has(&runs, "CalculatorTest", "phpunit-test")); + // A non-`test*` helper is not a test. + assert!(runs.iter().all(|r| r.text != "helper")); +} + +#[test] +fn phpunit_at_test_annotation() { + let src = r#")`. + pub tag: Option, + /// Source text of the captured node (what `$ZED_SYMBOL` resolves near). + pub text: String, + /// 1-indexed row the gutter icon lands on. + pub row: usize, +} + +/// Run a runnables `.scm` file over PHP `source` and collect every `@run` +/// capture, mirroring how Zed extracts runnables. +pub fn run_query(scm_path: &str, source: &str) -> Vec { + let language: tree_sitter::Language = tree_sitter_php::LANGUAGE_PHP.into(); + + let mut parser = Parser::new(); + parser.set_language(&language).expect("load PHP grammar"); + let tree = parser.parse(source, None).expect("parse source"); + + let scm = std::fs::read_to_string(scm_path).unwrap_or_else(|e| panic!("read {scm_path}: {e}")); + let query = Query::new(&language, &scm).expect("compile runnables query"); + let run_idx = query + .capture_index_for_name("run") + .expect("query defines a @run capture"); + + let mut cursor = QueryCursor::new(); + // Mirror Zed: QueryCursorHandle::new() caps in-progress matches at 64. This + // is what makes `program`-rooted correlation patterns drop runnables on big + // files, so tests must honour the same limit to catch that regression class. + cursor.set_match_limit(64); + + let mut runs = Vec::new(); + let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); + while let Some(m) = matches.next() { + let tag = query + .property_settings(m.pattern_index) + .iter() + .find(|p| &*p.key == "tag") + .and_then(|p| p.value.as_deref().map(str::to_owned)); + for c in m.captures.iter().filter(|c| c.index == run_idx) { + runs.push(Run { + tag: tag.clone(), + text: c + .node + .utf8_text(source.as_bytes()) + .expect("utf8 text") + .to_string(), + row: c.node.start_position().row + 1, + }); + } + } + runs +} diff --git a/tests/tasks.rs b/tests/tasks.rs new file mode 100644 index 0000000..57ae955 --- /dev/null +++ b/tests/tasks.rs @@ -0,0 +1,60 @@ +//! Consistency tests for `languages/php/tasks.json`, run with `cargo test`. +//! Mirrors `zed-extensions/java`'s `task_verification_test.rs`: a runnable tag +//! is useless without a task that carries it, so check the two files agree. + +use serde_json::Value; +use std::collections::HashSet; + +const TASKS: &str = "languages/php/tasks.json"; +const SCM: &str = "languages/php/runnables.scm"; + +fn tasks() -> Vec { + let json = std::fs::read_to_string(TASKS).expect("read tasks.json"); + let parsed: Value = serde_json::from_str(&json).expect("parse tasks.json"); + parsed.as_array().expect("tasks.json is an array").clone() +} + +/// The `command` of the first task carrying `tag`. +fn command_for_tag(tag: &str) -> String { + for t in tasks() { + let carries = t["tags"] + .as_array() + .map(|a| a.iter().any(|x| x.as_str() == Some(tag))) + .unwrap_or(false); + if carries { + return t["command"].as_str().unwrap().to_string(); + } + } + panic!("no task carries tag `{tag}`"); +} + +#[test] +fn runnable_tags_map_to_the_expected_runner() { + assert_eq!(command_for_tag("phpunit-test"), "./vendor/bin/phpunit"); + assert_eq!(command_for_tag("pest-test"), "./vendor/bin/pest"); +} + +/// Every tag emitted by a `(#set! tag )` in runnables.scm must have at +/// least one task in tasks.json, otherwise the gutter icon would do nothing. +#[test] +fn every_runnable_tag_has_a_task() { + let scm = std::fs::read_to_string(SCM).expect("read runnables.scm"); + let task_tags: HashSet = tasks() + .iter() + .filter_map(|t| t["tags"].as_array()) + .flatten() + .filter_map(|v| v.as_str()) + .map(String::from) + .collect(); + + for line in scm.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("(#set! tag ") { + let tag = rest.trim_end_matches(')').trim(); + assert!( + task_tags.contains(tag), + "runnable tag `{tag}` has no task in tasks.json" + ); + } + } +}