From 274c9c35cf6a7d93a5bdd96cc10d9f7e504eaa93 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:25:21 +0200 Subject: [PATCH 1/3] feat: `main` runnables, Stack/Cabal auto-detected --- Cargo.lock | 42 +++++--- Cargo.toml | 3 + languages/haskell/runnables.scm | 9 ++ languages/haskell/tasks.json | 24 +++++ tests/task_verification_test.rs | 169 ++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 15 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..6ba8eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -400,19 +400,13 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" 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..4393954 --- /dev/null +++ b/languages/haskell/runnables.scm @@ -0,0 +1,9 @@ +; Detect the main function +(declarations + [ + (function name: (variable) @run) @_ + (bind name: (variable) @run) @_ + ] + (#eq? @run "main") + (#set! tag haskell-build) + (#set! tag haskell-run)) diff --git a/languages/haskell/tasks.json b/languages/haskell/tasks.json new file mode 100644 index 0000000..eaf0aa9 --- /dev/null +++ b/languages/haskell/tasks.json @@ -0,0 +1,24 @@ +[ + { + "label": "Run", + "command": "if [ -f stack.yaml ]; then stack run; elif [ -f cabal.project ] || ls *.cabal 2>/dev/null | grep -q .; then cabal run; else echo 'No Stack or Cabal found to run the code' >&2; exit 1; fi", + "tags": ["haskell-run"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + }, + { + "label": "Build", + "command": "if [ -f stack.yaml ]; then stack build; elif [ -f cabal.project ] || ls *.cabal 2>/dev/null | grep -q .; then cabal build; else echo 'No Stack or Cabal found to build the code' >&2; exit 1; fi", + "tags": ["haskell-build"], + "shell": { + "with_arguments": { + "program": "/bin/sh", + "args": ["-c"] + } + } + } +] diff --git a/tests/task_verification_test.rs b/tests/task_verification_test.rs new file mode 100644 index 0000000..c62815e --- /dev/null +++ b/tests/task_verification_test.rs @@ -0,0 +1,169 @@ +// 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 with_cabal_project(&self) { + fs::File::create(self.temp_dir.join("cabal.project")).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(&self, tag: &str) -> String { + let command = get_task_command_by_tag(tag); + let output = Command::new("sh") + .arg("-c") + .arg(&command) + .env("PATH", &self.new_path) + .current_dir(&self.temp_dir) + .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); + } +} + +#[test] +fn test_stack_build() { + let project = TestProject::new("stack_build"); + project.with_stack(); + let output = project.run_task("haskell-build"); + assert!(output.contains("STACK_CALLED: build"), "Got: {}", output); +} + +#[test] +fn test_stack_run() { + let project = TestProject::new("stack_run"); + project.with_stack(); + let output = project.run_task("haskell-run"); + assert!(output.contains("STACK_CALLED: run"), "Got: {}", output); +} + +#[test] +fn test_cabal_build() { + let project = TestProject::new("cabal_build"); + project.with_cabal(); + let output = project.run_task("haskell-build"); + assert!(output.contains("CABAL_CALLED: build"), "Got: {}", output); +} + +#[test] +fn test_cabal_run() { + let project = TestProject::new("cabal_run"); + project.with_cabal(); + let output = project.run_task("haskell-run"); + assert!(output.contains("CABAL_CALLED: run"), "Got: {}", output); +} + +#[test] +fn test_cabal_project_build() { + let project = TestProject::new("cabal_project_build"); + project.with_cabal_project(); + let output = project.run_task("haskell-build"); + assert!(output.contains("CABAL_CALLED: build"), "Got: {}", output); +} + +#[test] +fn test_cabal_project_run() { + let project = TestProject::new("cabal_project_run"); + project.with_cabal_project(); + let output = project.run_task("haskell-run"); + assert!(output.contains("CABAL_CALLED: run"), "Got: {}", output); +} + +#[test] +fn test_stack_preferred_over_cabal() { + let project = TestProject::new("stack_preferred"); + project.with_stack(); + project.with_cabal(); + let output = project.run_task("haskell-run"); + assert!( + output.contains("STACK_CALLED: run"), + "Stack should be preferred when stack.yaml present. Got: {}", + output + ); +} + +#[test] +fn test_no_build_tool() { + let project = TestProject::new("no_tool"); + let output = project.run_task("haskell-build"); + assert!( + output.contains("No Stack or Cabal found"), + "Got: {}", + output + ); +} From 4690e2279a6506dab90e5e7a475d261861e26735 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:37:37 +0200 Subject: [PATCH 2/3] hide output of spawned tasks see https://zed.dev/docs/tasks?highlight=tasks#tasks --- languages/haskell/tasks.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/languages/haskell/tasks.json b/languages/haskell/tasks.json index eaf0aa9..58d86e7 100644 --- a/languages/haskell/tasks.json +++ b/languages/haskell/tasks.json @@ -8,7 +8,8 @@ "program": "/bin/sh", "args": ["-c"] } - } + }, + "show_command": false }, { "label": "Build", @@ -19,6 +20,7 @@ "program": "/bin/sh", "args": ["-c"] } - } + }, + "show_command": false } ] From c7531aaac4508f715f448e3b70c787b8640893e4 Mon Sep 17 00:00:00 2001 From: jk <47693+sectore@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:38:40 +0200 Subject: [PATCH 3/3] run Zed's `tree-sitter-query` extension to format / lint --- languages/haskell/runnables.scm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/languages/haskell/runnables.scm b/languages/haskell/runnables.scm index 4393954..7b9959d 100644 --- a/languages/haskell/runnables.scm +++ b/languages/haskell/runnables.scm @@ -1,8 +1,10 @@ ; Detect the main function (declarations [ - (function name: (variable) @run) @_ - (bind name: (variable) @run) @_ + (function + name: (variable) @run) + (bind + name: (variable) @run) ] (#eq? @run "main") (#set! tag haskell-build)