From dd20c7c79d208196ec18596e9c744327febbd865 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:37:40 +0200 Subject: [PATCH 1/3] feat: `test` runnables for `describe`/`it` Stack/Cabal auto-detection included to run tests. --- Cargo.lock | 38 ++++++--- Cargo.toml | 3 + languages/haskell/runnables.scm | 6 ++ languages/haskell/tasks.json | 14 ++++ tests/task_verification_test.rs | 139 ++++++++++++++++++++++++++++++++ 5 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 languages/haskell/runnables.scm create mode 100644 languages/haskell/tasks.json create mode 100644 tests/task_verification_test.rs diff --git a/Cargo.lock b/Cargo.lock index d727ed3..94d56bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -407,12 +407,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "semver" version = "1.0.26" @@ -424,18 +418,28 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.218" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.218" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -444,14 +448,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -740,6 +745,7 @@ dependencies = [ name = "zed_haskell" version = "0.3.2" dependencies = [ + "serde_json", "zed_extension_api", ] @@ -796,3 +802,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 2b4f363..8cddda7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,6 @@ crate-type = ["cdylib"] [dependencies] zed_extension_api = "0.7.0" + +[dev-dependencies] +serde_json = "1.0.150" diff --git a/languages/haskell/runnables.scm b/languages/haskell/runnables.scm new file mode 100644 index 0000000..f5b9362 --- /dev/null +++ b/languages/haskell/runnables.scm @@ -0,0 +1,6 @@ +; Detect describe/it test blocks +((apply + function: (variable) @run + (#any-of? @run "describe" "it") + argument: (literal (string) @HASKELL_TEST_NAME)) @_haskell-test + (#set! tag haskell-test)) diff --git a/languages/haskell/tasks.json b/languages/haskell/tasks.json new file mode 100644 index 0000000..6d5a130 --- /dev/null +++ b/languages/haskell/tasks.json @@ -0,0 +1,14 @@ +[ + { + "label": "Test $ZED_CUSTOM_HASKELL_TEST_NAME", + "command": "name=$(printf '%s' \"$ZED_CUSTOM_HASKELL_TEST_NAME\" | tr -d '\"'); if [ -f stack.yaml ]; then stack test --ta \"--match /$name\"; elif [ -f cabal.project ] || ls *.cabal 2>/dev/null | grep -q .; then cabal test --test-options \"--match /$name\"; else echo 'No Stack or Cabal found to run the tests' >&2; exit 1; fi", + "tags": ["haskell-test"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + }, + "show_command": false + } +] diff --git a/tests/task_verification_test.rs b/tests/task_verification_test.rs new file mode 100644 index 0000000..e27791f --- /dev/null +++ b/tests/task_verification_test.rs @@ -0,0 +1,139 @@ +// Heavily inspired by the Java extension's task verification tests: +// https://github.com/zed-extensions/java/blob/b6b8f9a5396b7f794d12a91ee4c703078ecc3e96/tests/task_verification_test.rs + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +fn get_task_command_by_tag(tag: &str) -> String { + let tasks_json = + fs::read_to_string("languages/haskell/tasks.json").expect("Failed to read tasks.json"); + let tasks: Value = serde_json::from_str(&tasks_json).expect("Failed to parse tasks.json"); + for task in tasks.as_array().expect("tasks.json is not an array") { + if let Some(tags) = task["tags"].as_array() { + if tags.iter().any(|t| t.as_str() == Some(tag)) { + return task["command"] + .as_str() + .expect("command is not a string") + .to_string(); + } + } + } + panic!("Task with tag '{tag}' not found"); +} + +struct TestProject { + temp_dir: PathBuf, + bin_dir: PathBuf, + new_path: String, +} + +impl TestProject { + fn new(name: &str) -> Self { + let temp_dir = std::env::temp_dir().join(format!("haskell_task_test_{name}")); + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir).unwrap(); + } + fs::create_dir_all(&temp_dir).unwrap(); + let bin_dir = temp_dir.join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + let new_path = format!( + "{}:{}", + bin_dir.to_string_lossy(), + std::env::var("PATH").unwrap_or_default() + ); + Self { + temp_dir, + bin_dir, + new_path, + } + } + + fn with_stack(&self) { + fs::File::create(self.temp_dir.join("stack.yaml")).unwrap(); + fs::File::create(self.temp_dir.join("myproject.cabal")).unwrap(); + self.mock_bin("stack", "#!/bin/sh\necho \"STACK_CALLED: $@\""); + } + + fn with_cabal(&self) { + fs::File::create(self.temp_dir.join("myproject.cabal")).unwrap(); + self.mock_bin("cabal", "#!/bin/sh\necho \"CABAL_CALLED: $@\""); + } + + fn mock_bin(&self, name: &str, content: &str) { + let bin_path = self.bin_dir.join(name); + fs::write(&bin_path, content).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&bin_path, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + fn run_task_with_env(&self, tag: &str, env: &[(&str, &str)]) -> String { + let command = get_task_command_by_tag(tag); + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(&command) + .env("PATH", &self.new_path) + .current_dir(&self.temp_dir); + for (key, val) in env { + cmd.env(key, val); + } + let output = cmd.output().expect("Failed to execute shell command"); + String::from_utf8_lossy(&output.stdout).into_owned() + + &String::from_utf8_lossy(&output.stderr) + } +} + +impl Drop for TestProject { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.temp_dir); + } +} + +// ZED_CUSTOM_HASKELL_TEST_NAME is the string token as captured by tree-sitter, +// which includes the surrounding quotes (e.g. `"Functor"`, not `Functor`). +// The task command strips them via `tr -d '"'` before passing to --match. + +#[test] +fn test_stack_test_strips_quotes_and_passes_match() { + let project = TestProject::new("stack_test_match"); + project.with_stack(); + let output = project.run_task_with_env( + "haskell-test", + &[("ZED_CUSTOM_HASKELL_TEST_NAME", "\"Functor\"")], + ); + assert!( + output.contains("STACK_CALLED: test --ta --match /Functor"), + "Got: {output}" + ); +} + +#[test] +fn test_cabal_test_strips_quotes_and_passes_match() { + let project = TestProject::new("cabal_test_match"); + project.with_cabal(); + let output = project.run_task_with_env( + "haskell-test", + &[("ZED_CUSTOM_HASKELL_TEST_NAME", "\"RemoteData\"")], + ); + assert!( + output.contains("CABAL_CALLED: test --test-options --match /RemoteData"), + "Got: {output}" + ); +} + +#[test] +fn test_no_build_tool_test() { + let project = TestProject::new("no_tool_test"); + let output = project.run_task_with_env( + "haskell-test", + &[("ZED_CUSTOM_HASKELL_TEST_NAME", "\"Functor\"")], + ); + assert!( + output.contains("No Stack or Cabal found"), + "Got: {output}" + ); +} From ad4a3a9293b715a80d2ef483f2692d0ceeca7048 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:23:52 +0200 Subject: [PATCH 2/3] run Zed's `tree-sitter-query` extension to format / lint --- languages/haskell/runnables.scm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/languages/haskell/runnables.scm b/languages/haskell/runnables.scm index f5b9362..a64f8ef 100644 --- a/languages/haskell/runnables.scm +++ b/languages/haskell/runnables.scm @@ -2,5 +2,6 @@ ((apply function: (variable) @run (#any-of? @run "describe" "it") - argument: (literal (string) @HASKELL_TEST_NAME)) @_haskell-test + argument: (literal + (string) @HASKELL_TEST_NAME)) (#set! tag haskell-test)) From 155031e7fb7a28e95819da4032ea567408f68f4e Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:29:54 +0200 Subject: [PATCH 3/3] cargo fmt --- tests/task_verification_test.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/task_verification_test.rs b/tests/task_verification_test.rs index e27791f..4e17059 100644 --- a/tests/task_verification_test.rs +++ b/tests/task_verification_test.rs @@ -132,8 +132,5 @@ fn test_no_build_tool_test() { "haskell-test", &[("ZED_CUSTOM_HASKELL_TEST_NAME", "\"Functor\"")], ); - assert!( - output.contains("No Stack or Cabal found"), - "Got: {output}" - ); + assert!(output.contains("No Stack or Cabal found"), "Got: {output}"); }