├── .github └── workflows │ ├── ci.yml │ ├── release-adapter.yml │ └── release.yml ├── .gitignore ├── .testingls.toml ├── .vim └── coc-settings.json ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── crates └── adapter │ ├── Cargo.toml │ ├── src │ ├── log.rs │ ├── main.rs │ ├── model.rs │ └── runner │ │ ├── cargo_nextest.rs │ │ ├── cargo_test.rs │ │ ├── deno.rs │ │ ├── go.rs │ │ ├── jest.rs │ │ ├── mod.rs │ │ ├── node_test.rs │ │ ├── phpunit.rs │ │ ├── util.rs │ │ └── vitest.rs │ └── tests │ └── go-test.txt ├── demo ├── .helix │ ├── config.toml │ └── languages.toml ├── .testingls.toml ├── .vim │ └── coc-settings.json ├── .vscode │ └── settings.json ├── README.md ├── deno │ ├── deno.json │ ├── deno.lock │ ├── main.ts │ ├── main_test.ts │ └── output.txt ├── go │ ├── README.md │ ├── cases.go │ ├── cases_test.go │ ├── example.go │ ├── example_test.go │ ├── go.mod │ ├── go.sum │ ├── main.go │ ├── main_tagged_test.go │ ├── main_test.go │ ├── many_table_test.go │ ├── map_table_test.go │ ├── suite_test.go │ └── three_level_nested_test.go ├── jest │ ├── .gitignore │ ├── README.md │ ├── another.spec.js │ ├── bun.lockb │ ├── index.js │ ├── index.spec.js │ ├── jsconfig.json │ ├── output.json │ └── package.json ├── node-test │ ├── index.test.js │ ├── output.xml │ ├── package.json │ └── util.js ├── phpunit │ ├── .gitignore │ ├── .mise.toml │ ├── composer.json │ ├── composer.lock │ ├── output.xml │ └── src │ │ ├── Calculator.php │ │ └── CalculatorTest.php ├── rust │ ├── .gitignore │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ └── lib.rs └── vitest │ ├── .gitignore │ ├── basic.test.ts │ ├── package.json │ └── vite.config.ts ├── doc └── ADAPTER_SPEC.md ├── justfile └── src ├── error.rs ├── lib.rs ├── log.rs ├── main.rs ├── server.rs ├── spec.rs └── util.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Rust 20 | uses: actions-rs/toolchain@v1 21 | with: 22 | toolchain: stable 23 | profile: minimal 24 | override: true 25 | 26 | - name: Build (required step) 27 | run: cargo build --workspace 28 | 29 | - name: Run tests 30 | run: cargo test --verbose --workspace -- --nocapture 31 | -------------------------------------------------------------------------------- /.github/workflows/release-adapter.yml: -------------------------------------------------------------------------------- 1 | name: Release Adapter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - crates/adapter/Cargo.toml 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | check-version: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | version_changed: ${{ steps.check_version.outputs.version_changed }} 19 | new_version: ${{ steps.check_version.outputs.new_version }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 2 24 | - name: Check if version changed 25 | id: check_version 26 | run: | 27 | PACKAGE_NAME=$(grep '^name' crates/adapter/Cargo.toml | sed 's/name = "\(.*\)"/\1/') 28 | RELEASED_VERSION=$(cargo search $PACKAGE_NAME --limit 1 | grep -oP '(?<=").*(?=")') 29 | if [ $? -ne 0 ]; then 30 | echo "Failed to fetch released version" 31 | exit 1 32 | fi 33 | NEW_VERSION=$(grep '^version' crates/adapter/Cargo.toml | sed 's/version = "\(.*\)"/\1/') 34 | 35 | if [ "$RELEASED_VERSION" != "$NEW_VERSION" ]; then 36 | echo "Version changed from $RELEASED_VERSION to $NEW_VERSION" 37 | echo "version_changed=true" >> $GITHUB_OUTPUT 38 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 39 | else 40 | echo "No version change" 41 | fi 42 | 43 | publish: 44 | needs: check-version 45 | if: needs.check-version.outputs.version_changed == 'true' 46 | runs-on: ubuntu-latest 47 | defaults: 48 | run: 49 | working-directory: crates/adapter 50 | steps: 51 | - uses: actions/checkout@v3 52 | - name: Set up Rust 53 | uses: actions-rs/toolchain@v1 54 | with: 55 | toolchain: stable 56 | override: true 57 | - name: Publish to crates.io 58 | env: 59 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 60 | run: cargo publish --token $CARGO_REGISTRY_TOKEN 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Auto Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - Cargo.toml 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | check-version: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | version_changed: ${{ steps.check_version.outputs.version_changed }} 19 | new_version: ${{ steps.check_version.outputs.new_version }} 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 2 24 | - name: Check if version changed 25 | id: check_version 26 | run: | 27 | PACKAGE_NAME=$(grep '^name' Cargo.toml | sed 's/name = "\(.*\)"/\1/') 28 | RELEASED_VERSION=$(cargo search $PACKAGE_NAME --limit 1 | grep -oP '(?<=").*(?=")') 29 | if [ $? -ne 0 ]; then 30 | echo "Failed to fetch released version" 31 | exit 1 32 | fi 33 | NEW_VERSION=$(grep '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/') 34 | 35 | if [ "$RELEASED_VERSION" != "$NEW_VERSION" ]; then 36 | echo "Version changed from $RELEASED_VERSION to $NEW_VERSION" 37 | echo "version_changed=true" >> $GITHUB_OUTPUT 38 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 39 | else 40 | echo "No version change" 41 | fi 42 | 43 | create-release: 44 | needs: check-version 45 | if: needs.check-version.outputs.version_changed == 'true' 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v3 49 | - name: Create Release 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | run: | 53 | gh release create v${{ needs.check-version.outputs.new_version }} \ 54 | --title "Release ${{ needs.check-version.outputs.new_version }}" \ 55 | --generate-notes 56 | 57 | publish: 58 | needs: [check-version, create-release] 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v3 62 | - name: Set up Rust 63 | uses: actions-rs/toolchain@v1 64 | with: 65 | toolchain: stable 66 | override: true 67 | - name: Publish to crates.io 68 | env: 69 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 70 | run: cargo publish --token $CARGO_REGISTRY_TOKEN 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /logs 3 | -------------------------------------------------------------------------------- /.testingls.toml: -------------------------------------------------------------------------------- 1 | enableWorkspaceDiagnostics = true 2 | 3 | [adapterCommand.rust] 4 | path = "testing-ls-adapter" 5 | extra_arg = [ 6 | "--test-kind=cargo-test", 7 | "--workspace" 8 | ] 9 | include = [ 10 | "/**/*.rs" 11 | ] 12 | exclude = [ 13 | "/demo/**/*" 14 | ] 15 | workspace_dir = "." 16 | -------------------------------------------------------------------------------- /.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "testing.enable": true, 3 | "testing.enableWorkspaceDiagnostics": true, 4 | "testing.server.path": "testing-language-server", 5 | "testing.trace.server": "verbose" 6 | } 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ```sh 4 | cargo install just 5 | cargo install cargo-watch 6 | just watch-build 7 | sudo ln -s $(pwd)/target/debug/testing-language-server /usr/local/bin/testing-language-server 8 | sudo ln -s $(pwd)/target/debug/testing-ls-adapter /usr/local/bin/testing-ls-adapter 9 | ``` 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "testing-language-server" 3 | version = "0.1.12" 4 | edition = "2021" 5 | description = "LSP server for testing" 6 | license = "MIT" 7 | 8 | [workspace] 9 | members = [ "crates/adapter"] 10 | exclude = ["demo"] 11 | 12 | [[bin]] 13 | name = "testing-language-server" 14 | path = "src/main.rs" 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | [workspace.dependencies] 18 | lsp-types = "0.95.1" 19 | serde_json = "1.0.116" 20 | serde = "1.0.198" 21 | anyhow = "1.0.82" 22 | thiserror = "1.0.59" 23 | regex = "1.10.4" 24 | tracing-appender = "0.2" 25 | tracing = "0.1.40" 26 | tracing-subscriber = { version = "0.3", default-features = false } 27 | dirs = "5.0.1" 28 | clap = { version = "4.5.4", features = ["derive"] } 29 | once_cell = "1.19.0" 30 | strum = "0.26.2" 31 | glob = "0.3.1" 32 | 33 | [dependencies] 34 | lsp-types = { workspace = true } 35 | serde_json = { workspace = true } 36 | serde = { workspace = true } 37 | anyhow = { workspace = true } 38 | thiserror = { workspace = true } 39 | regex = { workspace = true } 40 | tracing-appender = { workspace = true } 41 | tracing = { workspace = true } 42 | tracing-subscriber = { workspace = true, default-features = false } 43 | dirs = { workspace = true } 44 | clap = { workspace = true } 45 | once_cell = { workspace = true } 46 | strum = { workspace = true, features = ["derive"] } 47 | glob = { workspace = true } 48 | globwalk = "0.9.1" 49 | tree-sitter-php = "0.22.8" 50 | chrono = "0.4.38" 51 | toml = "0.8.19" 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kodai Kabasawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # testing-language-server 2 | 3 | ⚠️ **IMPORTANT NOTICE** 4 | This project is under active development and may introduce breaking changes. If you encounter any issues, please make sure to update to the latest version before reporting bugs. 5 | 6 | General purpose LSP server that integrate with testing. 7 | The language server is characterized by portability and extensibility. 8 | 9 | ## Motivation 10 | 11 | This LSP server is heavily influenced by the following tools 12 | 13 | - [neotest](https://github.com/nvim-neotest/neotest) 14 | - [Wallaby.js](https://wallabyjs.com) 15 | 16 | These tools are very useful and powerful. However, they depend on the execution environment, such as VSCode and Neovim, and the portability aspect was inconvenient for me. 17 | So, I designed this testing-language-server and its dedicated adapters for each test tool to be the middle layer to the parts that depend on each editor. 18 | 19 | This design makes it easy to view diagnostics from tests in any editor. Environment-dependent features like neotest and VSCode's built-in testing tools can also be achieved with minimal code using testing-language-server. 20 | 21 | ## Instllation 22 | 23 | ```sh 24 | cargo install testing-language-server 25 | cargo install testing-ls-adapter 26 | ``` 27 | 28 | ## Features 29 | 30 | - [x] Realtime testing diagnostics 31 | - [x] [VSCode extension](https://github.com/kbwo/vscode-testing-ls) 32 | - [x] [coc.nvim extension](https://github.com/kbwo/coc-testing-ls) 33 | - [x] For Neovim builtin LSP, see [testing-ls.nvim](https://github.com/kbwo/testing-ls.nvim) 34 | - [ ] More efficient checking of diagnostics 35 | - [ ] Useful commands in each extension 36 | 37 | ## Configuration 38 | 39 | ### Required settings for all editors 40 | You need to prepare .testingls.toml. See [this](./demo/.testingls.toml) for an example of the configuration. 41 | 42 | ```.testingls.toml 43 | enableWorkspaceDiagnostics = true 44 | 45 | [adapterCommand.cargo-test] 46 | path = "testing-ls-adapter" 47 | extra_arg = ["--test-kind=cargo-test"] 48 | include = ["/**/src/**/*.rs"] 49 | exclude = ["/**/target/**"] 50 | 51 | [adapterCommand.cargo-nextest] 52 | path = "testing-ls-adapter" 53 | extra_arg = ["--test-kind=cargo-nextest"] 54 | include = ["/**/src/**/*.rs"] 55 | exclude = ["/**/target/**"] 56 | 57 | [adapterCommand.jest] 58 | path = "testing-ls-adapter" 59 | extra_arg = ["--test-kind=jest"] 60 | include = ["/jest/*.js"] 61 | exclude = ["/jest/**/node_modules/**/*"] 62 | 63 | [adapterCommand.vitest] 64 | path = "testing-ls-adapter" 65 | extra_arg = ["--test-kind=vitest"] 66 | include = ["/vitest/*.test.ts", "/vitest/config/**/*.test.ts"] 67 | exclude = ["/vitest/**/node_modules/**/*"] 68 | 69 | [adapterCommand.deno] 70 | path = "testing-ls-adapter" 71 | extra_arg = ["--test-kind=deno"] 72 | include = ["/deno/*.ts"] 73 | exclude = [] 74 | 75 | [adapterCommand.go] 76 | path = "testing-ls-adapter" 77 | extra_arg = ["--test-kind=go-test"] 78 | include = ["/**/*.go"] 79 | exclude = [] 80 | 81 | [adapterCommand.node-test] 82 | path = "testing-ls-adapter" 83 | extra_arg = ["--test-kind=node-test"] 84 | include = ["/node-test/*.test.js"] 85 | exclude = [] 86 | 87 | [adapterCommand.phpunit] 88 | path = "testing-ls-adapter" 89 | extra_arg = ["--test-kind=phpunit"] 90 | include = ["/**/*Test.php"] 91 | exclude = ["/phpunit/vendor/**/*.php"] 92 | ``` 93 | 94 | ### VSCode 95 | 96 | Install from [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=kbwo.testing-language-server). 97 | You can see the example in [settings.json](./demo/.vscode/settings.json). 98 | 99 | ### coc.nvim 100 | Install from `:CocInstall coc-testing-ls`. 101 | You can see the example in [See more example](./.vim/coc-settings.json) 102 | 103 | ### Neovim (nvim-lspconfig) 104 | 105 | See [testing-ls.nvim](https://github.com/kbwo/testing-ls.nvim) 106 | 107 | ### Helix 108 | See [language.toml](./demo/.helix/language.toml). 109 | 110 | The array wrapper has been removed to simplify the configuration structure. Please update your settings accordingly. 111 | 112 | ## Adapter 113 | - [x] `cargo test` 114 | - [x] `cargo nextest` 115 | - [x] `jest` 116 | - [x] `deno test` 117 | - [x] `go test` 118 | - [x] `phpunit` 119 | - [x] `vitest` 120 | - [x] `node --test` (Node Test Runner) 121 | 122 | ### Writing custom adapter 123 | ⚠ The specification of adapter CLI is not stabilized yet. 124 | 125 | See [ADAPTER_SPEC.md](./doc/ADAPTER_SPEC.md) and [spec.rs](./src/spec.rs). -------------------------------------------------------------------------------- /crates/adapter/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "testing-ls-adapter" 3 | version = "0.1.2" 4 | edition = "2021" 5 | description = "testing-language-server adapter" 6 | license = "MIT" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | testing-language-server = "0.1.10" 12 | lsp-types = { workspace = true } 13 | serde_json = { workspace = true } 14 | serde = { workspace = true } 15 | regex = { workspace = true } 16 | clap = { workspace = true } 17 | tree-sitter = "0.22.5" 18 | tree-sitter-rust = "0.21.2" 19 | anyhow = { workspace = true } 20 | tempfile = "3.10.1" 21 | tree-sitter-javascript = "0.21.0" 22 | tree-sitter-go = "0.21.0" 23 | tree-sitter-php = "0.22.5" 24 | tracing-appender = { workspace = true } 25 | tracing = { workspace = true } 26 | tracing-subscriber = { workspace = true, default-features = false } 27 | dirs = "5.0.1" 28 | xml-rs = "0.8.21" 29 | -------------------------------------------------------------------------------- /crates/adapter/src/log.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use testing_language_server::util::clean_old_logs; 4 | use tracing_appender::non_blocking::WorkerGuard; 5 | 6 | pub struct Log; 7 | 8 | impl Log { 9 | fn log_dir() -> PathBuf { 10 | let home_dir = dirs::home_dir().unwrap(); 11 | 12 | home_dir.join(".config/testing_language_server/adapter/logs") 13 | } 14 | 15 | pub fn init() -> Result { 16 | let log_dir_path = Self::log_dir(); 17 | let prefix = "adapter.log"; 18 | let file_appender = tracing_appender::rolling::daily(&log_dir_path, prefix); 19 | let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); 20 | clean_old_logs( 21 | log_dir_path.to_str().unwrap(), 22 | 30, 23 | &format!("{prefix}.*"), 24 | &format!("{prefix}."), 25 | ) 26 | .unwrap(); 27 | tracing_subscriber::fmt().with_writer(non_blocking).init(); 28 | Ok(guard) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /crates/adapter/src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::model::AvailableTestKind; 2 | use crate::model::Runner; 3 | use anyhow::anyhow; 4 | use clap::Parser; 5 | use log::Log; 6 | use std::io; 7 | use std::io::Write; 8 | use std::str::FromStr; 9 | use testing_language_server::error::LSError; 10 | use testing_language_server::spec::AdapterCommands; 11 | use testing_language_server::spec::DetectWorkspaceArgs; 12 | use testing_language_server::spec::DiscoverArgs; 13 | use testing_language_server::spec::RunFileTestArgs; 14 | pub mod log; 15 | pub mod model; 16 | pub mod runner; 17 | 18 | fn pick_test_from_extra( 19 | extra: &mut [String], 20 | ) -> Result<(Vec, AvailableTestKind), anyhow::Error> { 21 | let mut extra = extra.to_vec(); 22 | let index = extra 23 | .iter() 24 | .position(|arg| arg.starts_with("--test-kind=")) 25 | .ok_or(anyhow!("test-kind is not found"))?; 26 | let test_kind = extra.remove(index); 27 | 28 | let language = test_kind.replace("--test-kind=", ""); 29 | Ok((extra, AvailableTestKind::from_str(&language)?)) 30 | } 31 | 32 | fn handle(commands: AdapterCommands) -> Result<(), LSError> { 33 | match commands { 34 | AdapterCommands::Discover(mut commands) => { 35 | let (extra, test_kind) = pick_test_from_extra(&mut commands.extra).unwrap(); 36 | test_kind.discover(DiscoverArgs { extra, ..commands })?; 37 | Ok(()) 38 | } 39 | AdapterCommands::RunFileTest(mut commands) => { 40 | let (extra, test_kind) = pick_test_from_extra(&mut commands.extra)?; 41 | test_kind.run_file_test(RunFileTestArgs { extra, ..commands })?; 42 | Ok(()) 43 | } 44 | AdapterCommands::DetectWorkspace(mut commands) => { 45 | let (extra, test_kind) = pick_test_from_extra(&mut commands.extra)?; 46 | test_kind.detect_workspaces(DetectWorkspaceArgs { extra, ..commands })?; 47 | Ok(()) 48 | } 49 | } 50 | } 51 | 52 | fn main() { 53 | let _guard = Log::init().expect("Failed to initialize logger"); 54 | let args = AdapterCommands::parse(); 55 | tracing::info!("adapter args={:#?}", args); 56 | if let Err(error) = handle(args) { 57 | io::stderr() 58 | .write_all(format!("{:#?}", error).as_bytes()) 59 | .unwrap(); 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use super::*; 66 | use crate::runner::cargo_test::CargoTestRunner; 67 | 68 | #[test] 69 | fn error_test_kind_detection() { 70 | let mut extra = vec![]; 71 | pick_test_from_extra(&mut extra).unwrap_err(); 72 | let mut extra = vec!["--foo=bar".to_string()]; 73 | pick_test_from_extra(&mut extra).unwrap_err(); 74 | } 75 | 76 | #[test] 77 | fn single_test_kind_detection() { 78 | let mut extra = vec!["--test-kind=cargo-test".to_string()]; 79 | let (_, language) = pick_test_from_extra(&mut extra).unwrap(); 80 | assert_eq!(language, AvailableTestKind::CargoTest(CargoTestRunner)); 81 | } 82 | 83 | #[test] 84 | fn multiple_test_kind_results_first_kind() { 85 | let mut extra = vec![ 86 | "--test-kind=cargo-test".to_string(), 87 | "--test-kind=jest".to_string(), 88 | "--test-kind=foo".to_string(), 89 | ]; 90 | let (_, test_kind) = pick_test_from_extra(&mut extra).unwrap(); 91 | assert_eq!(test_kind, AvailableTestKind::CargoTest(CargoTestRunner)); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/adapter/src/model.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::cargo_nextest::CargoNextestRunner; 2 | use crate::runner::cargo_test::CargoTestRunner; 3 | use crate::runner::deno::DenoRunner; 4 | use crate::runner::go::GoTestRunner; 5 | use crate::runner::node_test::NodeTestRunner; 6 | use crate::runner::phpunit::PhpunitRunner; 7 | use crate::runner::vitest::VitestRunner; 8 | use std::str::FromStr; 9 | use testing_language_server::error::LSError; 10 | use testing_language_server::spec::DetectWorkspaceArgs; 11 | use testing_language_server::spec::DiscoverArgs; 12 | use testing_language_server::spec::RunFileTestArgs; 13 | 14 | use crate::runner::jest::JestRunner; 15 | 16 | #[derive(Debug, Eq, PartialEq)] 17 | pub enum AvailableTestKind { 18 | CargoTest(CargoTestRunner), 19 | CargoNextest(CargoNextestRunner), 20 | Jest(JestRunner), 21 | Vitest(VitestRunner), 22 | Deno(DenoRunner), 23 | GoTest(GoTestRunner), 24 | Phpunit(PhpunitRunner), 25 | NodeTest(NodeTestRunner), 26 | } 27 | impl Runner for AvailableTestKind { 28 | fn discover(&self, args: DiscoverArgs) -> Result<(), LSError> { 29 | match self { 30 | AvailableTestKind::CargoTest(runner) => runner.discover(args), 31 | AvailableTestKind::CargoNextest(runner) => runner.discover(args), 32 | AvailableTestKind::Jest(runner) => runner.discover(args), 33 | AvailableTestKind::Deno(runner) => runner.discover(args), 34 | AvailableTestKind::GoTest(runner) => runner.discover(args), 35 | AvailableTestKind::Vitest(runner) => runner.discover(args), 36 | AvailableTestKind::Phpunit(runner) => runner.discover(args), 37 | AvailableTestKind::NodeTest(runner) => runner.discover(args), 38 | } 39 | } 40 | 41 | fn run_file_test(&self, args: RunFileTestArgs) -> Result<(), LSError> { 42 | match self { 43 | AvailableTestKind::CargoTest(runner) => runner.run_file_test(args), 44 | AvailableTestKind::CargoNextest(runner) => runner.run_file_test(args), 45 | AvailableTestKind::Jest(runner) => runner.run_file_test(args), 46 | AvailableTestKind::Deno(runner) => runner.run_file_test(args), 47 | AvailableTestKind::GoTest(runner) => runner.run_file_test(args), 48 | AvailableTestKind::Vitest(runner) => runner.run_file_test(args), 49 | AvailableTestKind::Phpunit(runner) => runner.run_file_test(args), 50 | AvailableTestKind::NodeTest(runner) => runner.run_file_test(args), 51 | } 52 | } 53 | 54 | fn detect_workspaces(&self, args: DetectWorkspaceArgs) -> Result<(), LSError> { 55 | match self { 56 | AvailableTestKind::CargoTest(runner) => runner.detect_workspaces(args), 57 | AvailableTestKind::CargoNextest(runner) => runner.detect_workspaces(args), 58 | AvailableTestKind::Jest(runner) => runner.detect_workspaces(args), 59 | AvailableTestKind::Deno(runner) => runner.detect_workspaces(args), 60 | AvailableTestKind::GoTest(runner) => runner.detect_workspaces(args), 61 | AvailableTestKind::Vitest(runner) => runner.detect_workspaces(args), 62 | AvailableTestKind::Phpunit(runner) => runner.detect_workspaces(args), 63 | AvailableTestKind::NodeTest(runner) => runner.detect_workspaces(args), 64 | } 65 | } 66 | } 67 | 68 | impl FromStr for AvailableTestKind { 69 | type Err = anyhow::Error; 70 | 71 | fn from_str(s: &str) -> Result { 72 | match s { 73 | "cargo-test" => Ok(AvailableTestKind::CargoTest(CargoTestRunner)), 74 | "cargo-nextest" => Ok(AvailableTestKind::CargoNextest(CargoNextestRunner)), 75 | "jest" => Ok(AvailableTestKind::Jest(JestRunner)), 76 | "go-test" => Ok(AvailableTestKind::GoTest(GoTestRunner)), 77 | "vitest" => Ok(AvailableTestKind::Vitest(VitestRunner)), 78 | "deno" => Ok(AvailableTestKind::Deno(DenoRunner)), 79 | "phpunit" => Ok(AvailableTestKind::Phpunit(PhpunitRunner)), 80 | "node-test" => Ok(AvailableTestKind::NodeTest(NodeTestRunner)), 81 | _ => Err(anyhow::anyhow!("Unknown test kind: {}", s)), 82 | } 83 | } 84 | } 85 | 86 | pub trait Runner { 87 | fn discover(&self, args: DiscoverArgs) -> Result<(), LSError>; 88 | fn run_file_test(&self, args: RunFileTestArgs) -> Result<(), LSError>; 89 | fn detect_workspaces(&self, args: DetectWorkspaceArgs) -> Result<(), LSError>; 90 | } 91 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/cargo_nextest.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::util::send_stdout; 2 | use std::path::PathBuf; 3 | use std::process::Output; 4 | use std::str::FromStr; 5 | use testing_language_server::error::LSError; 6 | use testing_language_server::spec::DetectWorkspaceResult; 7 | use testing_language_server::spec::RunFileTestResult; 8 | 9 | use testing_language_server::spec::DiscoverResult; 10 | use testing_language_server::spec::FoundFileTests; 11 | use testing_language_server::spec::TestItem; 12 | 13 | use crate::model::Runner; 14 | 15 | use super::util::detect_workspaces_from_file_list; 16 | use super::util::discover_rust_tests; 17 | use super::util::parse_cargo_diagnostics; 18 | use super::util::write_result_log; 19 | 20 | fn detect_workspaces(file_paths: &[String]) -> DetectWorkspaceResult { 21 | detect_workspaces_from_file_list(file_paths, &["Cargo.toml".to_string()]) 22 | } 23 | 24 | #[derive(Eq, PartialEq, Hash, Debug)] 25 | pub struct CargoNextestRunner; 26 | 27 | impl Runner for CargoNextestRunner { 28 | #[tracing::instrument(skip(self))] 29 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 30 | let file_paths = args.file_paths; 31 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 32 | 33 | for file_path in file_paths { 34 | let tests = discover_rust_tests(&file_path)?; 35 | discover_results.data.push(FoundFileTests { 36 | tests, 37 | path: file_path, 38 | }); 39 | } 40 | send_stdout(&discover_results)?; 41 | Ok(()) 42 | } 43 | 44 | #[tracing::instrument(skip(self))] 45 | fn run_file_test( 46 | &self, 47 | args: testing_language_server::spec::RunFileTestArgs, 48 | ) -> Result<(), LSError> { 49 | let file_paths = args.file_paths; 50 | let discovered_tests: Vec = file_paths 51 | .iter() 52 | .map(|path| discover_rust_tests(path)) 53 | .filter_map(Result::ok) 54 | .flatten() 55 | .collect::>(); 56 | let test_ids = discovered_tests 57 | .iter() 58 | .map(|item| item.id.clone()) 59 | .collect::>(); 60 | let workspace_root = args.workspace; 61 | let test_result = std::process::Command::new("cargo") 62 | .current_dir(&workspace_root) 63 | .arg("nextest") 64 | .arg("run") 65 | .arg("--workspace") 66 | .arg("--no-fail-fast") 67 | .args(args.extra) 68 | .arg("--") 69 | .args(&test_ids) 70 | .output() 71 | .unwrap(); 72 | let output = test_result; 73 | write_result_log("cargo_nextest.log", &output)?; 74 | let Output { 75 | stdout, 76 | stderr, 77 | status, 78 | } = output; 79 | let unexpected_status_code = status.code().map(|code| code != 100); 80 | if stdout.is_empty() && !stderr.is_empty() && unexpected_status_code.unwrap_or(false) { 81 | return Err(LSError::Adapter(String::from_utf8(stderr).unwrap())); 82 | } 83 | let test_result = String::from_utf8(stderr)?; 84 | let diagnostics: RunFileTestResult = parse_cargo_diagnostics( 85 | &test_result, 86 | PathBuf::from_str(&workspace_root).unwrap(), 87 | &file_paths, 88 | &discovered_tests, 89 | ); 90 | send_stdout(&diagnostics)?; 91 | Ok(()) 92 | } 93 | 94 | #[tracing::instrument(skip(self))] 95 | fn detect_workspaces( 96 | &self, 97 | args: testing_language_server::spec::DetectWorkspaceArgs, 98 | ) -> Result<(), LSError> { 99 | let file_paths = args.file_paths; 100 | let detect_result = detect_workspaces(&file_paths); 101 | send_stdout(&detect_result)?; 102 | Ok(()) 103 | } 104 | } 105 | 106 | #[cfg(test)] 107 | mod tests { 108 | use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; 109 | use testing_language_server::spec::{FileDiagnostics, TestItem}; 110 | 111 | use crate::runner::util::MAX_CHAR_LENGTH; 112 | 113 | use super::*; 114 | 115 | #[test] 116 | fn parse_test_results() { 117 | let fixture = r#" 118 | running 1 test 119 | test rocks::dependency::tests::parse_dependency ... FAILED 120 | failures: 121 | Finished test [unoptimized + debuginfo] target(s) in 0.12s 122 | Starting 1 test across 2 binaries (17 skipped) 123 | FAIL [ 0.004s] rocks-lib rocks::dependency::tests::parse_dependency 124 | test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 17 filtered out; finis 125 | hed in 0.00s 126 | --- STDERR: rocks-lib rocks::dependency::tests::parse_dependency --- 127 | thread 'rocks::dependency::tests::parse_dependency' panicked at rocks-lib/src/rocks/dependency.rs:86:64: 128 | called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number 129 | Location: 130 | rocks-lib/src/rocks/dependency.rs:62:22 131 | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace 132 | 133 | "#; 134 | let file_paths = 135 | vec!["/home/example/projects/rocks-lib/src/rocks/dependency.rs".to_string()]; 136 | let test_items: Vec = vec![TestItem { 137 | id: "rocks::dependency::tests::parse_dependency".to_string(), 138 | name: "rocks::dependency::tests::parse_dependency".to_string(), 139 | path: "/home/example/projects/rocks-lib/src/rocks/dependency.rs".to_string(), 140 | start_position: Range { 141 | start: Position { 142 | line: 85, 143 | character: 63, 144 | }, 145 | end: Position { 146 | line: 85, 147 | character: MAX_CHAR_LENGTH, 148 | }, 149 | }, 150 | end_position: Range { 151 | start: Position { 152 | line: 85, 153 | character: 63, 154 | }, 155 | end: Position { 156 | line: 85, 157 | character: MAX_CHAR_LENGTH, 158 | }, 159 | }, 160 | }]; 161 | let diagnostics: RunFileTestResult = parse_cargo_diagnostics( 162 | fixture, 163 | PathBuf::from_str("/home/example/projects").unwrap(), 164 | &file_paths, 165 | &test_items, 166 | ); 167 | let message = r#"called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number 168 | Location: 169 | rocks-lib/src/rocks/dependency.rs:62:22 170 | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace 171 | "#; 172 | 173 | assert_eq!( 174 | diagnostics, 175 | RunFileTestResult { 176 | data: vec![FileDiagnostics { 177 | path: file_paths.first().unwrap().to_owned(), 178 | diagnostics: vec![Diagnostic { 179 | range: Range { 180 | start: Position { 181 | line: 85, 182 | character: 63 183 | }, 184 | end: Position { 185 | line: 85, 186 | character: MAX_CHAR_LENGTH 187 | } 188 | }, 189 | message: message.to_string(), 190 | severity: Some(DiagnosticSeverity::ERROR), 191 | ..Diagnostic::default() 192 | }] 193 | }], 194 | messages: vec!() 195 | } 196 | ) 197 | } 198 | 199 | #[test] 200 | fn test_discover() { 201 | let file_path = "../../demo/rust/src/lib.rs"; 202 | discover_rust_tests(file_path).unwrap(); 203 | } 204 | 205 | #[test] 206 | fn test_detect_workspaces() { 207 | let current_dir = std::env::current_dir().unwrap(); 208 | let librs = current_dir.join("src/lib.rs"); 209 | let mainrs = current_dir.join("src/main.rs"); 210 | let absolute_path_of_demo = current_dir.join("../../demo/rust"); 211 | let demo_librs = absolute_path_of_demo.join("src/lib.rs"); 212 | let file_paths: Vec = [librs, mainrs, demo_librs] 213 | .iter() 214 | .map(|file_path| file_path.to_str().unwrap().to_string()) 215 | .collect(); 216 | 217 | let workspaces = detect_workspaces(&file_paths); 218 | assert_eq!(workspaces.data.len(), 2); 219 | assert!(workspaces 220 | .data 221 | .contains_key(absolute_path_of_demo.to_str().unwrap())); 222 | assert!(workspaces.data.contains_key(current_dir.to_str().unwrap())); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/cargo_test.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::util::send_stdout; 2 | use std::path::PathBuf; 3 | use std::process::Output; 4 | use std::str::FromStr; 5 | use testing_language_server::error::LSError; 6 | use testing_language_server::spec::DetectWorkspaceResult; 7 | use testing_language_server::spec::RunFileTestResult; 8 | 9 | use testing_language_server::spec::DiscoverResult; 10 | use testing_language_server::spec::FoundFileTests; 11 | use testing_language_server::spec::TestItem; 12 | 13 | use crate::model::Runner; 14 | 15 | use super::util::detect_workspaces_from_file_list; 16 | use super::util::discover_rust_tests; 17 | use super::util::parse_cargo_diagnostics; 18 | use super::util::write_result_log; 19 | 20 | fn detect_workspaces(file_paths: &[String]) -> DetectWorkspaceResult { 21 | detect_workspaces_from_file_list(file_paths, &["Cargo.toml".to_string()]) 22 | } 23 | 24 | #[derive(Eq, PartialEq, Hash, Debug)] 25 | pub struct CargoTestRunner; 26 | 27 | impl Runner for CargoTestRunner { 28 | #[tracing::instrument(skip(self))] 29 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 30 | let file_paths = args.file_paths; 31 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 32 | 33 | for file_path in file_paths { 34 | let tests = discover_rust_tests(&file_path)?; 35 | discover_results.data.push(FoundFileTests { 36 | tests, 37 | path: file_path, 38 | }); 39 | } 40 | send_stdout(&discover_results)?; 41 | Ok(()) 42 | } 43 | 44 | #[tracing::instrument(skip(self))] 45 | fn run_file_test( 46 | &self, 47 | args: testing_language_server::spec::RunFileTestArgs, 48 | ) -> Result<(), LSError> { 49 | let file_paths = args.file_paths; 50 | let discovered_tests: Vec = file_paths 51 | .iter() 52 | .map(|path| discover_rust_tests(path)) 53 | .filter_map(Result::ok) 54 | .flatten() 55 | .collect::>(); 56 | let test_ids = discovered_tests 57 | .iter() 58 | .map(|item| item.id.clone()) 59 | .collect::>(); 60 | let workspace_root = args.workspace; 61 | let test_result = std::process::Command::new("cargo") 62 | .current_dir(&workspace_root) 63 | .arg("test") 64 | .args(args.extra) 65 | .arg("--") 66 | .args(&test_ids) 67 | .output() 68 | .unwrap(); 69 | let output = test_result; 70 | write_result_log("cargo_test.log", &output)?; 71 | let Output { stdout, stderr, .. } = output; 72 | if stdout.is_empty() { 73 | return Err(LSError::Adapter(String::from_utf8(stderr).unwrap())); 74 | } 75 | // When `--nocapture` option is set, stderr has some important information 76 | // to parse test result 77 | let test_result = String::from_utf8(stderr)? + &String::from_utf8(stdout)?; 78 | 79 | let diagnostics: RunFileTestResult = parse_cargo_diagnostics( 80 | &test_result, 81 | PathBuf::from_str(&workspace_root).unwrap(), 82 | &file_paths, 83 | &discovered_tests, 84 | ); 85 | send_stdout(&diagnostics)?; 86 | Ok(()) 87 | } 88 | 89 | #[tracing::instrument(skip(self))] 90 | fn detect_workspaces( 91 | &self, 92 | args: testing_language_server::spec::DetectWorkspaceArgs, 93 | ) -> Result<(), LSError> { 94 | let file_paths = args.file_paths; 95 | let detect_result = detect_workspaces(&file_paths); 96 | send_stdout(&detect_result)?; 97 | Ok(()) 98 | } 99 | } 100 | 101 | #[cfg(test)] 102 | mod tests { 103 | use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; 104 | use testing_language_server::spec::FileDiagnostics; 105 | 106 | use crate::runner::util::MAX_CHAR_LENGTH; 107 | 108 | use super::*; 109 | 110 | #[test] 111 | fn parse_test_results() { 112 | let fixture = r#" 113 | running 1 test 114 | test rocks::dependency::tests::parse_dependency ... FAILED 115 | failures: 116 | Finished test [unoptimized + debuginfo] target(s) in 0.12s 117 | Starting 1 test across 2 binaries (17 skipped) 118 | FAIL [ 0.004s] rocks-lib rocks::dependency::tests::parse_dependency 119 | test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 17 filtered out; finis 120 | hed in 0.00s 121 | --- STDERR: rocks-lib rocks::dependency::tests::parse_dependency --- 122 | thread 'rocks::dependency::tests::parse_dependency' panicked at rocks-lib/src/rocks/dependency.rs:86:64: 123 | called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number 124 | Location: 125 | rocks-lib/src/rocks/dependency.rs:62:22 126 | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace 127 | 128 | "#; 129 | let file_paths = 130 | vec!["/home/example/projects/rocks-lib/src/rocks/dependency.rs".to_string()]; 131 | let test_items: Vec = vec![TestItem { 132 | id: "rocks::dependency::tests::parse_dependency".to_string(), 133 | name: "rocks::dependency::tests::parse_dependency".to_string(), 134 | path: "/home/example/projects/rocks-lib/src/rocks/dependency.rs".to_string(), 135 | start_position: Range { 136 | start: Position { 137 | line: 85, 138 | character: 63, 139 | }, 140 | end: Position { 141 | line: 85, 142 | character: MAX_CHAR_LENGTH, 143 | }, 144 | }, 145 | end_position: Range { 146 | start: Position { 147 | line: 85, 148 | character: 63, 149 | }, 150 | end: Position { 151 | line: 85, 152 | character: MAX_CHAR_LENGTH, 153 | }, 154 | }, 155 | }]; 156 | let diagnostics: RunFileTestResult = parse_cargo_diagnostics( 157 | fixture, 158 | PathBuf::from_str("/home/example/projects").unwrap(), 159 | &file_paths, 160 | &test_items, 161 | ); 162 | let message = r#"called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number 163 | Location: 164 | rocks-lib/src/rocks/dependency.rs:62:22 165 | note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace 166 | "#; 167 | 168 | assert_eq!( 169 | diagnostics, 170 | RunFileTestResult { 171 | data: vec![FileDiagnostics { 172 | path: file_paths.first().unwrap().to_owned(), 173 | diagnostics: vec![Diagnostic { 174 | range: Range { 175 | start: Position { 176 | line: 85, 177 | character: 63 178 | }, 179 | end: Position { 180 | line: 85, 181 | character: MAX_CHAR_LENGTH 182 | } 183 | }, 184 | message: message.to_string(), 185 | severity: Some(DiagnosticSeverity::ERROR), 186 | ..Diagnostic::default() 187 | }] 188 | }], 189 | messages: vec![] 190 | } 191 | ) 192 | } 193 | 194 | #[test] 195 | fn test_discover() { 196 | let file_path = "../../demo/rust/src/lib.rs"; 197 | discover_rust_tests(file_path).unwrap(); 198 | } 199 | 200 | #[test] 201 | fn test_detect_workspaces() { 202 | let current_dir = std::env::current_dir().unwrap(); 203 | let librs = current_dir.join("src/lib.rs"); 204 | let mainrs = current_dir.join("src/main.rs"); 205 | let absolute_path_of_demo = current_dir.join("../../demo/rust"); 206 | let demo_librs = absolute_path_of_demo.join("src/lib.rs"); 207 | let file_paths: Vec = [librs, mainrs, demo_librs] 208 | .iter() 209 | .map(|file_path| file_path.to_str().unwrap().to_string()) 210 | .collect(); 211 | 212 | let workspaces = detect_workspaces(&file_paths); 213 | assert_eq!(workspaces.data.len(), 2); 214 | assert!(workspaces 215 | .data 216 | .contains_key(absolute_path_of_demo.to_str().unwrap())); 217 | assert!(workspaces.data.contains_key(current_dir.to_str().unwrap())); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/deno.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::util::resolve_path; 2 | use crate::runner::util::send_stdout; 3 | use lsp_types::Diagnostic; 4 | use lsp_types::DiagnosticSeverity; 5 | use lsp_types::Position; 6 | use lsp_types::Range; 7 | use regex::Regex; 8 | use std::collections::HashMap; 9 | use std::path::PathBuf; 10 | use std::process::Output; 11 | use std::str::FromStr; 12 | use testing_language_server::error::LSError; 13 | 14 | use testing_language_server::spec::DetectWorkspaceResult; 15 | use testing_language_server::spec::DiscoverResult; 16 | use testing_language_server::spec::FileDiagnostics; 17 | use testing_language_server::spec::FoundFileTests; 18 | use testing_language_server::spec::RunFileTestResult; 19 | use testing_language_server::spec::TestItem; 20 | 21 | use crate::model::Runner; 22 | 23 | use super::util::clean_ansi; 24 | use super::util::detect_workspaces_from_file_list; 25 | use super::util::discover_with_treesitter; 26 | use super::util::write_result_log; 27 | use super::util::MAX_CHAR_LENGTH; 28 | 29 | fn get_position_from_output(line: &str) -> Option<(String, u32, u32)> { 30 | let re = Regex::new(r"=> (?P.*):(?P\d+):(?P\d+)").unwrap(); 31 | 32 | if let Some(captures) = re.captures(line) { 33 | let file = captures.name("file").unwrap().as_str().to_string(); 34 | let line = captures.name("line").unwrap().as_str().parse().unwrap(); 35 | let column = captures.name("column").unwrap().as_str().parse().unwrap(); 36 | 37 | Some((file, line, column)) 38 | } else { 39 | None 40 | } 41 | } 42 | 43 | fn parse_diagnostics( 44 | contents: &str, 45 | workspace_root: PathBuf, 46 | file_paths: &[String], 47 | ) -> Result { 48 | let contents = clean_ansi(&contents.replace("\r\n", "\n")); 49 | let lines = contents.lines(); 50 | let mut result_map: HashMap> = HashMap::new(); 51 | let mut file_name: Option = None; 52 | let mut lnum: Option = None; 53 | let mut message = String::new(); 54 | let mut error_exists = false; 55 | for line in lines { 56 | if line.contains("ERRORS") { 57 | error_exists = true; 58 | } else if !error_exists { 59 | continue; 60 | } 61 | if let Some(position) = get_position_from_output(line) { 62 | if file_name.is_some() { 63 | let diagnostic = Diagnostic { 64 | range: Range { 65 | start: Position { 66 | line: lnum.unwrap(), 67 | character: 1, 68 | }, 69 | end: Position { 70 | line: lnum.unwrap(), 71 | character: MAX_CHAR_LENGTH, 72 | }, 73 | }, 74 | message: message.clone(), 75 | severity: Some(DiagnosticSeverity::ERROR), 76 | ..Diagnostic::default() 77 | }; 78 | let file_path = resolve_path(&workspace_root, file_name.as_ref().unwrap()) 79 | .to_str() 80 | .unwrap() 81 | .to_string(); 82 | if file_paths.contains(&file_path) { 83 | result_map.entry(file_path).or_default().push(diagnostic); 84 | } 85 | } 86 | file_name = Some(position.0); 87 | lnum = Some(position.1); 88 | } else { 89 | message += line; 90 | } 91 | } 92 | Ok(RunFileTestResult { 93 | data: result_map 94 | .into_iter() 95 | .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics }) 96 | .collect(), 97 | messages: vec![], 98 | }) 99 | } 100 | 101 | fn detect_workspaces(file_paths: Vec) -> DetectWorkspaceResult { 102 | detect_workspaces_from_file_list(&file_paths, &["deno.json".to_string()]) 103 | } 104 | 105 | fn discover(file_path: &str) -> Result, LSError> { 106 | // from https://github.com/MarkEmmons/neotest-deno/blob/7136b9342aeecb675c7c16a0bde327d7fcb00a1c/lua/neotest-deno/init.lua#L93 107 | // license: https://github.com/MarkEmmons/neotest-deno/blob/main/LICENSE 108 | let query = r#" 109 | ;; Deno.test 110 | (call_expression 111 | function: (member_expression) @func_name (#match? @func_name "^Deno.test$") 112 | arguments: [ 113 | (arguments ((string) @test.name . (arrow_function))) 114 | (arguments . (function_expression name: (identifier) @test.name)) 115 | (arguments . (object(pair 116 | key: (property_identifier) @key (#match? @key "^name$") 117 | value: (string) @test.name 118 | ))) 119 | (arguments ((string) @test.name . (object) . (arrow_function))) 120 | (arguments (object) . (function_expression name: (identifier) @test.name)) 121 | ] 122 | ) @test.definition 123 | 124 | ;; BDD describe - nested 125 | (call_expression 126 | function: (identifier) @func_name (#match? @func_name "^describe$") 127 | arguments: [ 128 | (arguments ((string) @namespace.name . (arrow_function))) 129 | (arguments ((string) @namespace.name . (function_expression))) 130 | ] 131 | ) @namespace.definition 132 | 133 | ;; BDD describe - flat 134 | (variable_declarator 135 | name: (identifier) @namespace.id 136 | value: (call_expression 137 | function: (identifier) @func_name (#match? @func_name "^describe") 138 | arguments: [ 139 | (arguments ((string) @namespace.name)) 140 | (arguments (object (pair 141 | key: (property_identifier) @key (#match? @key "^name$") 142 | value: (string) @namespace.name 143 | ))) 144 | ] 145 | ) 146 | ) @namespace.definition 147 | 148 | ;; BDD it 149 | (call_expression 150 | function: (identifier) @func_name (#match? @func_name "^it$") 151 | arguments: [ 152 | (arguments ((string) @test.name . (arrow_function))) 153 | (arguments ((string) @test.name . (function_expression))) 154 | ] 155 | ) @test.definition 156 | "#; 157 | discover_with_treesitter(file_path, &tree_sitter_javascript::language(), query) 158 | } 159 | 160 | #[derive(Eq, PartialEq, Debug)] 161 | pub struct DenoRunner; 162 | 163 | impl Runner for DenoRunner { 164 | #[tracing::instrument(skip(self))] 165 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 166 | let file_paths = args.file_paths; 167 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 168 | for file_path in file_paths { 169 | discover_results.data.push(FoundFileTests { 170 | tests: discover(&file_path)?, 171 | path: file_path, 172 | }) 173 | } 174 | send_stdout(&discover_results)?; 175 | Ok(()) 176 | } 177 | 178 | #[tracing::instrument(skip(self))] 179 | fn run_file_test( 180 | &self, 181 | args: testing_language_server::spec::RunFileTestArgs, 182 | ) -> Result<(), LSError> { 183 | let file_paths = args.file_paths; 184 | let workspace = args.workspace; 185 | let output = std::process::Command::new("deno") 186 | .current_dir(&workspace) 187 | .args(["test", "--no-prompt"]) 188 | .args(&file_paths) 189 | .output() 190 | .unwrap(); 191 | write_result_log("deno.log", &output)?; 192 | let Output { stdout, stderr, .. } = output; 193 | if stdout.is_empty() { 194 | return Err(LSError::Adapter(String::from_utf8(stderr).unwrap())); 195 | } 196 | let test_result = String::from_utf8(stdout)?; 197 | let diagnostics: RunFileTestResult = parse_diagnostics( 198 | &test_result, 199 | PathBuf::from_str(&workspace).unwrap(), 200 | &file_paths, 201 | )?; 202 | send_stdout(&diagnostics)?; 203 | Ok(()) 204 | } 205 | 206 | #[tracing::instrument(skip(self))] 207 | fn detect_workspaces( 208 | &self, 209 | args: testing_language_server::spec::DetectWorkspaceArgs, 210 | ) -> Result<(), LSError> { 211 | let file_paths = args.file_paths; 212 | let detect_result = detect_workspaces(file_paths); 213 | send_stdout(&detect_result)?; 214 | Ok(()) 215 | } 216 | } 217 | 218 | #[cfg(test)] 219 | mod tests { 220 | 221 | use std::env::current_dir; 222 | 223 | use super::*; 224 | 225 | #[test] 226 | fn test_parse_diagnostics() { 227 | let test_result = std::env::current_dir() 228 | .unwrap() 229 | .join("../../demo/deno/output.txt"); 230 | let test_result = std::fs::read_to_string(test_result).unwrap(); 231 | let workspace = PathBuf::from_str("/home/demo/test/dneo/").unwrap(); 232 | let target_file_path = "/home/demo/test/dneo/main_test.ts"; 233 | let diagnostics = 234 | parse_diagnostics(&test_result, workspace, &[target_file_path.to_string()]).unwrap(); 235 | assert_eq!(diagnostics.data.len(), 1); 236 | } 237 | 238 | #[test] 239 | fn test_detect_workspace() { 240 | let current_dir = std::env::current_dir().unwrap(); 241 | let absolute_path_of_demo = current_dir.join("../../demo/deno"); 242 | let test_file = absolute_path_of_demo.join("main.test.ts"); 243 | let file_paths: Vec = [test_file] 244 | .iter() 245 | .map(|file_path| file_path.to_str().unwrap().to_string()) 246 | .collect(); 247 | let detect_result = detect_workspaces(file_paths); 248 | assert_eq!(detect_result.data.len(), 1); 249 | detect_result.data.iter().for_each(|(workspace, _)| { 250 | assert_eq!(workspace, absolute_path_of_demo.to_str().unwrap()); 251 | }); 252 | } 253 | 254 | #[test] 255 | fn test_discover() { 256 | let file_path = current_dir().unwrap().join("../../demo/deno/main_test.ts"); 257 | let file_path = file_path.to_str().unwrap(); 258 | let test_items = discover(file_path).unwrap(); 259 | assert_eq!(test_items.len(), 3); 260 | assert_eq!( 261 | test_items, 262 | vec![ 263 | TestItem { 264 | id: String::from("addTest"), 265 | name: String::from("addTest"), 266 | path: file_path.to_string(), 267 | start_position: Range { 268 | start: Position { 269 | line: 7, 270 | character: 0 271 | }, 272 | end: Position { 273 | line: 7, 274 | character: 10000 275 | } 276 | }, 277 | end_position: Range { 278 | start: Position { 279 | line: 9, 280 | character: 0 281 | }, 282 | end: Position { 283 | line: 9, 284 | character: 2 285 | } 286 | } 287 | }, 288 | TestItem { 289 | id: String::from("fail1"), 290 | name: String::from("fail1"), 291 | path: file_path.to_string(), 292 | start_position: Range { 293 | start: Position { 294 | line: 11, 295 | character: 0 296 | }, 297 | end: Position { 298 | line: 11, 299 | character: 10000 300 | } 301 | }, 302 | end_position: Range { 303 | start: Position { 304 | line: 13, 305 | character: 0 306 | }, 307 | end: Position { 308 | line: 13, 309 | character: 2 310 | } 311 | } 312 | }, 313 | TestItem { 314 | id: String::from("fail2"), 315 | name: String::from("fail2"), 316 | path: file_path.to_string(), 317 | start_position: Range { 318 | start: Position { 319 | line: 15, 320 | character: 0 321 | }, 322 | end: Position { 323 | line: 15, 324 | character: 10000 325 | } 326 | }, 327 | end_position: Range { 328 | start: Position { 329 | line: 17, 330 | character: 0 331 | }, 332 | end: Position { 333 | line: 17, 334 | character: 2 335 | } 336 | } 337 | } 338 | ] 339 | ) 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/go.rs: -------------------------------------------------------------------------------- 1 | use crate::model::Runner; 2 | use crate::runner::util::send_stdout; 3 | use anyhow::anyhow; 4 | use lsp_types::Diagnostic; 5 | use lsp_types::DiagnosticSeverity; 6 | use lsp_types::Position; 7 | use lsp_types::Range; 8 | use regex::Regex; 9 | use serde::Deserialize; 10 | use std::collections::HashMap; 11 | use std::path::PathBuf; 12 | use std::process::Output; 13 | use std::str::FromStr; 14 | use testing_language_server::error::LSError; 15 | use testing_language_server::spec::DiscoverResult; 16 | use testing_language_server::spec::FileDiagnostics; 17 | use testing_language_server::spec::FoundFileTests; 18 | use testing_language_server::spec::RunFileTestResult; 19 | use testing_language_server::spec::TestItem; 20 | 21 | use super::util::detect_workspaces_from_file_list; 22 | use super::util::discover_with_treesitter; 23 | use super::util::write_result_log; 24 | use super::util::MAX_CHAR_LENGTH; 25 | 26 | #[derive(Deserialize, Eq, PartialEq)] 27 | #[serde(rename_all = "camelCase")] 28 | enum Action { 29 | Start, 30 | Run, 31 | Output, 32 | Fail, 33 | Pass, 34 | } 35 | 36 | #[allow(dead_code)] 37 | #[derive(Deserialize)] 38 | #[serde(rename_all = "PascalCase")] 39 | struct TestResultLine { 40 | time: String, 41 | action: Action, 42 | package: String, 43 | test: Option, 44 | output: Option, 45 | } 46 | 47 | fn get_position_from_output(output: &str) -> Option<(String, u32)> { 48 | let pattern = r"^\s{4}(.*_test\.go):(\d+):"; 49 | let re = Regex::new(pattern).unwrap(); 50 | if let Some(captures) = re.captures(output) { 51 | if let (Some(file_name), Some(lnum)) = (captures.get(1), captures.get(2)) { 52 | return Some(( 53 | file_name.as_str().to_string(), 54 | lnum.as_str().parse::().unwrap() - 1, 55 | )); 56 | } 57 | } 58 | None 59 | } 60 | fn get_log_from_output(output: &str) -> String { 61 | output.replace(" ", "") 62 | } 63 | 64 | fn parse_diagnostics( 65 | contents: &str, 66 | workspace_root: PathBuf, 67 | file_paths: &[String], 68 | ) -> Result { 69 | let contents = contents.replace("\r\n", "\n"); 70 | let lines = contents.lines(); 71 | let mut result_map: HashMap> = HashMap::new(); 72 | let mut file_name: Option = None; 73 | let mut lnum: Option = None; 74 | let mut message = String::new(); 75 | let mut last_action: Option = None; 76 | for line in lines { 77 | let value: TestResultLine = serde_json::from_str(line).map_err(|e| anyhow!("{:?}", e))?; 78 | match value.action { 79 | Action::Run => { 80 | file_name = None; 81 | message = String::new(); 82 | } 83 | Action::Output => { 84 | let output = &value.output.unwrap(); 85 | if let Some((detected_file_name, detected_lnum)) = get_position_from_output(output) 86 | { 87 | file_name = Some(detected_file_name); 88 | lnum = Some(detected_lnum); 89 | message = String::new(); 90 | } else { 91 | message += &get_log_from_output(output); 92 | } 93 | } 94 | _ => {} 95 | } 96 | let current_action = value.action; 97 | let is_action_changed = last_action.as_ref() != Some(¤t_action); 98 | if is_action_changed { 99 | last_action = Some(current_action); 100 | } else { 101 | continue; 102 | } 103 | 104 | if let (Some(detected_fn), Some(detected_lnum)) = (&file_name, lnum) { 105 | let diagnostic = Diagnostic { 106 | range: Range { 107 | start: Position { 108 | line: detected_lnum, 109 | character: 1, 110 | }, 111 | end: Position { 112 | line: detected_lnum, 113 | character: MAX_CHAR_LENGTH, 114 | }, 115 | }, 116 | message: message.clone(), 117 | severity: Some(DiagnosticSeverity::ERROR), 118 | ..Diagnostic::default() 119 | }; 120 | let file_path = workspace_root 121 | .join(detected_fn) 122 | .to_str() 123 | .unwrap() 124 | .to_owned(); 125 | if file_paths.contains(&file_path) { 126 | result_map.entry(file_path).or_default().push(diagnostic); 127 | } 128 | file_name = None; 129 | lnum = None; 130 | } 131 | } 132 | 133 | Ok(RunFileTestResult { 134 | data: result_map 135 | .into_iter() 136 | .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics }) 137 | .collect(), 138 | messages: vec![], 139 | }) 140 | } 141 | 142 | fn discover(file_path: &str) -> Result, LSError> { 143 | // from https://github.com/nvim-neotest/neotest-go/blob/92950ad7be2ca02a41abca5c6600ff6ffaf5b5d6/lua/neotest-go/init.lua#L54 144 | // license: https://github.com/nvim-neotest/neotest-go/blob/92950ad7be2ca02a41abca5c6600ff6ffaf5b5d6/README.md 145 | let query = r#" 146 | ;;query 147 | ((function_declaration 148 | name: (identifier) @test.name) 149 | (#match? @test.name "^(Test|Example)")) 150 | @test.definition 151 | 152 | (method_declaration 153 | name: (field_identifier) @test.name 154 | (#match? @test.name "^(Test|Example)")) @test.definition 155 | 156 | (call_expression 157 | function: (selector_expression 158 | field: (field_identifier) @test.method) 159 | (#match? @test.method "^Run$") 160 | arguments: (argument_list . (interpreted_string_literal) @test.name)) 161 | @test.definition 162 | ;; query for list table tests 163 | (block 164 | (short_var_declaration 165 | left: (expression_list 166 | (identifier) @test.cases) 167 | right: (expression_list 168 | (composite_literal 169 | (literal_value 170 | (literal_element 171 | (literal_value 172 | (keyed_element 173 | (literal_element 174 | (identifier) @test.field.name) 175 | (literal_element 176 | (interpreted_string_literal) @test.name)))) @test.definition)))) 177 | (for_statement 178 | (range_clause 179 | left: (expression_list 180 | (identifier) @test.case) 181 | right: (identifier) @test.cases1 182 | (#eq? @test.cases @test.cases1)) 183 | body: (block 184 | (expression_statement 185 | (call_expression 186 | function: (selector_expression 187 | field: (field_identifier) @test.method) 188 | (#match? @test.method "^Run$") 189 | arguments: (argument_list 190 | (selector_expression 191 | operand: (identifier) @test.case1 192 | (#eq? @test.case @test.case1) 193 | field: (field_identifier) @test.field.name1 194 | (#eq? @test.field.name @test.field.name1)))))))) 195 | 196 | ;; query for map table tests 197 | (block 198 | (short_var_declaration 199 | left: (expression_list 200 | (identifier) @test.cases) 201 | right: (expression_list 202 | (composite_literal 203 | (literal_value 204 | (keyed_element 205 | (literal_element 206 | (interpreted_string_literal) @test.name) 207 | (literal_element 208 | (literal_value) @test.definition)))))) 209 | (for_statement 210 | (range_clause 211 | left: (expression_list 212 | ((identifier) @test.key.name) 213 | ((identifier) @test.case)) 214 | right: (identifier) @test.cases1 215 | (#eq? @test.cases @test.cases1)) 216 | body: (block 217 | (expression_statement 218 | (call_expression 219 | function: (selector_expression 220 | field: (field_identifier) @test.method) 221 | (#match? @test.method "^Run$") 222 | arguments: (argument_list 223 | ((identifier) @test.key.name1 224 | (#eq? @test.key.name @test.key.name1)))))))) 225 | "#; 226 | discover_with_treesitter(file_path, &tree_sitter_go::language(), query) 227 | } 228 | 229 | #[derive(Eq, PartialEq, Hash, Debug)] 230 | pub struct GoTestRunner; 231 | impl Runner for GoTestRunner { 232 | #[tracing::instrument(skip(self))] 233 | fn discover( 234 | &self, 235 | args: testing_language_server::spec::DiscoverArgs, 236 | ) -> Result<(), testing_language_server::error::LSError> { 237 | let file_paths = args.file_paths; 238 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 239 | 240 | for file_path in file_paths { 241 | let tests = discover(&file_path)?; 242 | discover_results.data.push(FoundFileTests { 243 | tests, 244 | path: file_path, 245 | }); 246 | } 247 | send_stdout(&discover_results)?; 248 | Ok(()) 249 | } 250 | 251 | #[tracing::instrument(skip(self))] 252 | fn run_file_test( 253 | &self, 254 | args: testing_language_server::spec::RunFileTestArgs, 255 | ) -> Result<(), testing_language_server::error::LSError> { 256 | let file_paths = args.file_paths; 257 | let default_args = ["-v", "-json", "", "-count=1", "-timeout=60s"]; 258 | let workspace = args.workspace; 259 | let output = std::process::Command::new("go") 260 | .current_dir(&workspace) 261 | .arg("test") 262 | .args(default_args) 263 | .args(args.extra) 264 | .output() 265 | .unwrap(); 266 | write_result_log("go.log", &output)?; 267 | let Output { stdout, stderr, .. } = output; 268 | if stdout.is_empty() && !stderr.is_empty() { 269 | return Err(LSError::Adapter(String::from_utf8(stderr).unwrap())); 270 | } 271 | let test_result = String::from_utf8(stdout)?; 272 | let diagnostics: RunFileTestResult = parse_diagnostics( 273 | &test_result, 274 | PathBuf::from_str(&workspace).unwrap(), 275 | &file_paths, 276 | )?; 277 | send_stdout(&diagnostics)?; 278 | Ok(()) 279 | } 280 | 281 | #[tracing::instrument(skip(self))] 282 | fn detect_workspaces( 283 | &self, 284 | args: testing_language_server::spec::DetectWorkspaceArgs, 285 | ) -> Result<(), testing_language_server::error::LSError> { 286 | send_stdout(&detect_workspaces_from_file_list( 287 | &args.file_paths, 288 | &["go.mod".to_string()], 289 | ))?; 290 | Ok(()) 291 | } 292 | } 293 | 294 | #[cfg(test)] 295 | mod tests { 296 | use crate::runner::go::discover; 297 | use std::str::FromStr; 298 | use std::{fs::read_to_string, path::PathBuf}; 299 | 300 | use crate::runner::go::parse_diagnostics; 301 | 302 | #[test] 303 | fn test_parse_diagnostics() { 304 | let current_dir = std::env::current_dir().unwrap(); 305 | let test_file_path = current_dir.join("tests/go-test.txt"); 306 | let contents = read_to_string(test_file_path).unwrap(); 307 | let workspace = PathBuf::from_str("/home/demo/test/go/src/test").unwrap(); 308 | let target_file_path = "/home/demo/test/go/src/test/cases_test.go"; 309 | let result = 310 | parse_diagnostics(&contents, workspace, &[target_file_path.to_string()]).unwrap(); 311 | let result = result.data.first().unwrap(); 312 | assert_eq!(result.path, target_file_path); 313 | let diagnostic = result.diagnostics.first().unwrap(); 314 | assert_eq!(diagnostic.range.start.line, 30); 315 | assert_eq!(diagnostic.range.start.character, 1); 316 | assert_eq!(diagnostic.range.end.line, 30); 317 | assert_eq!(diagnostic.message, "\tError Trace:\tcases_test.go:31\n\tError: \tNot equal: \n\t \texpected: 7\n\t \tactual : -1\n\tTest: \tTestSubtract/test_two\n--- FAIL: TestSubtract (0.00s)\n --- FAIL: TestSubtract/test_one (0.00s)\n"); 318 | } 319 | 320 | #[test] 321 | fn test_discover() { 322 | let file_path = "../../demo/go/cases_test.go"; 323 | let test_items = discover(file_path).unwrap(); 324 | assert!(!test_items.is_empty()); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/jest.rs: -------------------------------------------------------------------------------- 1 | use crate::runner::util::send_stdout; 2 | use lsp_types::Diagnostic; 3 | use lsp_types::DiagnosticSeverity; 4 | use serde_json::Value; 5 | use std::collections::HashMap; 6 | use std::fs; 7 | use testing_language_server::error::LSError; 8 | 9 | use testing_language_server::spec::DetectWorkspaceResult; 10 | use testing_language_server::spec::DiscoverResult; 11 | use testing_language_server::spec::FileDiagnostics; 12 | use testing_language_server::spec::FoundFileTests; 13 | use testing_language_server::spec::RunFileTestResult; 14 | use testing_language_server::spec::TestItem; 15 | 16 | use crate::model::Runner; 17 | 18 | use super::util::clean_ansi; 19 | use super::util::detect_workspaces_from_file_list; 20 | use super::util::discover_with_treesitter; 21 | use super::util::LOG_LOCATION; 22 | use super::util::MAX_CHAR_LENGTH; 23 | 24 | fn parse_diagnostics( 25 | test_result: &str, 26 | file_paths: Vec, 27 | ) -> Result { 28 | let mut result_map: HashMap> = HashMap::new(); 29 | let json: Value = serde_json::from_str(test_result)?; 30 | let test_results = json["testResults"].as_array().unwrap(); 31 | for test_result in test_results { 32 | let file_path = test_result["name"].as_str().unwrap(); 33 | if !file_paths.iter().any(|path| path.contains(file_path)) { 34 | continue; 35 | } 36 | let assertion_results = test_result["assertionResults"].as_array().unwrap(); 37 | 'assertion: for assertion_result in assertion_results { 38 | let status = assertion_result["status"].as_str().unwrap(); 39 | if status != "failed" { 40 | continue 'assertion; 41 | } 42 | let location = assertion_result["location"].as_object().unwrap(); 43 | let failure_messages = assertion_result["failureMessages"].as_array().unwrap(); 44 | let line = location["line"].as_u64().unwrap() - 1; 45 | let column = location["column"].as_u64().unwrap() - 1; 46 | failure_messages.iter().for_each(|message| { 47 | let message = clean_ansi(message.as_str().unwrap()); 48 | let diagnostic = Diagnostic { 49 | range: lsp_types::Range { 50 | start: lsp_types::Position { 51 | line: line as u32, 52 | character: column as u32, 53 | }, 54 | end: lsp_types::Position { 55 | line: line as u32, 56 | character: MAX_CHAR_LENGTH, 57 | }, 58 | }, 59 | message, 60 | severity: Some(DiagnosticSeverity::ERROR), 61 | ..Diagnostic::default() 62 | }; 63 | result_map 64 | .entry(file_path.to_string()) 65 | .or_default() 66 | .push(diagnostic); 67 | }) 68 | } 69 | } 70 | Ok(RunFileTestResult { 71 | data: result_map 72 | .into_iter() 73 | .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics }) 74 | .collect(), 75 | messages: vec![], 76 | }) 77 | } 78 | 79 | fn detect_workspaces(file_paths: Vec) -> DetectWorkspaceResult { 80 | detect_workspaces_from_file_list(&file_paths, &["package.json".to_string()]) 81 | } 82 | 83 | fn discover(file_path: &str) -> Result, LSError> { 84 | // from https://github.com/nvim-neotest/neotest-jest/blob/514fd4eae7da15fd409133086bb8e029b65ac43f/lua/neotest-jest/init.lua#L162 85 | // license: https://github.com/nvim-neotest/neotest-jest/blob/514fd4eae7da15fd409133086bb8e029b65ac43f/LICENSE.md 86 | let query = r#" 87 | ; -- Namespaces -- 88 | ; Matches: `describe('context', () => {})` 89 | ((call_expression 90 | function: (identifier) @func_name (#eq? @func_name "describe") 91 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 92 | )) @namespace.definition 93 | ; Matches: `describe('context', function() {})` 94 | ((call_expression 95 | function: (identifier) @func_name (#eq? @func_name "describe") 96 | arguments: (arguments (string (string_fragment) @namespace.name) (function_expression)) 97 | )) @namespace.definition 98 | ; Matches: `describe.only('context', () => {})` 99 | ((call_expression 100 | function: (member_expression 101 | object: (identifier) @func_name (#any-of? @func_name "describe") 102 | ) 103 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 104 | )) @namespace.definition 105 | ; Matches: `describe.only('context', function() {})` 106 | ((call_expression 107 | function: (member_expression 108 | object: (identifier) @func_name (#any-of? @func_name "describe") 109 | ) 110 | arguments: (arguments (string (string_fragment) @namespace.name) (function_expression)) 111 | )) @namespace.definition 112 | ; Matches: `describe.each(['data'])('context', () => {})` 113 | ((call_expression 114 | function: (call_expression 115 | function: (member_expression 116 | object: (identifier) @func_name (#any-of? @func_name "describe") 117 | ) 118 | ) 119 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 120 | )) @namespace.definition 121 | ; Matches: `describe.each(['data'])('context', function() {})` 122 | ((call_expression 123 | function: (call_expression 124 | function: (member_expression 125 | object: (identifier) @func_name (#any-of? @func_name "describe") 126 | ) 127 | ) 128 | arguments: (arguments (string (string_fragment) @namespace.name) (function_expression)) 129 | )) @namespace.definition 130 | 131 | ; -- Tests -- 132 | ; Matches: `test('test') / it('test')` 133 | ((call_expression 134 | function: (identifier) @func_name (#any-of? @func_name "it" "test") 135 | arguments: (arguments (string (string_fragment) @test.name) [(arrow_function) (function_expression)]) 136 | )) @test.definition 137 | ; Matches: `test.only('test') / it.only('test')` 138 | ((call_expression 139 | function: (member_expression 140 | object: (identifier) @func_name (#any-of? @func_name "test" "it") 141 | ) 142 | arguments: (arguments (string (string_fragment) @test.name) [(arrow_function) (function_expression)]) 143 | )) @test.definition 144 | ; Matches: `test.each(['data'])('test') / it.each(['data'])('test')` 145 | ((call_expression 146 | function: (call_expression 147 | function: (member_expression 148 | object: (identifier) @func_name (#any-of? @func_name "it" "test") 149 | property: (property_identifier) @each_property (#eq? @each_property "each") 150 | ) 151 | ) 152 | arguments: (arguments (string (string_fragment) @test.name) [(arrow_function) (function_expression)]) 153 | )) @test.definition 154 | "#; 155 | discover_with_treesitter(file_path, &tree_sitter_javascript::language(), query) 156 | } 157 | 158 | #[derive(Eq, PartialEq, Debug)] 159 | pub struct JestRunner; 160 | 161 | impl Runner for JestRunner { 162 | #[tracing::instrument(skip(self))] 163 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 164 | let file_paths = args.file_paths; 165 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 166 | for file_path in file_paths { 167 | discover_results.data.push(FoundFileTests { 168 | tests: discover(&file_path)?, 169 | path: file_path, 170 | }) 171 | } 172 | send_stdout(&discover_results)?; 173 | Ok(()) 174 | } 175 | 176 | #[tracing::instrument(skip(self))] 177 | fn run_file_test( 178 | &self, 179 | args: testing_language_server::spec::RunFileTestArgs, 180 | ) -> Result<(), LSError> { 181 | let file_paths = args.file_paths; 182 | let workspace_root = args.workspace; 183 | let log_path = LOG_LOCATION.join("jest.json"); 184 | std::process::Command::new("jest") 185 | .current_dir(&workspace_root) 186 | .args([ 187 | "--testLocationInResults", 188 | "--forceExit", 189 | "--no-coverage", 190 | "--verbose", 191 | "--json", 192 | "--outputFile", 193 | log_path.to_str().unwrap(), 194 | ]) 195 | .output() 196 | .unwrap(); 197 | let test_result = fs::read_to_string(log_path)?; 198 | let diagnostics: RunFileTestResult = parse_diagnostics(&test_result, file_paths)?; 199 | send_stdout(&diagnostics)?; 200 | Ok(()) 201 | } 202 | 203 | #[tracing::instrument(skip(self))] 204 | fn detect_workspaces( 205 | &self, 206 | args: testing_language_server::spec::DetectWorkspaceArgs, 207 | ) -> Result<(), LSError> { 208 | let file_paths = args.file_paths; 209 | let detect_result = detect_workspaces(file_paths); 210 | send_stdout(&detect_result)?; 211 | Ok(()) 212 | } 213 | } 214 | 215 | #[cfg(test)] 216 | mod tests { 217 | use lsp_types::{Position, Range}; 218 | 219 | use super::*; 220 | 221 | #[test] 222 | fn test_parse_diagnostics() { 223 | let test_result = std::env::current_dir() 224 | .unwrap() 225 | .join("../../demo/jest/output.json"); 226 | let test_result = std::fs::read_to_string(test_result).unwrap(); 227 | let diagnostics = parse_diagnostics( 228 | &test_result, 229 | vec![ 230 | "/absolute_path/demo/jest/index.spec.js".to_string(), 231 | "/absolute_path/demo/jest/another.spec.js".to_string(), 232 | ], 233 | ) 234 | .unwrap(); 235 | assert_eq!(diagnostics.data.len(), 2); 236 | } 237 | 238 | #[test] 239 | fn test_detect_workspace() { 240 | let current_dir = std::env::current_dir().unwrap(); 241 | let absolute_path_of_demo = current_dir.join("../../demo/jest"); 242 | let demo_indexjs = absolute_path_of_demo.join("index.spec.js"); 243 | let file_paths: Vec = [demo_indexjs] 244 | .iter() 245 | .map(|file_path| file_path.to_str().unwrap().to_string()) 246 | .collect(); 247 | let detect_result = detect_workspaces(file_paths); 248 | assert_eq!(detect_result.data.len(), 1); 249 | detect_result.data.iter().for_each(|(workspace, _)| { 250 | assert_eq!(workspace, absolute_path_of_demo.to_str().unwrap()); 251 | }); 252 | } 253 | 254 | #[test] 255 | fn test_discover() { 256 | let file_path = "../../demo/jest/index.spec.js"; 257 | let test_items = discover(file_path).unwrap(); 258 | assert_eq!(test_items.len(), 1); 259 | assert_eq!( 260 | test_items, 261 | vec![TestItem { 262 | id: String::from("index::fail"), 263 | name: String::from("index::fail"), 264 | path: file_path.to_string(), 265 | start_position: Range { 266 | start: Position { 267 | line: 1, 268 | character: 2 269 | }, 270 | end: Position { 271 | line: 1, 272 | character: MAX_CHAR_LENGTH 273 | } 274 | }, 275 | end_position: Range { 276 | start: Position { 277 | line: 3, 278 | character: 0 279 | }, 280 | end: Position { 281 | line: 3, 282 | character: 4 283 | } 284 | } 285 | }] 286 | ) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cargo_nextest; 2 | pub mod cargo_test; 3 | pub mod node_test; 4 | pub mod deno; 5 | pub mod go; 6 | pub mod jest; 7 | pub mod phpunit; 8 | pub mod util; 9 | pub mod vitest; 10 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/phpunit.rs: -------------------------------------------------------------------------------- 1 | use std::fs::File; 2 | use std::io::BufReader; 3 | use std::process::Output; 4 | use testing_language_server::error::LSError; 5 | use testing_language_server::spec::{ 6 | DetectWorkspaceResult, DiscoverResult, FileDiagnostics, FoundFileTests, RunFileTestResult, 7 | TestItem, 8 | }; 9 | use xml::reader::{ParserConfig, XmlEvent}; 10 | 11 | use crate::model::Runner; 12 | 13 | use super::util::{ 14 | detect_workspaces_from_file_list, discover_with_treesitter, send_stdout, ResultFromXml, 15 | LOG_LOCATION, 16 | }; 17 | 18 | fn detect_workspaces(file_paths: Vec) -> DetectWorkspaceResult { 19 | detect_workspaces_from_file_list(&file_paths, &["composer.json".to_string()]) 20 | } 21 | 22 | fn get_result_from_characters(characters: &str) -> Result { 23 | // characters can be like 24 | // Tests\\CalculatorTest::testFail1\nFailed asserting that 8 matches expected 1.\n\n/home/kbwo/projects/github.com/kbwo/testing-language-server/demo/phpunit/src/CalculatorTest.php:28 25 | let mut split = characters.split("\n\n"); 26 | let message = split 27 | .next() 28 | .unwrap() 29 | .trim_start_matches("Failed asserting that ") 30 | .trim_end_matches(".") 31 | .to_string(); 32 | let location = split.next().unwrap().to_string(); 33 | let mut split_location = location.split(":"); 34 | 35 | let path = split_location.next().unwrap().to_string(); 36 | let line = split_location.next().unwrap().parse().unwrap(); 37 | Ok(ResultFromXml { 38 | message, 39 | path, 40 | line, 41 | col: 1, 42 | }) 43 | } 44 | 45 | fn get_result_from_xml(path: &str) -> Result, anyhow::Error> { 46 | use xml::common::Position; 47 | 48 | let file = File::open(path).unwrap(); 49 | let mut reader = ParserConfig::default() 50 | .ignore_root_level_whitespace(false) 51 | .create_reader(BufReader::new(file)); 52 | 53 | let local_name = "failure"; 54 | 55 | let mut in_failure = false; 56 | let mut result: Vec = Vec::new(); 57 | loop { 58 | match reader.next() { 59 | Ok(e) => match e { 60 | XmlEvent::StartElement { name, .. } => { 61 | if name.local_name.starts_with(local_name) { 62 | in_failure = true; 63 | } 64 | } 65 | XmlEvent::EndElement { .. } => { 66 | in_failure = false; 67 | } 68 | XmlEvent::Characters(data) => { 69 | if let Ok(result_from_xml) = get_result_from_characters(&data) { 70 | if in_failure { 71 | result.push(result_from_xml); 72 | } 73 | } 74 | } 75 | XmlEvent::EndDocument => break, 76 | _ => {} 77 | }, 78 | Err(e) => { 79 | tracing::error!("Error at {}: {e}", reader.position()); 80 | break; 81 | } 82 | } 83 | } 84 | 85 | Ok(result) 86 | } 87 | 88 | fn discover(file_path: &str) -> Result, LSError> { 89 | // from https://github.com/olimorris/neotest-phpunit/blob/bbd79d95e927ccd16f0e1d765060058d34838e2e/lua/neotest-phpunit/init.lua#L111 90 | // license: https://github.com/olimorris/neotest-phpunit/blob/bbd79d95e927ccd16f0e1d765060058d34838e2e/LICENSE 91 | let query = r#" 92 | ((class_declaration 93 | name: (name) @namespace.name (#match? @namespace.name "Test") 94 | )) @namespace.definition 95 | 96 | ((method_declaration 97 | (attribute_list 98 | (attribute_group 99 | (attribute) @test_attribute (#match? @test_attribute "Test") 100 | ) 101 | ) 102 | ( 103 | (visibility_modifier) 104 | (name) @test.name 105 | ) @test.definition 106 | )) 107 | 108 | ((method_declaration 109 | (name) @test.name (#match? @test.name "test") 110 | )) @test.definition 111 | 112 | (((comment) @test_comment (#match? @test_comment "\\@test") . 113 | (method_declaration 114 | (name) @test.name 115 | ) @test.definition 116 | )) 117 | "#; 118 | discover_with_treesitter(file_path, &tree_sitter_php::language_php(), query) 119 | } 120 | 121 | #[derive(Eq, PartialEq, Debug)] 122 | pub struct PhpunitRunner; 123 | 124 | impl Runner for PhpunitRunner { 125 | #[tracing::instrument(skip(self))] 126 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 127 | let file_paths = args.file_paths; 128 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 129 | for file_path in file_paths { 130 | discover_results.data.push(FoundFileTests { 131 | tests: discover(&file_path)?, 132 | path: file_path, 133 | }) 134 | } 135 | send_stdout(&discover_results)?; 136 | Ok(()) 137 | } 138 | 139 | #[tracing::instrument(skip(self))] 140 | fn run_file_test( 141 | &self, 142 | args: testing_language_server::spec::RunFileTestArgs, 143 | ) -> Result<(), LSError> { 144 | let file_paths = args.file_paths; 145 | let workspace_root = args.workspace; 146 | let log_path = LOG_LOCATION.join("phpunit.xml"); 147 | let tests = file_paths 148 | .iter() 149 | .map(|path| { 150 | discover(path).map(|test_items| { 151 | test_items 152 | .into_iter() 153 | .map(|item| item.id) 154 | .collect::>() 155 | }) 156 | }) 157 | .filter_map(Result::ok) 158 | .flatten() 159 | .collect::>(); 160 | let test_names = tests.join("|"); 161 | let filter_pattern = format!("/{test_names}/"); 162 | let output = std::process::Command::new("phpunit") 163 | .current_dir(&workspace_root) 164 | .args([ 165 | "--log-junit", 166 | log_path.to_str().unwrap(), 167 | "--filter", 168 | &filter_pattern, 169 | ]) 170 | .args(file_paths) 171 | .stdout(std::process::Stdio::null()) 172 | .stderr(std::process::Stdio::null()) 173 | .output() 174 | .unwrap(); 175 | let Output { stdout, stderr, .. } = output; 176 | if stdout.is_empty() && !stderr.is_empty() { 177 | return Err(LSError::Adapter(String::from_utf8(stderr).unwrap())); 178 | } 179 | let result_from_xml = get_result_from_xml(log_path.to_str().unwrap())?; 180 | let result_item: Vec = result_from_xml 181 | .into_iter() 182 | .map(|result_from_xml| { 183 | let result_item: FileDiagnostics = result_from_xml.into(); 184 | result_item 185 | }) 186 | .collect(); 187 | let result = RunFileTestResult { 188 | data: result_item, 189 | messages: vec![], 190 | }; 191 | send_stdout(&result)?; 192 | Ok(()) 193 | } 194 | 195 | #[tracing::instrument(skip(self))] 196 | fn detect_workspaces( 197 | &self, 198 | args: testing_language_server::spec::DetectWorkspaceArgs, 199 | ) -> Result<(), LSError> { 200 | let file_paths = args.file_paths; 201 | let detect_result = detect_workspaces(file_paths); 202 | send_stdout(&detect_result)?; 203 | Ok(()) 204 | } 205 | } 206 | 207 | #[cfg(test)] 208 | mod tests { 209 | use lsp_types::{Position, Range}; 210 | 211 | use crate::runner::util::MAX_CHAR_LENGTH; 212 | 213 | use super::*; 214 | 215 | #[test] 216 | fn parse_xml() { 217 | let mut path = std::env::current_dir().unwrap(); 218 | path.push("../../demo/phpunit/output.xml"); 219 | let result = get_result_from_xml(path.to_str().unwrap()).unwrap(); 220 | assert_eq!(result.len(), 1); 221 | assert_eq!( 222 | result[0].message, 223 | "Tests\\CalculatorTest::testFail1\nFailed asserting that 8 matches expected 1" 224 | ); 225 | assert_eq!( 226 | result[0].path, 227 | "/home/kbwo/testing-language-server/demo/phpunit/src/CalculatorTest.php" 228 | ); 229 | assert_eq!(result[0].line, 28); 230 | } 231 | 232 | #[test] 233 | fn test_discover() { 234 | let file_path = "../../demo/phpunit/src/CalculatorTest.php"; 235 | let test_items = discover(file_path).unwrap(); 236 | assert_eq!(test_items.len(), 3); 237 | assert_eq!( 238 | test_items, 239 | [ 240 | TestItem { 241 | id: "CalculatorTest::testAdd".to_string(), 242 | name: "CalculatorTest::testAdd".to_string(), 243 | path: file_path.to_string(), 244 | start_position: Range { 245 | start: Position { 246 | line: 9, 247 | character: 4 248 | }, 249 | end: Position { 250 | line: 9, 251 | character: MAX_CHAR_LENGTH 252 | } 253 | }, 254 | end_position: Range { 255 | start: Position { 256 | line: 14, 257 | character: 0 258 | }, 259 | end: Position { 260 | line: 14, 261 | character: 5 262 | } 263 | } 264 | }, 265 | TestItem { 266 | id: "CalculatorTest::testSubtract".to_string(), 267 | name: "CalculatorTest::testSubtract".to_string(), 268 | path: file_path.to_string(), 269 | start_position: Range { 270 | start: Position { 271 | line: 16, 272 | character: 4 273 | }, 274 | end: Position { 275 | line: 16, 276 | character: MAX_CHAR_LENGTH 277 | } 278 | }, 279 | end_position: Range { 280 | start: Position { 281 | line: 21, 282 | character: 0 283 | }, 284 | end: Position { 285 | line: 21, 286 | character: 5 287 | } 288 | } 289 | }, 290 | TestItem { 291 | id: "CalculatorTest::testFail1".to_string(), 292 | name: "CalculatorTest::testFail1".to_string(), 293 | path: file_path.to_string(), 294 | start_position: Range { 295 | start: Position { 296 | line: 23, 297 | character: 4 298 | }, 299 | end: Position { 300 | line: 23, 301 | character: MAX_CHAR_LENGTH 302 | } 303 | }, 304 | end_position: Range { 305 | start: Position { 306 | line: 28, 307 | character: 0 308 | }, 309 | end: Position { 310 | line: 28, 311 | character: 5 312 | } 313 | } 314 | } 315 | ] 316 | ) 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/util.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::io; 3 | use std::path::{Path, PathBuf}; 4 | use std::process::Output; 5 | use std::str::FromStr; 6 | use std::sync::LazyLock; 7 | 8 | use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range}; 9 | use regex::Regex; 10 | use serde::Serialize; 11 | use testing_language_server::spec::{DetectWorkspaceResult, FileDiagnostics, TestItem}; 12 | use testing_language_server::{error::LSError, spec::RunFileTestResult}; 13 | use tree_sitter::{Language, Point, Query, QueryCursor}; 14 | 15 | pub struct DiscoverWithTSOption {} 16 | 17 | pub static LOG_LOCATION: LazyLock = LazyLock::new(|| { 18 | let home_dir = dirs::home_dir().unwrap(); 19 | home_dir.join(".config/testing_language_server/adapter/") 20 | }); 21 | 22 | // If the character value is greater than the line length it defaults back to the line length. 23 | pub const MAX_CHAR_LENGTH: u32 = 10000; 24 | 25 | #[derive(Debug)] 26 | pub struct ResultFromXml { 27 | pub message: String, 28 | pub path: String, 29 | pub line: u32, 30 | pub col: u32, 31 | } 32 | 33 | #[allow(clippy::from_over_into)] 34 | impl Into for ResultFromXml { 35 | fn into(self) -> FileDiagnostics { 36 | FileDiagnostics { 37 | path: self.path, 38 | diagnostics: vec![Diagnostic { 39 | message: self.message, 40 | range: Range { 41 | start: Position { 42 | line: self.line - 1, 43 | character: self.col - 1, 44 | }, 45 | end: Position { 46 | line: self.line - 1, 47 | character: MAX_CHAR_LENGTH, 48 | }, 49 | }, 50 | severity: Some(DiagnosticSeverity::ERROR), 51 | ..Default::default() 52 | }], 53 | } 54 | } 55 | } 56 | 57 | /// determine if a particular file is the root of workspace based on whether it is in the same directory 58 | fn detect_workspace_from_file(file_path: PathBuf, file_names: &[String]) -> Option { 59 | let parent = file_path.parent(); 60 | if let Some(parent) = parent { 61 | if file_names 62 | .iter() 63 | .any(|file_name| parent.join(file_name).exists()) 64 | { 65 | return Some(parent.to_string_lossy().to_string()); 66 | } else { 67 | detect_workspace_from_file(parent.to_path_buf(), file_names) 68 | } 69 | } else { 70 | None 71 | } 72 | } 73 | 74 | pub fn detect_workspaces_from_file_list( 75 | target_file_paths: &[String], 76 | file_names: &[String], 77 | ) -> DetectWorkspaceResult { 78 | let mut result_map: HashMap> = HashMap::new(); 79 | let mut file_paths = target_file_paths.to_vec(); 80 | file_paths.sort_by_key(|b| b.len()); 81 | for file_path in file_paths { 82 | let existing_workspace = result_map 83 | .iter() 84 | .find(|(workspace_root, _)| file_path.contains(workspace_root.as_str())); 85 | if let Some((workspace_root, _)) = existing_workspace { 86 | result_map 87 | .entry(workspace_root.to_string()) 88 | .or_default() 89 | .push(file_path.clone()); 90 | } 91 | // Push the file path to the found workspace even if existing_workspace becomes Some. 92 | // In some cases, a simple way to find a workspace, 93 | // such as the relationship between the project root and the adapter crate in this repository, may not work. 94 | let workspace = 95 | detect_workspace_from_file(PathBuf::from_str(&file_path).unwrap(), file_names); 96 | if let Some(workspace) = workspace { 97 | if result_map 98 | .get(&workspace) 99 | .map(|v| !v.contains(&file_path)) 100 | .unwrap_or(true) 101 | { 102 | result_map 103 | .entry(workspace) 104 | .or_default() 105 | .push(file_path.clone()); 106 | } 107 | } 108 | } 109 | DetectWorkspaceResult { data: result_map } 110 | } 111 | 112 | pub fn send_stdout(value: &T) -> Result<(), LSError> 113 | where 114 | T: ?Sized + Serialize + std::fmt::Debug, 115 | { 116 | tracing::info!("adapter stdout: {:#?}", value); 117 | serde_json::to_writer(std::io::stdout(), &value)?; 118 | Ok(()) 119 | } 120 | 121 | pub fn clean_ansi(input: &str) -> String { 122 | let re = Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]").unwrap(); 123 | re.replace_all(input, "").to_string() 124 | } 125 | 126 | pub fn discover_rust_tests(file_path: &str) -> Result, LSError> { 127 | // from https://github.com/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/lua/neotest-rust/init.lua#L167 128 | // license: https://github.com/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/LICENSE 129 | let query = r#" 130 | ( 131 | (attribute_item 132 | [ 133 | (attribute 134 | (identifier) @macro_name 135 | ) 136 | (attribute 137 | [ 138 | (identifier) @macro_name 139 | (scoped_identifier 140 | name: (identifier) @macro_name 141 | ) 142 | ] 143 | ) 144 | ] 145 | ) 146 | [ 147 | (attribute_item 148 | (attribute 149 | (identifier) 150 | ) 151 | ) 152 | (line_comment) 153 | ]* 154 | . 155 | (function_item 156 | name: (identifier) @test.name 157 | ) @test.definition 158 | (#any-of? @macro_name "test" "rstest" "case") 159 | 160 | ) 161 | (mod_item name: (identifier) @namespace.name)? @namespace.definition 162 | "#; 163 | discover_with_treesitter(file_path, &tree_sitter_rust::language(), query) 164 | } 165 | 166 | pub fn discover_with_treesitter( 167 | file_path: &str, 168 | language: &Language, 169 | query: &str, 170 | ) -> Result, LSError> { 171 | let mut parser = tree_sitter::Parser::new(); 172 | let mut test_items: Vec = vec![]; 173 | parser 174 | .set_language(language) 175 | .expect("Error loading Rust grammar"); 176 | let source_code = std::fs::read_to_string(file_path)?; 177 | let tree = parser.parse(&source_code, None).unwrap(); 178 | let query = Query::new(language, query).expect("Error creating query"); 179 | 180 | let mut cursor = QueryCursor::new(); 181 | cursor.set_byte_range(tree.root_node().byte_range()); 182 | let source = source_code.as_bytes(); 183 | let matches = cursor.matches(&query, tree.root_node(), source); 184 | 185 | let mut namespace_name = String::new(); 186 | let mut namespace_position_stack: Vec<(Point, Point)> = vec![]; 187 | let mut test_id_set = HashSet::new(); 188 | for m in matches { 189 | let mut test_start_position = Point::default(); 190 | let mut test_end_position = Point::default(); 191 | for capture in m.captures { 192 | let capture_name = query.capture_names()[capture.index as usize]; 193 | let value = capture.node.utf8_text(source)?; 194 | let start_position = capture.node.start_position(); 195 | let end_position = capture.node.end_position(); 196 | 197 | match capture_name { 198 | "namespace.definition" => { 199 | namespace_position_stack.push((start_position, end_position)); 200 | } 201 | "namespace.name" => { 202 | let current_namespace = namespace_position_stack.first(); 203 | if let Some((ns_start, ns_end)) = current_namespace { 204 | // In namespace definition 205 | if start_position.row >= ns_start.row 206 | && end_position.row <= ns_end.row 207 | && !namespace_name.is_empty() 208 | { 209 | namespace_name = format!("{}::{}", namespace_name, value); 210 | } else { 211 | namespace_name = value.to_string(); 212 | } 213 | } else { 214 | namespace_name = value.to_string(); 215 | } 216 | } 217 | "test.definition" => { 218 | if let Some((ns_start, ns_end)) = namespace_position_stack.first() { 219 | if start_position.row < ns_start.row || end_position.row > ns_end.row { 220 | namespace_position_stack.remove(0); 221 | namespace_name = String::new(); 222 | } 223 | } 224 | test_start_position = start_position; 225 | test_end_position = end_position; 226 | } 227 | "test.name" => { 228 | let test_id = if namespace_name.is_empty() { 229 | value.to_string() 230 | } else { 231 | format!("{}::{}", namespace_name, value) 232 | }; 233 | 234 | if test_id_set.contains(&test_id) { 235 | continue; 236 | } else { 237 | test_id_set.insert(test_id.clone()); 238 | } 239 | 240 | let test_item = TestItem { 241 | id: test_id.clone(), 242 | name: test_id, 243 | path: file_path.to_string(), 244 | start_position: Range { 245 | start: Position { 246 | line: test_start_position.row as u32, 247 | character: test_start_position.column as u32, 248 | }, 249 | end: Position { 250 | line: test_start_position.row as u32, 251 | character: MAX_CHAR_LENGTH, 252 | }, 253 | }, 254 | end_position: Range { 255 | start: Position { 256 | line: test_end_position.row as u32, 257 | character: 0, 258 | }, 259 | end: Position { 260 | line: test_end_position.row as u32, 261 | character: test_end_position.column as u32, 262 | }, 263 | }, 264 | }; 265 | test_items.push(test_item); 266 | test_start_position = Point::default(); 267 | test_end_position = Point::default(); 268 | } 269 | _ => {} 270 | } 271 | } 272 | } 273 | 274 | Ok(test_items) 275 | } 276 | 277 | pub fn parse_cargo_diagnostics( 278 | contents: &str, 279 | workspace_root: PathBuf, 280 | file_paths: &[String], 281 | test_items: &[TestItem], 282 | ) -> RunFileTestResult { 283 | let contents = contents.replace("\r\n", "\n"); 284 | let lines = contents.lines(); 285 | let mut result_map: HashMap> = HashMap::new(); 286 | for (i, line) in lines.clone().enumerate() { 287 | // Example: 288 | // thread 'server::tests::test_panic' panicked at src/server.rs:584:9: 289 | let re = Regex::new(r"thread '([^']+)' panicked at ([^:]+):(\d+):(\d+):").unwrap(); 290 | if let Some(m) = re.captures(line) { 291 | let mut message = String::new(); 292 | // :: 293 | let id_with_file = m.get(1).unwrap().as_str().to_string(); 294 | 295 | // relaive path 296 | let relative_file_path = m.get(2).unwrap().as_str().to_string(); 297 | 298 | if let Some(file_path) = file_paths.iter().find(|path| { 299 | path.contains(workspace_root.join(&relative_file_path).to_str().unwrap()) 300 | }) { 301 | let matched_test_item = test_items.iter().find(|item| { 302 | let item_path = item.path.strip_prefix(workspace_root.to_str().unwrap()).unwrap_or(&item.path); 303 | let item_path = item_path.strip_suffix(".rs").unwrap_or(item_path); 304 | let item_path = item_path.replace('/', "::") 305 | .replace("::src::lib", "") 306 | .replace("::src::main", "") 307 | .replace("::src::", ""); 308 | let exact_id = format!("{}::{}", item_path, item.id); 309 | tracing::info!("DEBUGPRINT[7]: util.rs:301: item_path={:#?}, exact_id={:#?}, id_with_file={:#?}", item_path, exact_id, id_with_file); 310 | exact_id == id_with_file 311 | }); 312 | 313 | let lnum = m.get(3).unwrap().as_str().parse::().unwrap() - 1; 314 | let col = m.get(4).unwrap().as_str().parse::().unwrap() - 1; 315 | let mut next_i = i + 1; 316 | while next_i < lines.clone().count() 317 | && !lines.clone().nth(next_i).unwrap().is_empty() 318 | { 319 | message = format!("{}{}\n", message, lines.clone().nth(next_i).unwrap()); 320 | next_i += 1; 321 | } 322 | let diagnostic = Diagnostic { 323 | range: Range { 324 | start: Position { 325 | line: lnum, 326 | character: col, 327 | }, 328 | end: Position { 329 | line: lnum, 330 | character: MAX_CHAR_LENGTH, 331 | }, 332 | }, 333 | message: message.clone(), 334 | severity: Some(DiagnosticSeverity::ERROR), 335 | ..Diagnostic::default() 336 | }; 337 | 338 | // if the test item is matched, 339 | // add a diagnostic to the beginning of the test item 340 | // in order to show which test failed. 341 | // If this code does not exist, only panicked positions are shown 342 | if let Some(test_item) = matched_test_item { 343 | let message = format!( 344 | "`{}` failed at {relative_file_path}:{lnum}:{col}\nMessage:\n{message}", 345 | test_item.name 346 | ); 347 | let lnum = test_item.start_position.start.line; 348 | let col = test_item.start_position.start.character; 349 | let diagnostic = Diagnostic { 350 | range: Range { 351 | start: Position { 352 | line: lnum, 353 | character: col, 354 | }, 355 | end: Position { 356 | line: lnum, 357 | character: MAX_CHAR_LENGTH, 358 | }, 359 | }, 360 | message, 361 | severity: Some(DiagnosticSeverity::ERROR), 362 | ..Diagnostic::default() 363 | }; 364 | result_map 365 | .entry(test_item.path.to_string()) 366 | .or_default() 367 | .push(diagnostic); 368 | } 369 | result_map 370 | .entry(file_path.to_string()) 371 | .or_default() 372 | .push(diagnostic); 373 | } else { 374 | continue; 375 | } 376 | } 377 | } 378 | 379 | let data = result_map 380 | .into_iter() 381 | .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics }) 382 | .collect(); 383 | 384 | RunFileTestResult { 385 | data, 386 | messages: vec![], 387 | } 388 | } 389 | 390 | /// remove this function because duplicate implementation 391 | pub fn resolve_path(base_dir: &Path, relative_path: &str) -> PathBuf { 392 | let absolute = if Path::new(relative_path).is_absolute() { 393 | PathBuf::from(relative_path) 394 | } else { 395 | base_dir.join(relative_path) 396 | }; 397 | 398 | let mut components = Vec::new(); 399 | for component in absolute.components() { 400 | match component { 401 | std::path::Component::ParentDir => { 402 | components.pop(); 403 | } 404 | std::path::Component::Normal(_) | std::path::Component::RootDir => { 405 | components.push(component); 406 | } 407 | _ => {} 408 | } 409 | } 410 | 411 | PathBuf::from_iter(components) 412 | } 413 | 414 | pub fn write_result_log(file_name: &str, output: &Output) -> io::Result<()> { 415 | let stdout = String::from_utf8(output.stdout.clone()).unwrap(); 416 | let stderr = String::from_utf8(output.stderr.clone()).unwrap(); 417 | let content = format!("stdout:\n{}\nstderr:\n{}", stdout, stderr); 418 | let log_path = LOG_LOCATION.join(file_name); 419 | std::fs::write(&log_path, content)?; 420 | Ok(()) 421 | } 422 | -------------------------------------------------------------------------------- /crates/adapter/src/runner/vitest.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashMap, 3 | fs::{self}, 4 | }; 5 | 6 | use lsp_types::{Diagnostic, DiagnosticSeverity}; 7 | use serde_json::Value; 8 | use testing_language_server::{ 9 | error::LSError, 10 | spec::{DiscoverResult, FileDiagnostics, FoundFileTests, RunFileTestResult, TestItem}, 11 | }; 12 | 13 | use crate::model::Runner; 14 | 15 | use super::util::{ 16 | clean_ansi, detect_workspaces_from_file_list, discover_with_treesitter, send_stdout, 17 | LOG_LOCATION, MAX_CHAR_LENGTH, 18 | }; 19 | 20 | #[derive(Eq, PartialEq, Hash, Debug)] 21 | pub struct VitestRunner; 22 | 23 | fn discover(file_path: &str) -> Result, LSError> { 24 | // from https://github.com/marilari88/neotest-vitest/blob/353364aa05b94b09409cbef21b79c97c5564e2ce/lua/neotest-vitest/init.lua#L101 25 | let query = r#" 26 | ; -- Namespaces -- 27 | ; Matches: `describe('context')` 28 | ((call_expression 29 | function: (identifier) @func_name (#eq? @func_name "describe") 30 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 31 | )) @namespace.definition 32 | ; Matches: `describe.only('context')` 33 | ((call_expression 34 | function: (member_expression 35 | object: (identifier) @func_name (#any-of? @func_name "describe") 36 | ) 37 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 38 | )) @namespace.definition 39 | ; Matches: `describe.each(['data'])('context')` 40 | ((call_expression 41 | function: (call_expression 42 | function: (member_expression 43 | object: (identifier) @func_name (#any-of? @func_name "describe") 44 | ) 45 | ) 46 | arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function)) 47 | )) @namespace.definition 48 | 49 | ; -- Tests -- 50 | ; Matches: `test('test') / it('test')` 51 | ((call_expression 52 | function: (identifier) @func_name (#any-of? @func_name "it" "test") 53 | arguments: (arguments (string (string_fragment) @test.name) (arrow_function)) 54 | )) @test.definition 55 | ; Matches: `test.only('test') / it.only('test')` 56 | ((call_expression 57 | function: (member_expression 58 | object: (identifier) @func_name (#any-of? @func_name "test" "it") 59 | ) 60 | arguments: (arguments (string (string_fragment) @test.name) (arrow_function)) 61 | )) @test.definition 62 | ; Matches: `test.each(['data'])('test') / it.each(['data'])('test')` 63 | ((call_expression 64 | function: (call_expression 65 | function: (member_expression 66 | object: (identifier) @func_name (#any-of? @func_name "it" "test") 67 | ) 68 | ) 69 | arguments: (arguments (string (string_fragment) @test.name) (arrow_function)) 70 | )) @test.definition 71 | "#; 72 | discover_with_treesitter(file_path, &tree_sitter_javascript::language(), query) 73 | } 74 | 75 | fn parse_diagnostics( 76 | test_result: &str, 77 | file_paths: Vec, 78 | ) -> Result { 79 | let mut result_map: HashMap> = HashMap::new(); 80 | let json: Value = serde_json::from_str(test_result)?; 81 | let test_results = json["testResults"].as_array().unwrap(); 82 | for test_result in test_results { 83 | let file_path = test_result["name"].as_str().unwrap(); 84 | if !file_paths.iter().any(|path| path.contains(file_path)) { 85 | continue; 86 | } 87 | let assertion_results = test_result["assertionResults"].as_array().unwrap(); 88 | 'assertion: for assertion_result in assertion_results { 89 | let status = assertion_result["status"].as_str().unwrap(); 90 | if status != "failed" { 91 | continue 'assertion; 92 | } 93 | let location = assertion_result["location"].as_object().unwrap(); 94 | let failure_messages = assertion_result["failureMessages"].as_array().unwrap(); 95 | let line = location["line"].as_u64().unwrap() - 1; 96 | failure_messages.iter().for_each(|message| { 97 | let message = clean_ansi(message.as_str().unwrap()); 98 | let diagnostic = Diagnostic { 99 | range: lsp_types::Range { 100 | start: lsp_types::Position { 101 | line: line as u32, 102 | // Line and column number is slightly incorrect. 103 | // ref: 104 | // Bug in json reporter line number? · vitest-dev/vitest · Discussion #5350 105 | // https://github.com/vitest-dev/vitest/discussions/5350 106 | // Currently, The row numbers are from the parse result, the column numbers are 0 and MAX_CHAR_LENGTH is hard-coded. 107 | character: 0, 108 | }, 109 | end: lsp_types::Position { 110 | line: line as u32, 111 | character: MAX_CHAR_LENGTH, 112 | }, 113 | }, 114 | message, 115 | severity: Some(DiagnosticSeverity::ERROR), 116 | ..Diagnostic::default() 117 | }; 118 | result_map 119 | .entry(file_path.to_string()) 120 | .or_default() 121 | .push(diagnostic); 122 | }) 123 | } 124 | } 125 | Ok(RunFileTestResult { 126 | data: result_map 127 | .into_iter() 128 | .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics }) 129 | .collect(), 130 | messages: vec![], 131 | }) 132 | } 133 | 134 | impl Runner for VitestRunner { 135 | #[tracing::instrument(skip(self))] 136 | fn discover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> { 137 | let file_paths = args.file_paths; 138 | let mut discover_results: DiscoverResult = DiscoverResult { data: vec![] }; 139 | 140 | for file_path in file_paths { 141 | let tests = discover(&file_path)?; 142 | discover_results.data.push(FoundFileTests { 143 | tests, 144 | path: file_path, 145 | }); 146 | } 147 | send_stdout(&discover_results)?; 148 | Ok(()) 149 | } 150 | 151 | #[tracing::instrument(skip(self))] 152 | fn run_file_test( 153 | &self, 154 | args: testing_language_server::spec::RunFileTestArgs, 155 | ) -> Result<(), LSError> { 156 | let file_paths = args.file_paths; 157 | let workspace_root = args.workspace; 158 | let log_path = LOG_LOCATION.join("vitest.json"); 159 | let log_path = log_path.to_str().unwrap(); 160 | std::process::Command::new("vitest") 161 | .current_dir(&workspace_root) 162 | .args([ 163 | "--watch=false", 164 | "--reporter=json", 165 | "--outputFile=", 166 | log_path, 167 | ]) 168 | .output() 169 | .unwrap(); 170 | let test_result = fs::read_to_string(log_path)?; 171 | let diagnostics: RunFileTestResult = parse_diagnostics(&test_result, file_paths)?; 172 | send_stdout(&diagnostics)?; 173 | Ok(()) 174 | } 175 | 176 | #[tracing::instrument(skip(self))] 177 | fn detect_workspaces( 178 | &self, 179 | args: testing_language_server::spec::DetectWorkspaceArgs, 180 | ) -> Result<(), LSError> { 181 | send_stdout(&detect_workspaces_from_file_list( 182 | &args.file_paths, 183 | &[ 184 | "package.json".to_string(), 185 | "vitest.config.ts".to_string(), 186 | "vitest.config.js".to_string(), 187 | "vite.config.ts".to_string(), 188 | "vite.config.js".to_string(), 189 | "vitest.config.mts".to_string(), 190 | "vitest.config.mjs".to_string(), 191 | "vite.config.mts".to_string(), 192 | "vite.config.mjs".to_string(), 193 | ], 194 | ))?; 195 | Ok(()) 196 | } 197 | } 198 | 199 | #[cfg(test)] 200 | mod tests { 201 | use lsp_types::{Position, Range}; 202 | 203 | use super::*; 204 | 205 | #[test] 206 | fn test_discover() { 207 | let file_path = "../../demo/vitest/basic.test.ts"; 208 | let test_items = discover(file_path).unwrap(); 209 | assert_eq!(test_items.len(), 2); 210 | assert_eq!( 211 | test_items, 212 | [ 213 | TestItem { 214 | id: "describe text::pass".to_string(), 215 | name: "describe text::pass".to_string(), 216 | path: file_path.to_string(), 217 | start_position: Range { 218 | start: Position { 219 | line: 4, 220 | character: 2 221 | }, 222 | end: Position { 223 | line: 4, 224 | character: 10000 225 | } 226 | }, 227 | end_position: Range { 228 | start: Position { 229 | line: 6, 230 | character: 0 231 | }, 232 | end: Position { 233 | line: 6, 234 | character: 4 235 | } 236 | } 237 | }, 238 | TestItem { 239 | id: "describe text::fail".to_string(), 240 | name: "describe text::fail".to_string(), 241 | path: file_path.to_string(), 242 | start_position: Range { 243 | start: Position { 244 | line: 8, 245 | character: 2 246 | }, 247 | end: Position { 248 | line: 8, 249 | character: 10000 250 | } 251 | }, 252 | end_position: Range { 253 | start: Position { 254 | line: 10, 255 | character: 0 256 | }, 257 | end: Position { 258 | line: 10, 259 | character: 4 260 | } 261 | } 262 | } 263 | ] 264 | ) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /demo/.helix/config.toml: -------------------------------------------------------------------------------- 1 | [editor.soft-wrap] 2 | enable = true 3 | max-wrap = 25 # increase value to reduce forced mid-word wrapping 4 | -------------------------------------------------------------------------------- /demo/.helix/languages.toml: -------------------------------------------------------------------------------- 1 | [language-server.testing-ls] 2 | command = "testing-language-server" 3 | args = [] 4 | 5 | [[language]] 6 | name = "rust" 7 | language-servers = [ { name = "testing-ls", only-features = [ "diagnostics" ] }, "rust-analyzer" ] 8 | 9 | [[language]] 10 | name = "typescript" 11 | language-servers = [ { name = "testing-ls", only-features = [ "diagnostics" ] }, "typescript-language-server" ] 12 | 13 | [[language]] 14 | name = "php" 15 | language-servers = [ { name = "testing-ls", only-features = [ "diagnostics" ] }, "phpactor" ] 16 | 17 | [[language]] 18 | name = "go" 19 | language-servers = [ { name = "testing-ls", only-features = [ "diagnostics" ] }, "gopls" ] 20 | 21 | [[language]] 22 | name = "javascript" 23 | language-servers = [ { name = "testing-ls", only-features = [ "diagnostics" ] }, "typescript-language-server" ] -------------------------------------------------------------------------------- /demo/.testingls.toml: -------------------------------------------------------------------------------- 1 | enableWorkspaceDiagnostics = true 2 | 3 | [adapterCommand.cargo-test] 4 | path = "testing-ls-adapter" 5 | extra_arg = ["--test-kind=cargo-test"] 6 | include = ["/**/src/**/*.rs"] 7 | exclude = ["/**/target/**"] 8 | 9 | [adapterCommand.cargo-nextest] 10 | path = "testing-ls-adapter" 11 | extra_arg = ["--test-kind=cargo-nextest"] 12 | include = ["/**/src/**/*.rs"] 13 | exclude = ["/**/target/**"] 14 | 15 | [adapterCommand.jest] 16 | path = "testing-ls-adapter" 17 | extra_arg = ["--test-kind=jest"] 18 | include = ["/jest/*.js"] 19 | exclude = ["/jest/**/node_modules/**/*"] 20 | 21 | [adapterCommand.vitest] 22 | path = "testing-ls-adapter" 23 | extra_arg = ["--test-kind=vitest"] 24 | include = ["/vitest/*.test.ts", "/vitest/config/**/*.test.ts"] 25 | exclude = ["/vitest/**/node_modules/**/*"] 26 | 27 | [adapterCommand.deno] 28 | path = "testing-ls-adapter" 29 | extra_arg = ["--test-kind=deno"] 30 | include = ["/deno/*.ts"] 31 | exclude = [] 32 | 33 | [adapterCommand.go] 34 | path = "testing-ls-adapter" 35 | extra_arg = ["--test-kind=go-test"] 36 | include = ["/**/*.go"] 37 | exclude = [] 38 | 39 | [adapterCommand.node-test] 40 | path = "testing-ls-adapter" 41 | extra_arg = ["--test-kind=node-test"] 42 | include = ["/node-test/*.test.js"] 43 | exclude = [] 44 | 45 | [adapterCommand.phpunit] 46 | path = "testing-ls-adapter" 47 | extra_arg = ["--test-kind=phpunit"] 48 | include = ["/**/*Test.php"] 49 | exclude = ["/phpunit/vendor/**/*.php"] 50 | -------------------------------------------------------------------------------- /demo/.vim/coc-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "languageserver": { 3 | "testing": { 4 | "command": "testing-language-server", 5 | "trace.server": "verbose", 6 | "filetypes": [ 7 | "rust", 8 | "javascript", 9 | "go", 10 | "typescript", 11 | "php" 12 | ], 13 | "initializationOptions": {} 14 | } 15 | }, 16 | "deno.enable": false, 17 | "tsserver.enable": false 18 | } 19 | -------------------------------------------------------------------------------- /demo/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "testing.enable": true, 3 | "filetypes": [ 4 | "rust", 5 | "javascript", 6 | "go", 7 | "typescript", 8 | "php" 9 | ], 10 | "testing.enableWorkspaceDiagnostics": true, 11 | "testing.server.path": "testing-language-server", 12 | "testing.trace.server": "verbose" 13 | } 14 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | ## Using `nvim-lspconfig` 2 | 3 | The specification is not stable, so you need to set it yourself. Once the spec is stable, I will send a PR to `nvim-lspconfig`. 4 | ``` 5 | local lspconfig = require('lspconfig') 6 | local configs = require('lspconfig.configs') 7 | local util = require "lspconfig/util" 8 | 9 | configs.testing_ls = { 10 | default_config = { 11 | cmd = { "testing-language-server" }, 12 | filetypes = { "rust" }, 13 | root_dir = util.root_pattern(".git", "Cargo.toml"), 14 | init_options = { 15 | enable = true, 16 | fileTypes = {"rust"}, 17 | adapterCommand = { 18 | rust = { 19 | { 20 | path = "testing-ls-adapter", 21 | extra_arg = { "--test-kind=cargo-test", "--workspace" }, 22 | include = { "/demo/**/src/**/*.rs"}, 23 | exclude = { "/**/target/**"}, 24 | } 25 | } 26 | }, 27 | enableWorkspaceDiagnostics = true, 28 | trace = { 29 | server = "verbose" 30 | } 31 | } 32 | }, 33 | docs = { 34 | description = [[ 35 | https://github.com/kbwo/testing-language-server 36 | 37 | Language Server for real-time testing. 38 | ]], 39 | }, 40 | } 41 | 42 | lspconfig.testing_ls.setup{} 43 | ``` 44 | -------------------------------------------------------------------------------- /demo/deno/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "dev": "deno run --watch main.ts" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /demo/deno/deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@std/assert": "jsr:@std/assert@1.0.0", 6 | "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1" 7 | }, 8 | "jsr": { 9 | "@std/assert@1.0.0": { 10 | "integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008", 11 | "dependencies": [ 12 | "jsr:@std/internal@^1.0.1" 13 | ] 14 | }, 15 | "@std/internal@1.0.1": { 16 | "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" 17 | } 18 | } 19 | }, 20 | "remote": {} 21 | } 22 | -------------------------------------------------------------------------------- /demo/deno/main.ts: -------------------------------------------------------------------------------- 1 | export function add(a: number, b: number): number { 2 | return a + b; 3 | } 4 | 5 | // Learn more at https://deno.land/manual/examples/module_metadata#concepts 6 | if (import.meta.main) { 7 | console.log("Add 2 + 3 =", add(2, 3)); 8 | } 9 | -------------------------------------------------------------------------------- /demo/deno/main_test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertEquals } from "jsr:@std/assert"; 2 | import { add } from "./main.ts"; 3 | 4 | const throwFn = () => { 5 | throw new Error("error"); 6 | }; 7 | 8 | Deno.test(function addTest() { 9 | assertEquals(add(2, 3), 5); 10 | }); 11 | 12 | Deno.test(function fail1() { 13 | assertEquals(add(2, 5), 5); 14 | }); 15 | 16 | Deno.test(function fail2() { 17 | assert(throwFn()); 18 | }); 19 | -------------------------------------------------------------------------------- /demo/deno/output.txt: -------------------------------------------------------------------------------- 1 | running 3 tests from ./main_test.ts 2 | addTest ... ok (0ms) 3 | fail1 ... FAILED (1ms) 4 | fail1 ... FAILED (0ms) 5 | 6 |  ERRORS  7 | 8 | fail1 => ./main_test.ts:12:6 9 | error: AssertionError: Values are not equal. 10 | 11 | 12 | [Diff] Actual / Expected 13 | 14 | 15 | - 7 16 | + 5 17 | 18 | throw new AssertionError(message); 19 |  ^ 20 | at assertEquals (https://jsr.io/@std/assert/1.0.0/equals.ts:47:9) 21 | at fail1 (file:///home/demo/test/dneo/main_test.ts:13:3) 22 | 23 | fail1 => ./main_test.ts:16:6 24 | error: Error: error 25 | throw new Error("error"); 26 |  ^ 27 | at throwFn (file:///home/demo/test/dneo/main_test.ts:5:9) 28 | at fail1 (file:///home/demo/test/dneo/main_test.ts:17:10) 29 | 30 |  FAILURES  31 | 32 | fail1 => ./main_test.ts:12:6 33 | fail1 => ./main_test.ts:16:6 34 | 35 | FAILED | 1 passed | 2 failed (3ms) 36 | 37 | -------------------------------------------------------------------------------- /demo/go/README.md: -------------------------------------------------------------------------------- 1 | This directory is from https://github.com/nvim-neotest/neotest-go/tree/main/neotest_go. 2 | 3 | LICENSE: https://github.com/nvim-neotest/neotest-go/blob/main/LICENSE.md 4 | -------------------------------------------------------------------------------- /demo/go/cases.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func add(a, b int) int { 4 | return a + b 5 | } 6 | 7 | func subtract(a, b int) int { 8 | return a - b 9 | } 10 | -------------------------------------------------------------------------------- /demo/go/cases_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSubtract(t *testing.T) { 10 | testCases := []struct { 11 | desc string 12 | a int 13 | b int 14 | want int 15 | }{ 16 | { 17 | desc: "test one", 18 | a: 1, 19 | b: 2, 20 | want: 3, 21 | }, 22 | { 23 | desc: "test two", 24 | a: 1, 25 | b: 2, 26 | want: 7, 27 | }, 28 | } 29 | for _, tC := range testCases { 30 | t.Run(tC.desc, func(t *testing.T) { 31 | assert.Equal(t, tC.want, subtract(tC.a, tC.b)) 32 | }) 33 | } 34 | } 35 | 36 | func TestAdd(t *testing.T) { 37 | t.Run("test one", func(t *testing.T) { 38 | assert.Equal(t, 3, add(1, 2)) 39 | }) 40 | 41 | t.Run("test two", func(t *testing.T) { 42 | assert.Equal(t, 5, add(1, 2)) 43 | }) 44 | 45 | variable := "string" 46 | t.Run(variable, func(t *testing.T) { 47 | assert.Equal(t, 3, add(1, 2)) 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /demo/go/example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func hello() { 6 | fmt.Println("hello world") 7 | } 8 | -------------------------------------------------------------------------------- /demo/go/example_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func Example_hello_ok() { 4 | hello() 5 | 6 | // Output: 7 | // hello world 8 | } 9 | 10 | func Example_hello_ng() { 11 | hello() 12 | 13 | // Output: 14 | // NG pattern 15 | } 16 | -------------------------------------------------------------------------------- /demo/go/go.mod: -------------------------------------------------------------------------------- 1 | module neotest_go 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/davecgh/go-spew v1.1.1 // indirect 7 | github.com/pmezard/go-difflib v1.0.0 // indirect 8 | github.com/stretchr/objx v0.4.0 // indirect 9 | github.com/stretchr/testify v1.7.2 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /demo/go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 7 | github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= 8 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 9 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 10 | github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= 11 | github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 13 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 14 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 15 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | -------------------------------------------------------------------------------- /demo/go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("hello world") 7 | } 8 | 9 | func addOne(x int) int { 10 | return x + 1 11 | } 12 | 13 | func addTwo(x int) int { 14 | return x + 2 15 | } 16 | -------------------------------------------------------------------------------- /demo/go/main_tagged_test.go: -------------------------------------------------------------------------------- 1 | //go:build files 2 | // +build files 3 | 4 | package main 5 | 6 | import ( 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestAddOne2(t *testing.T) { 13 | assert.Equal(t, 2, addOne(1)) 14 | } 15 | -------------------------------------------------------------------------------- /demo/go/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAddOne(t *testing.T) { 10 | assert.Equal(t, 2, addOne(1)) 11 | } 12 | 13 | func TestAddTwo(t *testing.T) { 14 | assert.Equal(t, 3, addTwo(1)) 15 | } 16 | -------------------------------------------------------------------------------- /demo/go/many_table_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestSomeTest(t *testing.T) { 10 | tt := []struct { 11 | name string 12 | method string 13 | url string 14 | apiKey string 15 | status int 16 | }{ 17 | {name: "AccessDenied1", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 18 | {name: "AccessDenied2", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 19 | {name: "AccessDenied3", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 20 | {name: "AccessDenied4", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 21 | {name: "AccessDenied5", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 22 | {name: "AccessDenied6", method: http.MethodGet, url: "/api/nothing", apiKey: "lalala", status: http.StatusForbidden}, 23 | } 24 | 25 | for _, tc := range tt { 26 | tc := tc 27 | t.Run(tc.name, func(_ *testing.T) { 28 | fmt.Println(tc.name, tc.method, tc.url, tc.apiKey, tc.status) 29 | }) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /demo/go/map_table_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestSplit(t *testing.T) { 10 | tests := map[string]struct { 11 | input string 12 | sep string 13 | want []string 14 | }{ 15 | "simple": {input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}}, 16 | "wrong sep": {input: "a/b/c", sep: ",", want: []string{"a/b/c"}}, 17 | "no sep": {input: "abc", sep: "/", want: []string{"abc"}}, 18 | "trailing sep": {input: "a/b/c/", sep: "/", want: []string{"a", "b", "c"}}, 19 | } 20 | 21 | for name, tc := range tests { 22 | t.Run(name, func(t *testing.T) { 23 | got := Split(tc.input, tc.sep) 24 | if !reflect.DeepEqual(tc.want, got) { 25 | t.Fatalf("%s: expected: %v, got: %v", name, tc.want, got) 26 | } 27 | }) 28 | } 29 | } 30 | 31 | func Split(s, sep string) []string { 32 | var result []string 33 | i := strings.Index(s, sep) 34 | for i > -1 { 35 | result = append(result, s[:i]) 36 | s = s[i+len(sep):] 37 | i = strings.Index(s, sep) 38 | } 39 | return append(result, s) 40 | } 41 | -------------------------------------------------------------------------------- /demo/go/suite_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Basic imports 4 | import ( 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/suite" 9 | ) 10 | 11 | // Define the suite, and absorb the built-in basic suite 12 | // functionality from testify - including a T() method which 13 | // returns the current testing context 14 | type ExampleTestSuite struct { 15 | suite.Suite 16 | VariableThatShouldStartAtFive int 17 | } 18 | 19 | // Make sure that VariableThatShouldStartAtFive is set to five 20 | // before each test 21 | func (suite *ExampleTestSuite) SetupTest() { 22 | suite.VariableThatShouldStartAtFive = 5 23 | } 24 | 25 | // All methods that begin with "Test" are run as tests within a 26 | // suite. 27 | func (suite *ExampleTestSuite) TestExample() { 28 | assert.Equal(suite.T(), 5, suite.VariableThatShouldStartAtFive) 29 | } 30 | 31 | func (suite *ExampleTestSuite) TestExampleFailure() { 32 | assert.Equal(suite.T(), 5, 3) 33 | } 34 | 35 | // In order for 'go test' to run this suite, we need to create 36 | // a normal test function and pass our suite to suite.Run 37 | func TestExampleTestSuite(t *testing.T) { 38 | suite.Run(t, new(ExampleTestSuite)) 39 | } 40 | -------------------------------------------------------------------------------- /demo/go/three_level_nested_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestOdd(t *testing.T) { 6 | t.Run("odd", func(t *testing.T) { 7 | t.Run("5 is odd", func(t *testing.T) { 8 | if 5%2 != 1 { 9 | t.Error("5 is actually odd") 10 | } 11 | t.Run("9 is odd", func(t *testing.T) { 12 | if 9%2 != 1 { 13 | t.Error("5 is actually odd") 14 | } 15 | }) 16 | }) 17 | t.Run("7 is odd", func(t *testing.T) { 18 | if 7%2 != 1 { 19 | t.Error("7 is actually odd") 20 | } 21 | }) 22 | 23 | }) 24 | 25 | } 26 | -------------------------------------------------------------------------------- /demo/jest/.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /demo/jest/README.md: -------------------------------------------------------------------------------- 1 | # demo 2 | 3 | To install dependencies: 4 | 5 | ```bash 6 | bun install 7 | ``` 8 | 9 | To run: 10 | 11 | ```bash 12 | bun run index.js 13 | ``` 14 | 15 | This project was created using `bun init` in bun v1.1.6. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime. 16 | -------------------------------------------------------------------------------- /demo/jest/another.spec.js: -------------------------------------------------------------------------------- 1 | describe("another", () => { 2 | it("fail", () => { 3 | expect(1).toBe(0); 4 | }); 5 | 6 | it("pass", () => { 7 | expect(1).toBe(1); 8 | }); 9 | }); 10 | 11 | test("toplevel test", () => { 12 | expect(1).toBe(2); 13 | }); 14 | -------------------------------------------------------------------------------- /demo/jest/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kbwo/testing-language-server/c53cdb6a6b5f8ee6b337c640a16ee3565fac94f2/demo/jest/bun.lockb -------------------------------------------------------------------------------- /demo/jest/index.js: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /demo/jest/index.spec.js: -------------------------------------------------------------------------------- 1 | describe("index", () => { 2 | it("fail", () => { 3 | expect(1).toBe(0); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /demo/jest/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo/jest/output.json: -------------------------------------------------------------------------------- 1 | { 2 | "numFailedTestSuites": 2, 3 | "numFailedTests": 2, 4 | "numPassedTestSuites": 0, 5 | "numPassedTests": 1, 6 | "numPendingTestSuites": 0, 7 | "numPendingTests": 0, 8 | "numRuntimeErrorTestSuites": 0, 9 | "numTodoTests": 0, 10 | "numTotalTestSuites": 2, 11 | "numTotalTests": 3, 12 | "openHandles": [], 13 | "snapshot": { 14 | "added": 0, 15 | "didUpdate": false, 16 | "failure": false, 17 | "filesAdded": 0, 18 | "filesRemoved": 0, 19 | "filesRemovedList": [], 20 | "filesUnmatched": 0, 21 | "filesUpdated": 0, 22 | "matched": 0, 23 | "total": 0, 24 | "unchecked": 0, 25 | "uncheckedKeysByFile": [], 26 | "unmatched": 0, 27 | "updated": 0 28 | }, 29 | "startTime": 1714484637658, 30 | "success": false, 31 | "testResults": [ 32 | { 33 | "assertionResults": [ 34 | { 35 | "ancestorTitles": ["index"], 36 | "duration": 3, 37 | "failureDetails": [ 38 | { 39 | "matcherResult": { 40 | "actual": 1, 41 | "expected": 0, 42 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m0\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 43 | "name": "toBe", 44 | "pass": false 45 | } 46 | } 47 | ], 48 | "failureMessages": [ 49 | "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m0\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at Object.toBe (/absolute_path/demo/jest/index.spec.js:4:15)\n at Promise.then.completed (/absolute_path/demo/jest/node_modules/jest-circus/build/utils.js:298:28)\n at new Promise ()\n at callAsyncCircusFn (/absolute_path/demo/jest/node_modules/jest-circus/build/utils.js:231:10)\n at _callCircusTest (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:316:40)\n at _runTest (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:252:3)\n at _runTestsForDescribeBlock (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:126:9)\n at _runTestsForDescribeBlock (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:121:9)\n at run (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:71:3)\n at runAndTransformResultsToJestFormat (/absolute_path/demo/jest/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)\n at jestAdapter (/absolute_path/demo/jest/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)\n at runTestInternal (/absolute_path/demo/jest/node_modules/jest-runner/build/runTest.js:367:16)\n at runTest (/absolute_path/demo/jest/node_modules/jest-runner/build/runTest.js:444:34)" 50 | ], 51 | "fullName": "index fail", 52 | "invocations": 1, 53 | "location": { 54 | "column": 3, 55 | "line": 3 56 | }, 57 | "numPassingAsserts": 0, 58 | "retryReasons": [], 59 | "status": "failed", 60 | "title": "fail" 61 | } 62 | ], 63 | "endTime": 1714484637874, 64 | "message": "\u001b[1m\u001b[31m \u001b[1m● \u001b[22m\u001b[1mindex › fail\u001b[39m\u001b[22m\n\n \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\n Expected: \u001b[32m0\u001b[39m\n Received: \u001b[31m1\u001b[39m\n\u001b[2m\u001b[22m\n\u001b[2m \u001b[0m \u001b[90m 2 |\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 3 |\u001b[39m it(\u001b[32m\"fail\"\u001b[39m\u001b[33m,\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[22m\n\u001b[2m \u001b[31m\u001b[1m>\u001b[22m\u001b[2m\u001b[39m\u001b[90m 4 |\u001b[39m expect(\u001b[35m1\u001b[39m)\u001b[33m.\u001b[39mtoBe(\u001b[35m0\u001b[39m)\u001b[22m\n\u001b[2m \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[2m\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 5 |\u001b[39m })\u001b[22m\n\u001b[2m \u001b[90m 6 |\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 7 |\u001b[39m })\u001b[0m\u001b[22m\n\u001b[2m\u001b[22m\n\u001b[2m \u001b[2mat Object.toBe (\u001b[22m\u001b[2m\u001b[0m\u001b[36mindex.spec.js\u001b[39m\u001b[0m\u001b[2m:4:15)\u001b[22m\u001b[2m\u001b[22m\n", 65 | "name": "/absolute_path/demo/jest/index.spec.js", 66 | "startTime": 1714484637684, 67 | "status": "failed", 68 | "summary": "" 69 | }, 70 | { 71 | "assertionResults": [ 72 | { 73 | "ancestorTitles": ["another"], 74 | "duration": 2, 75 | "failureDetails": [ 76 | { 77 | "matcherResult": { 78 | "actual": 1, 79 | "expected": 0, 80 | "message": "\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m0\u001b[39m\nReceived: \u001b[31m1\u001b[39m", 81 | "name": "toBe", 82 | "pass": false 83 | } 84 | } 85 | ], 86 | "failureMessages": [ 87 | "Error: \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\nExpected: \u001b[32m0\u001b[39m\nReceived: \u001b[31m1\u001b[39m\n at Object.toBe (/absolute_path/demo/jest/another.spec.js:4:15)\n at Promise.then.completed (/absolute_path/demo/jest/node_modules/jest-circus/build/utils.js:298:28)\n at new Promise ()\n at callAsyncCircusFn (/absolute_path/demo/jest/node_modules/jest-circus/build/utils.js:231:10)\n at _callCircusTest (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:316:40)\n at _runTest (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:252:3)\n at _runTestsForDescribeBlock (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:126:9)\n at _runTestsForDescribeBlock (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:121:9)\n at run (/absolute_path/demo/jest/node_modules/jest-circus/build/run.js:71:3)\n at runAndTransformResultsToJestFormat (/absolute_path/demo/jest/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)\n at jestAdapter (/absolute_path/demo/jest/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)\n at runTestInternal (/absolute_path/demo/jest/node_modules/jest-runner/build/runTest.js:367:16)\n at runTest (/absolute_path/demo/jest/node_modules/jest-runner/build/runTest.js:444:34)" 88 | ], 89 | "fullName": "another fail", 90 | "invocations": 1, 91 | "location": { 92 | "column": 3, 93 | "line": 3 94 | }, 95 | "numPassingAsserts": 0, 96 | "retryReasons": [], 97 | "status": "failed", 98 | "title": "fail" 99 | }, 100 | { 101 | "ancestorTitles": ["another"], 102 | "duration": 1, 103 | "failureDetails": [], 104 | "failureMessages": [], 105 | "fullName": "another pass", 106 | "invocations": 1, 107 | "location": { 108 | "column": 3, 109 | "line": 7 110 | }, 111 | "numPassingAsserts": 1, 112 | "retryReasons": [], 113 | "status": "passed", 114 | "title": "pass" 115 | } 116 | ], 117 | "endTime": 1714484637974, 118 | "message": "\u001b[1m\u001b[31m \u001b[1m● \u001b[22m\u001b[1manother › fail\u001b[39m\u001b[22m\n\n \u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m).\u001b[22mtoBe\u001b[2m(\u001b[22m\u001b[32mexpected\u001b[39m\u001b[2m) // Object.is equality\u001b[22m\n\n Expected: \u001b[32m0\u001b[39m\n Received: \u001b[31m1\u001b[39m\n\u001b[2m\u001b[22m\n\u001b[2m \u001b[0m \u001b[90m 2 |\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 3 |\u001b[39m it(\u001b[32m\"fail\"\u001b[39m\u001b[33m,\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[22m\n\u001b[2m \u001b[31m\u001b[1m>\u001b[22m\u001b[2m\u001b[39m\u001b[90m 4 |\u001b[39m expect(\u001b[35m1\u001b[39m)\u001b[33m.\u001b[39mtoBe(\u001b[35m0\u001b[39m)\u001b[22m\n\u001b[2m \u001b[90m |\u001b[39m \u001b[31m\u001b[1m^\u001b[22m\u001b[2m\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 5 |\u001b[39m })\u001b[22m\n\u001b[2m \u001b[90m 6 |\u001b[39m\u001b[22m\n\u001b[2m \u001b[90m 7 |\u001b[39m it(\u001b[32m\"pass\"\u001b[39m\u001b[33m,\u001b[39m () \u001b[33m=>\u001b[39m {\u001b[0m\u001b[22m\n\u001b[2m\u001b[22m\n\u001b[2m \u001b[2mat Object.toBe (\u001b[22m\u001b[2m\u001b[0m\u001b[36manother.spec.js\u001b[39m\u001b[0m\u001b[2m:4:15)\u001b[22m\u001b[2m\u001b[22m\n", 119 | "name": "/absolute_path/demo/jest/another.spec.js", 120 | "startTime": 1714484637879, 121 | "status": "failed", 122 | "summary": "" 123 | } 124 | ], 125 | "wasInterrupted": false 126 | } 127 | -------------------------------------------------------------------------------- /demo/jest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "module": "index.js", 4 | "type": "module", 5 | "devDependencies": { 6 | "@types/bun": "latest", 7 | "@types/jest": "^29.5.12", 8 | "jest": "^29.7.0" 9 | }, 10 | "peerDependencies": { 11 | "typescript": "^5.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /demo/node-test/index.test.js: -------------------------------------------------------------------------------- 1 | const test = require("node:test"); 2 | const { describe, it } = require("node:test"); 3 | const assert = require("node:assert"); 4 | const { throwError } = require("./util.js"); 5 | // # Basic example 6 | test("synchronous passing test", (t) => { 7 | // This test passes because it does not throw an exception. 8 | assert.strictEqual(1, 1); 9 | }); 10 | 11 | test("synchronous failing test", (t) => { 12 | // This test fails because it throws an exception. 13 | assert.strictEqual(1, 2); 14 | }); 15 | 16 | test("asynchronous passing test", async (t) => { 17 | // This test passes because the Promise returned by the async 18 | // function is settled and not rejected. 19 | assert.strictEqual(1, 1); 20 | }); 21 | 22 | test("asynchronous failing test", async (t) => { 23 | // This test fails because the Promise returned by the async 24 | // function is rejected. 25 | assert.strictEqual(1, 2); 26 | }); 27 | 28 | test("failing test using Promises", (t) => { 29 | // Promises can be used directly as well. 30 | return new Promise((resolve, reject) => { 31 | setImmediate(() => { 32 | reject(new Error("this will cause the test to fail")); 33 | }); 34 | }); 35 | }); 36 | 37 | test("callback passing test", (t, done) => { 38 | // done() is the callback function. When the setImmediate() runs, it invokes 39 | // done() with no arguments. 40 | setImmediate(done); 41 | }); 42 | 43 | test("callback failing test", (t, done) => { 44 | // When the setImmediate() runs, done() is invoked with an Error object and 45 | // the test fails. 46 | setImmediate(() => { 47 | done(new Error("callback failure")); 48 | }); 49 | }); 50 | 51 | // # Subtests 52 | test("top level test", async (t) => { 53 | await t.test("subtest 1", (t) => { 54 | assert.strictEqual(1, 1); 55 | }); 56 | 57 | await t.test("subtest 2", (t) => { 58 | assert.strictEqual(2, 2); 59 | }); 60 | }); 61 | 62 | // # Skipping tests 63 | // The skip option is used, but no message is provided. 64 | test("skip option", { skip: true }, (t) => { 65 | // This code is never executed. 66 | }); 67 | 68 | // The skip option is used, and a message is provided. 69 | test("skip option with message", { skip: "this is skipped" }, (t) => { 70 | // This code is never executed. 71 | }); 72 | 73 | test("skip() method", (t) => { 74 | // Make sure to return here as well if the test contains additional logic. 75 | t.skip(); 76 | }); 77 | 78 | test("skip() method with message", (t) => { 79 | // Make sure to return here as well if the test contains additional logic. 80 | t.skip("this is skipped"); 81 | }); 82 | 83 | // # TODO tests 84 | // The todo option is used, but no message is provided. 85 | test("todo option", { todo: true }, (t) => { 86 | // This code is executed, but not treated as a failure. 87 | throw new Error("this does not fail the test"); 88 | }); 89 | 90 | // The todo option is used, and a message is provided. 91 | test("todo option with message", { todo: "this is a todo test" }, (t) => { 92 | // This code is executed. 93 | }); 94 | 95 | test("todo() method", (t) => { 96 | t.todo(); 97 | }); 98 | 99 | test("todo() method with message", (t) => { 100 | t.todo("this is a todo test and is not treated as a failure"); 101 | throw new Error("this does not fail the test"); 102 | }); 103 | 104 | // # describe() and it() aliases 105 | describe("A thing", () => { 106 | it("should work", () => { 107 | assert.strictEqual(1, 1); 108 | }); 109 | 110 | it("should be ok", () => { 111 | assert.strictEqual(2, 2); 112 | }); 113 | 114 | describe("a nested thing", () => { 115 | it("should work", () => { 116 | assert.strictEqual(3, 3); 117 | }); 118 | }); 119 | }); 120 | 121 | // # only tests 122 | // Assume Node.js is run with the --test-only command-line option. 123 | // The suite's 'only' option is set, so these tests are run. 124 | test("only: this test is run", { only: true }, async (t) => { 125 | // Within this test, all subtests are run by default. 126 | await t.test("running subtest"); 127 | 128 | // The test context can be updated to run subtests with the 'only' option. 129 | t.runOnly(true); 130 | await t.test("this subtest is now skipped"); 131 | await t.test("this subtest is run", { only: true }); 132 | 133 | // Switch the context back to execute all tests. 134 | t.runOnly(false); 135 | await t.test("this subtest is now run"); 136 | 137 | // Explicitly do not run these tests. 138 | await t.test("skipped subtest 3", { only: false }); 139 | await t.test("skipped subtest 4", { skip: true }); 140 | }); 141 | 142 | // The 'only' option is not set, so this test is skipped. 143 | test("only: this test is not run", () => { 144 | // This code is not run. 145 | throw new Error("fail"); 146 | }); 147 | 148 | describe("A suite", () => { 149 | // The 'only' option is set, so this test is run. 150 | it("this test is run A ", { only: true }, () => { 151 | // This code is run. 152 | }); 153 | 154 | it("this test is not run B", () => { 155 | // This code is not run. 156 | throw new Error("fail"); 157 | }); 158 | }); 159 | 160 | describe.only("B suite", () => { 161 | // The 'only' option is set, so this test is run. 162 | it("this test is run C", () => { 163 | // This code is run. 164 | }); 165 | 166 | it("this test is run D", () => { 167 | // This code is run. 168 | }); 169 | }); 170 | 171 | test("import from external file. this must be fail", () => { 172 | throwError(); 173 | }); 174 | -------------------------------------------------------------------------------- /demo/node-test/output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | [Error [ERR_TEST_FAILURE]: Expected values to be strictly equal: 7 | 8 | 1 !== 2 9 | ] { 10 | failureType: 'testCodeFailure', 11 | cause: AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 12 | 13 | 1 !== 2 14 | 15 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:13:10) 16 | at Test.runInAsyncScope (node:async_hooks:203:9) 17 | at Test.run (node:internal/test_runner/test:631:25) 18 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 19 | at Test.postRun (node:internal/test_runner/test:715:19) 20 | at Test.run (node:internal/test_runner/test:673:12) 21 | at async startSubtest (node:internal/test_runner/harness:214:3) { 22 | generatedMessage: true, 23 | code: 'ERR_ASSERTION', 24 | actual: 1, 25 | expected: 2, 26 | operator: 'strictEqual' 27 | }, 28 | code: 'ERR_TEST_FAILURE' 29 | } 30 | 31 | 32 | 33 | 34 | 35 | [Error [ERR_TEST_FAILURE]: Expected values to be strictly equal: 36 | 37 | 1 !== 2 38 | ] { 39 | failureType: 'testCodeFailure', 40 | cause: AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: 41 | 42 | 1 !== 2 43 | 44 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:25:10) 45 | at Test.runInAsyncScope (node:async_hooks:203:9) 46 | at Test.run (node:internal/test_runner/test:631:25) 47 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 48 | at Test.postRun (node:internal/test_runner/test:715:19) 49 | at Test.run (node:internal/test_runner/test:673:12) 50 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7) { 51 | generatedMessage: true, 52 | code: 'ERR_ASSERTION', 53 | actual: 1, 54 | expected: 2, 55 | operator: 'strictEqual' 56 | }, 57 | code: 'ERR_TEST_FAILURE' 58 | } 59 | 60 | 61 | 62 | 63 | [Error [ERR_TEST_FAILURE]: this will cause the test to fail] { 64 | failureType: 'testCodeFailure', 65 | cause: Error: this will cause the test to fail 66 | at Immediate.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:32:14) 67 | at process.processImmediate (node:internal/timers:476:21), 68 | code: 'ERR_TEST_FAILURE' 69 | } 70 | 71 | 72 | 73 | 74 | 75 | [Error [ERR_TEST_FAILURE]: callback failure] { 76 | failureType: 'testCodeFailure', 77 | cause: Error: callback failure 78 | at Immediate.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:47:10) 79 | at process.processImmediate (node:internal/timers:476:21), 80 | code: 'ERR_TEST_FAILURE' 81 | } 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | [Error [ERR_TEST_FAILURE]: this does not fail the test] { 104 | failureType: 'testCodeFailure', 105 | cause: Error: this does not fail the test 106 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:87:9) 107 | at Test.runInAsyncScope (node:async_hooks:203:9) 108 | at Test.run (node:internal/test_runner/test:631:25) 109 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 110 | at Test.postRun (node:internal/test_runner/test:715:19) 111 | at Test.run (node:internal/test_runner/test:673:12) 112 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), 113 | code: 'ERR_TEST_FAILURE' 114 | } 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | [Error [ERR_TEST_FAILURE]: this does not fail the test] { 127 | failureType: 'testCodeFailure', 128 | cause: Error: this does not fail the test 129 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:101:9) 130 | at Test.runInAsyncScope (node:async_hooks:203:9) 131 | at Test.run (node:internal/test_runner/test:631:25) 132 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 133 | at Test.postRun (node:internal/test_runner/test:715:19) 134 | at Test.run (node:internal/test_runner/test:673:12) 135 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), 136 | code: 'ERR_TEST_FAILURE' 137 | } 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | [Error [ERR_TEST_FAILURE]: fail] { 164 | failureType: 'testCodeFailure', 165 | cause: Error: fail 166 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:145:9) 167 | at Test.runInAsyncScope (node:async_hooks:203:9) 168 | at Test.run (node:internal/test_runner/test:631:25) 169 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 170 | at Test.postRun (node:internal/test_runner/test:715:19) 171 | at Test.run (node:internal/test_runner/test:673:12) 172 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), 173 | code: 'ERR_TEST_FAILURE' 174 | } 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | [Error [ERR_TEST_FAILURE]: fail] { 183 | failureType: 'testCodeFailure', 184 | cause: Error: fail 185 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:156:11) 186 | at Test.runInAsyncScope (node:async_hooks:203:9) 187 | at Test.run (node:internal/test_runner/test:631:25) 188 | at Suite.processPendingSubtests (node:internal/test_runner/test:374:18) 189 | at Test.postRun (node:internal/test_runner/test:715:19) 190 | at Test.run (node:internal/test_runner/test:673:12) 191 | at async Promise.all (index 0) 192 | at async Suite.run (node:internal/test_runner/test:948:7) 193 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), 194 | code: 'ERR_TEST_FAILURE' 195 | } 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | [Error [ERR_TEST_FAILURE]: this will cause the test to fail] { 207 | failureType: 'testCodeFailure', 208 | cause: Error: this will cause the test to fail 209 | at throwError (/home/test-user/projects/testing-language-server/demo/node-test/util.js:2:9) 210 | at TestContext.<anonymous> (/home/test-user/projects/testing-language-server/demo/node-test/index.test.js:172:3) 211 | at Test.runInAsyncScope (node:async_hooks:203:9) 212 | at Test.run (node:internal/test_runner/test:631:25) 213 | at Test.processPendingSubtests (node:internal/test_runner/test:374:18) 214 | at Suite.postRun (node:internal/test_runner/test:715:19) 215 | at Suite.run (node:internal/test_runner/test:962:10) 216 | at async Test.processPendingSubtests (node:internal/test_runner/test:374:7), 217 | code: 'ERR_TEST_FAILURE' 218 | } 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | -------------------------------------------------------------------------------- /demo/node-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "main": "index.test.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "" 12 | } 13 | -------------------------------------------------------------------------------- /demo/node-test/util.js: -------------------------------------------------------------------------------- 1 | const throwError = () => { 2 | throw new Error("this will cause the test to fail"); 3 | }; 4 | 5 | module.exports = { throwError }; 6 | -------------------------------------------------------------------------------- /demo/phpunit/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /demo/phpunit/.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | php = "8.3" 3 | -------------------------------------------------------------------------------- /demo/phpunit/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kbwo/phpunit", 3 | "autoload": { 4 | "psr-4": { 5 | "App\\": "src/" 6 | } 7 | }, 8 | "authors": [ 9 | { 10 | "name": "kbwo", 11 | "email": "kabaaa1126@gmail.com" 12 | } 13 | ], 14 | "require-dev": { 15 | "phpunit/phpunit": "^11.3" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /demo/phpunit/output.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Tests\CalculatorTest::testFail1 9 | Failed asserting that 8 matches expected 1. 10 | 11 | /home/kbwo/testing-language-server/demo/phpunit/src/CalculatorTest.php:28 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /demo/phpunit/src/Calculator.php: -------------------------------------------------------------------------------- 1 | add(2, 3); 14 | $this->assertEquals(5, $result); 15 | } 16 | 17 | public function testSubtract() 18 | { 19 | $calculator = new Calculator(); 20 | $result = $calculator->subtract(5, 3); 21 | $this->assertEquals(2, $result); 22 | } 23 | 24 | public function testFail1() 25 | { 26 | $calculator = new Calculator(); 27 | $result = $calculator->subtract(10, 2); 28 | $this->assertEquals(1, $result); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /demo/rust/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /demo/rust/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "autocfg" 22 | version = "1.2.0" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" 25 | 26 | [[package]] 27 | name = "backtrace" 28 | version = "0.3.71" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" 31 | dependencies = [ 32 | "addr2line", 33 | "cc", 34 | "cfg-if", 35 | "libc", 36 | "miniz_oxide", 37 | "object", 38 | "rustc-demangle", 39 | ] 40 | 41 | [[package]] 42 | name = "bitflags" 43 | version = "2.5.0" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 46 | 47 | [[package]] 48 | name = "bytes" 49 | version = "1.6.0" 50 | source = "registry+https://github.com/rust-lang/crates.io-index" 51 | checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" 52 | 53 | [[package]] 54 | name = "cc" 55 | version = "1.0.96" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "065a29261d53ba54260972629f9ca6bffa69bac13cd1fed61420f7fa68b9f8bd" 58 | 59 | [[package]] 60 | name = "cfg-if" 61 | version = "1.0.0" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 64 | 65 | [[package]] 66 | name = "demo" 67 | version = "0.1.0" 68 | dependencies = [ 69 | "tokio", 70 | ] 71 | 72 | [[package]] 73 | name = "gimli" 74 | version = "0.28.1" 75 | source = "registry+https://github.com/rust-lang/crates.io-index" 76 | checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" 77 | 78 | [[package]] 79 | name = "hermit-abi" 80 | version = "0.3.9" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" 83 | 84 | [[package]] 85 | name = "libc" 86 | version = "0.2.154" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" 89 | 90 | [[package]] 91 | name = "lock_api" 92 | version = "0.4.12" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 95 | dependencies = [ 96 | "autocfg", 97 | "scopeguard", 98 | ] 99 | 100 | [[package]] 101 | name = "memchr" 102 | version = "2.7.2" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" 105 | 106 | [[package]] 107 | name = "miniz_oxide" 108 | version = "0.7.2" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" 111 | dependencies = [ 112 | "adler", 113 | ] 114 | 115 | [[package]] 116 | name = "mio" 117 | version = "0.8.11" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" 120 | dependencies = [ 121 | "libc", 122 | "wasi", 123 | "windows-sys 0.48.0", 124 | ] 125 | 126 | [[package]] 127 | name = "num_cpus" 128 | version = "1.16.0" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" 131 | dependencies = [ 132 | "hermit-abi", 133 | "libc", 134 | ] 135 | 136 | [[package]] 137 | name = "object" 138 | version = "0.32.2" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" 141 | dependencies = [ 142 | "memchr", 143 | ] 144 | 145 | [[package]] 146 | name = "parking_lot" 147 | version = "0.12.2" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" 150 | dependencies = [ 151 | "lock_api", 152 | "parking_lot_core", 153 | ] 154 | 155 | [[package]] 156 | name = "parking_lot_core" 157 | version = "0.9.10" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 160 | dependencies = [ 161 | "cfg-if", 162 | "libc", 163 | "redox_syscall", 164 | "smallvec", 165 | "windows-targets 0.52.5", 166 | ] 167 | 168 | [[package]] 169 | name = "pin-project-lite" 170 | version = "0.2.14" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" 173 | 174 | [[package]] 175 | name = "proc-macro2" 176 | version = "1.0.81" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" 179 | dependencies = [ 180 | "unicode-ident", 181 | ] 182 | 183 | [[package]] 184 | name = "quote" 185 | version = "1.0.36" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 188 | dependencies = [ 189 | "proc-macro2", 190 | ] 191 | 192 | [[package]] 193 | name = "redox_syscall" 194 | version = "0.5.1" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" 197 | dependencies = [ 198 | "bitflags", 199 | ] 200 | 201 | [[package]] 202 | name = "rustc-demangle" 203 | version = "0.1.23" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 206 | 207 | [[package]] 208 | name = "scopeguard" 209 | version = "1.2.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 212 | 213 | [[package]] 214 | name = "signal-hook-registry" 215 | version = "1.4.2" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 218 | dependencies = [ 219 | "libc", 220 | ] 221 | 222 | [[package]] 223 | name = "smallvec" 224 | version = "1.13.2" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 227 | 228 | [[package]] 229 | name = "socket2" 230 | version = "0.5.6" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871" 233 | dependencies = [ 234 | "libc", 235 | "windows-sys 0.52.0", 236 | ] 237 | 238 | [[package]] 239 | name = "syn" 240 | version = "2.0.60" 241 | source = "registry+https://github.com/rust-lang/crates.io-index" 242 | checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3" 243 | dependencies = [ 244 | "proc-macro2", 245 | "quote", 246 | "unicode-ident", 247 | ] 248 | 249 | [[package]] 250 | name = "tokio" 251 | version = "1.37.0" 252 | source = "registry+https://github.com/rust-lang/crates.io-index" 253 | checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" 254 | dependencies = [ 255 | "backtrace", 256 | "bytes", 257 | "libc", 258 | "mio", 259 | "num_cpus", 260 | "parking_lot", 261 | "pin-project-lite", 262 | "signal-hook-registry", 263 | "socket2", 264 | "tokio-macros", 265 | "windows-sys 0.48.0", 266 | ] 267 | 268 | [[package]] 269 | name = "tokio-macros" 270 | version = "2.2.0" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" 273 | dependencies = [ 274 | "proc-macro2", 275 | "quote", 276 | "syn", 277 | ] 278 | 279 | [[package]] 280 | name = "unicode-ident" 281 | version = "1.0.12" 282 | source = "registry+https://github.com/rust-lang/crates.io-index" 283 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 284 | 285 | [[package]] 286 | name = "wasi" 287 | version = "0.11.0+wasi-snapshot-preview1" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 290 | 291 | [[package]] 292 | name = "windows-sys" 293 | version = "0.48.0" 294 | source = "registry+https://github.com/rust-lang/crates.io-index" 295 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 296 | dependencies = [ 297 | "windows-targets 0.48.5", 298 | ] 299 | 300 | [[package]] 301 | name = "windows-sys" 302 | version = "0.52.0" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 305 | dependencies = [ 306 | "windows-targets 0.52.5", 307 | ] 308 | 309 | [[package]] 310 | name = "windows-targets" 311 | version = "0.48.5" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 314 | dependencies = [ 315 | "windows_aarch64_gnullvm 0.48.5", 316 | "windows_aarch64_msvc 0.48.5", 317 | "windows_i686_gnu 0.48.5", 318 | "windows_i686_msvc 0.48.5", 319 | "windows_x86_64_gnu 0.48.5", 320 | "windows_x86_64_gnullvm 0.48.5", 321 | "windows_x86_64_msvc 0.48.5", 322 | ] 323 | 324 | [[package]] 325 | name = "windows-targets" 326 | version = "0.52.5" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" 329 | dependencies = [ 330 | "windows_aarch64_gnullvm 0.52.5", 331 | "windows_aarch64_msvc 0.52.5", 332 | "windows_i686_gnu 0.52.5", 333 | "windows_i686_gnullvm", 334 | "windows_i686_msvc 0.52.5", 335 | "windows_x86_64_gnu 0.52.5", 336 | "windows_x86_64_gnullvm 0.52.5", 337 | "windows_x86_64_msvc 0.52.5", 338 | ] 339 | 340 | [[package]] 341 | name = "windows_aarch64_gnullvm" 342 | version = "0.48.5" 343 | source = "registry+https://github.com/rust-lang/crates.io-index" 344 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 345 | 346 | [[package]] 347 | name = "windows_aarch64_gnullvm" 348 | version = "0.52.5" 349 | source = "registry+https://github.com/rust-lang/crates.io-index" 350 | checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" 351 | 352 | [[package]] 353 | name = "windows_aarch64_msvc" 354 | version = "0.48.5" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 357 | 358 | [[package]] 359 | name = "windows_aarch64_msvc" 360 | version = "0.52.5" 361 | source = "registry+https://github.com/rust-lang/crates.io-index" 362 | checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" 363 | 364 | [[package]] 365 | name = "windows_i686_gnu" 366 | version = "0.48.5" 367 | source = "registry+https://github.com/rust-lang/crates.io-index" 368 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 369 | 370 | [[package]] 371 | name = "windows_i686_gnu" 372 | version = "0.52.5" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" 375 | 376 | [[package]] 377 | name = "windows_i686_gnullvm" 378 | version = "0.52.5" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" 381 | 382 | [[package]] 383 | name = "windows_i686_msvc" 384 | version = "0.48.5" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 387 | 388 | [[package]] 389 | name = "windows_i686_msvc" 390 | version = "0.52.5" 391 | source = "registry+https://github.com/rust-lang/crates.io-index" 392 | checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" 393 | 394 | [[package]] 395 | name = "windows_x86_64_gnu" 396 | version = "0.48.5" 397 | source = "registry+https://github.com/rust-lang/crates.io-index" 398 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 399 | 400 | [[package]] 401 | name = "windows_x86_64_gnu" 402 | version = "0.52.5" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" 405 | 406 | [[package]] 407 | name = "windows_x86_64_gnullvm" 408 | version = "0.48.5" 409 | source = "registry+https://github.com/rust-lang/crates.io-index" 410 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 411 | 412 | [[package]] 413 | name = "windows_x86_64_gnullvm" 414 | version = "0.52.5" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" 417 | 418 | [[package]] 419 | name = "windows_x86_64_msvc" 420 | version = "0.48.5" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 423 | 424 | [[package]] 425 | name = "windows_x86_64_msvc" 426 | version = "0.52.5" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" 429 | -------------------------------------------------------------------------------- /demo/rust/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "demo" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | tokio = { version = "1", features = ["full"] } 10 | -------------------------------------------------------------------------------- /demo/rust/src/lib.rs: -------------------------------------------------------------------------------- 1 | fn hello() { 2 | println!("Hello, world!"); 3 | } 4 | 5 | #[cfg(test)] 6 | mod tests { 7 | fn not_test() {} 8 | 9 | #[test] 10 | fn success() { 11 | assert!(true); 12 | } 13 | 14 | #[test] 15 | fn fail() { 16 | assert!(false); 17 | } 18 | 19 | #[tokio::test] 20 | async fn tokio_test_success() { 21 | assert!(true); 22 | } 23 | 24 | #[tokio::test] 25 | async fn tokio_test_fail() { 26 | assert!(false); 27 | } 28 | 29 | mod nested_namespace { 30 | fn not_test() {} 31 | 32 | #[test] 33 | fn success() { 34 | assert!(true); 35 | } 36 | 37 | #[test] 38 | fn fail() { 39 | assert!(false); 40 | } 41 | 42 | mod nested_nested_namespace { 43 | fn not_test() {} 44 | 45 | #[test] 46 | fn success() { 47 | assert!(true); 48 | } 49 | 50 | #[test] 51 | fn fail() { 52 | assert!(false); 53 | } 54 | } 55 | } 56 | 57 | fn p() { 58 | panic!("test failed"); 59 | } 60 | 61 | #[test] 62 | fn test_panic() { 63 | p(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /demo/vitest/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .yarn 3 | -------------------------------------------------------------------------------- /demo/vitest/basic.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import { describe, test } from "vitest"; 3 | 4 | describe("describe text", () => { 5 | test("pass", async () => { 6 | assert(false); 7 | }); 8 | 9 | test("fail", async () => { 10 | assert(false); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /demo/vitest/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spec", 3 | "version": "0.0.1", 4 | "description": "neotest-vitest spec", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "dependencies": { 8 | "ts-node": "^10.8.2", 9 | "typescript": "^4.7.4" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^18.0.3", 13 | "vite": "^3.0.9", 14 | "vitest": "^0.22.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/vitest/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // Configure Vitest (https://vitest.dev/config/) 4 | 5 | import { defineConfig } from 'vite' 6 | 7 | export default defineConfig({ 8 | test: { 9 | /* for example, use global to avoid globals imports (describe, test, expect): */ 10 | // globals: true, 11 | }, 12 | }) 13 | 14 | -------------------------------------------------------------------------------- /doc/ADAPTER_SPEC.md: -------------------------------------------------------------------------------- 1 | # Adapter Specifications 2 | 3 | This document outlines the command specifications. 4 | 5 | # Commands 6 | 7 | These commands must be implemented by the adapter. 8 | 9 | - **discover**: Initiates the discovery process. 10 | - **run-file-test**: Executes tests on specified files. 11 | - **detect-workspace**: Identifies the workspace based on provided parameters. 12 | 13 | ## discover 14 | 15 | ### Arguments 16 | - `file_paths`: A list of file paths to be processed. 17 | 18 | ### Stdout 19 | Returns a JSON array of discovered items. Each item is a JSON object containing: 20 | - `path`: String representing the file path. 21 | - `tests`: Array of test items, where each test item is a JSON object including: 22 | - `id`: String identifier for the test. 23 | - `name`: String name of the test. 24 | - `start_position`: [Range](https://docs.rs/lsp-types/latest/lsp_types/struct.Range.html) indicating the start position of the test in the file. 25 | - `end_position`: [Range](https://docs.rs/lsp-types/latest/lsp_types/struct.Range.html) indicating the end position of the test in the file. 26 | 27 | ## run-file-test 28 | 29 | ### Arguments 30 | - `file_paths`: A list of file paths to be tested. 31 | - `workspace`: The workspace identifier where the tests will be executed. 32 | 33 | ### Stdout 34 | Returns a JSON array of test results. Each result is a JSON object containing: 35 | - `path`: String representing the file path. 36 | - `diagnostics`: Array of [Diagnostic](https://docs.rs/lsp-types/latest/lsp_types/struct.Diagnostic.html) objects. 37 | 38 | ## detect-workspace 39 | 40 | ### Arguments 41 | - `file_paths`: A list of file paths to identify the workspace. 42 | 43 | ### Stdout 44 | Returns a JSON object where: 45 | - Keys are strings representing workspace file paths. 46 | - Values are arrays of strings representing file paths associated with each workspace. 47 | 48 | # Note: All stdout must be valid JSON and should be parseable by standard JSON parsers. 49 | 50 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | watch-build: 2 | cargo watch -x 'build --workspace' 3 | 4 | test: 5 | cargo test --workspace 6 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum LSError { 7 | #[error("IO error")] 8 | IO(#[from] io::Error), 9 | 10 | #[error("Serialization error")] 11 | Serialization(#[from] serde_json::Error), 12 | 13 | #[error("Adapter error")] 14 | Adapter(String), 15 | 16 | #[error("UTF8 error")] 17 | UTF8(#[from] std::str::Utf8Error), 18 | 19 | #[error("From UTF8 error")] 20 | FromUTF8(#[from] std::string::FromUtf8Error), 21 | 22 | #[error("Unknown error")] 23 | Any(#[from] anyhow::Error), 24 | } 25 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | pub mod spec; 3 | pub mod util; 4 | -------------------------------------------------------------------------------- /src/log.rs: -------------------------------------------------------------------------------- 1 | use crate::util::clean_old_logs; 2 | use std::path::PathBuf; 3 | use tracing_appender::non_blocking::WorkerGuard; 4 | 5 | pub struct Log; 6 | 7 | impl Log { 8 | fn log_dir() -> PathBuf { 9 | let home_dir = dirs::home_dir().unwrap(); 10 | 11 | home_dir.join(".config/testing_language_server/logs") 12 | } 13 | 14 | pub fn init() -> Result { 15 | let log_dir_path = Self::log_dir(); 16 | let prefix = "server.log"; 17 | let file_appender = tracing_appender::rolling::daily(&log_dir_path, prefix); 18 | let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); 19 | clean_old_logs( 20 | log_dir_path.to_str().unwrap(), 21 | 30, 22 | &format!("{prefix}.*"), 23 | &format!("{prefix}."), 24 | ) 25 | .unwrap(); 26 | tracing_subscriber::fmt().with_writer(non_blocking).init(); 27 | Ok(guard) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod log; 3 | mod server; 4 | mod spec; 5 | mod util; 6 | 7 | use std::io::{self, BufRead, Read}; 8 | 9 | use error::LSError; 10 | use lsp_types::InitializeParams; 11 | use serde::de::Error; 12 | use serde::Deserialize; 13 | use serde_json::{json, Value}; 14 | use util::{format_uri, send_stdout}; 15 | 16 | use crate::log::Log; 17 | use crate::server::TestingLS; 18 | use crate::util::send_error; 19 | 20 | fn extract_textdocument_uri(params: &Value) -> Result { 21 | let uri = params["textDocument"]["uri"] 22 | .as_str() 23 | .ok_or(serde_json::Error::custom("`textDocument.uri` is not set"))?; 24 | Ok(format_uri(uri)) 25 | } 26 | 27 | fn extract_uri(params: &Value) -> Result { 28 | let uri = params["uri"] 29 | .as_str() 30 | .ok_or(serde_json::Error::custom("`uri` is not set"))?; 31 | Ok(format_uri(uri)) 32 | } 33 | 34 | fn main_loop(server: &mut TestingLS) -> Result<(), LSError> { 35 | let mut is_workspace_checked = false; 36 | loop { 37 | let mut size = 0; 38 | 'read_header: loop { 39 | let mut buffer = String::new(); 40 | let stdin = io::stdin(); 41 | let mut handle = stdin.lock(); 42 | handle.read_line(&mut buffer)?; 43 | 44 | if buffer.is_empty() { 45 | tracing::warn!("buffer is empty") 46 | } 47 | 48 | // The end of header section 49 | if buffer == "\r\n" { 50 | break 'read_header; 51 | } 52 | 53 | let split: Vec<&str> = buffer.split(' ').collect(); 54 | 55 | if split.len() != 2 { 56 | tracing::warn!("unexpected"); 57 | } 58 | 59 | let header_name = split[0].to_lowercase(); 60 | let header_value = split[1].trim(); 61 | 62 | match header_name.as_ref() { 63 | "content-length" => {} 64 | "content-type:" => {} 65 | _ => {} 66 | } 67 | 68 | size = header_value.parse::().unwrap(); 69 | } 70 | 71 | let stdin = io::stdin(); 72 | let mut handle = stdin.lock(); 73 | let mut buf = vec![0u8; size]; 74 | handle.read_exact(&mut buf).unwrap(); 75 | let message = String::from_utf8(buf).unwrap(); 76 | 77 | let received_json: Value = serde_json::from_str(&message)?; 78 | tracing::info!("received json={:#?}", received_json); 79 | let method = &received_json["method"].as_str(); 80 | let params = &received_json["params"]; 81 | 82 | if let Some(method) = method { 83 | match *method { 84 | "$/cancelRequest" => {} 85 | "initialized" => { 86 | is_workspace_checked = true; 87 | server.diagnose_workspace()?; 88 | } 89 | "initialize" => { 90 | let initialize_params = InitializeParams::deserialize(params)?; 91 | let id = received_json["id"].as_i64().unwrap(); 92 | server.initialize(id, initialize_params)?; 93 | } 94 | "shutdown" => { 95 | let id = received_json["id"].as_i64().unwrap(); 96 | server.shutdown(id)?; 97 | } 98 | "exit" => { 99 | std::process::exit(0); 100 | } 101 | "workspace/diagnostic" => { 102 | is_workspace_checked = true; 103 | server.diagnose_workspace()?; 104 | } 105 | "textDocument/diagnostic" | "textDocument/didSave" => { 106 | let uri = extract_textdocument_uri(params)?; 107 | server.check_file(&uri, false)?; 108 | } 109 | "textDocument/didOpen" => { 110 | if !is_workspace_checked { 111 | is_workspace_checked = true; 112 | server.diagnose_workspace()?; 113 | } 114 | let uri = extract_textdocument_uri(params)?; 115 | if server.refreshing_needed(&uri) { 116 | server.refresh_workspaces_cache()?; 117 | } 118 | } 119 | "$/runFileTest" => { 120 | let uri = extract_uri(params)?; 121 | server.check_file(&uri, false)?; 122 | } 123 | "$/runWorkspaceTest" => { 124 | server.diagnose_workspace()?; 125 | } 126 | "$/discoverFileTest" => { 127 | let id = received_json["id"].as_i64().unwrap(); 128 | let uri = extract_uri(params)?; 129 | let result = server.discover_file(&uri)?; 130 | send_stdout(&json!({ 131 | "jsonrpc": "2.0", 132 | "id": id, 133 | "result": result, 134 | }))?; 135 | } 136 | _ => { 137 | // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#responseMessage 138 | let id = received_json["id"].as_i64(); 139 | if id.is_some() { 140 | send_error( 141 | id, 142 | -32601, // Method not found 143 | format!("method not found: {}", method), 144 | )?; 145 | } 146 | } 147 | } 148 | } 149 | } 150 | } 151 | 152 | fn main() { 153 | let mut server = TestingLS::new(); 154 | let _guard = Log::init().expect("Failed to initialize logger"); 155 | if let Err(ls_error) = main_loop(&mut server) { 156 | tracing::error!("Error: {:?}", ls_error); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/spec.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use lsp_types::Diagnostic; 3 | use lsp_types::Range; 4 | use lsp_types::ShowMessageParams; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | use std::collections::HashMap; 8 | 9 | #[derive(Parser, Debug)] 10 | pub enum AdapterCommands { 11 | Discover(DiscoverArgs), 12 | RunFileTest(RunFileTestArgs), 13 | DetectWorkspace(DetectWorkspaceArgs), 14 | } 15 | 16 | /// Arguments for ` discover` command 17 | #[derive(clap::Args, Debug)] 18 | #[command(version, about, long_about = None)] 19 | pub struct DiscoverArgs { 20 | #[arg(short, long)] 21 | pub file_paths: Vec, 22 | #[arg(last = true)] 23 | pub extra: Vec, 24 | } 25 | 26 | /// Arguments for ` run-file-test` command 27 | #[derive(clap::Args, Debug)] 28 | #[command(version, about, long_about = None)] 29 | pub struct RunFileTestArgs { 30 | #[arg(short, long)] 31 | pub file_paths: Vec, 32 | 33 | #[arg(short, long)] 34 | pub workspace: String, 35 | 36 | #[arg(last = true)] 37 | pub extra: Vec, 38 | } 39 | 40 | /// Arguments for ` detect-workspace` command 41 | #[derive(clap::Args, Debug)] 42 | #[command(version, about, long_about = None)] 43 | pub struct DetectWorkspaceArgs { 44 | #[arg(short, long)] 45 | pub file_paths: Vec, 46 | #[arg(last = true)] 47 | pub extra: Vec, 48 | } 49 | 50 | pub type AdapterId = String; 51 | pub type FilePath = String; 52 | pub type WorkspaceFilePath = String; 53 | 54 | #[derive(Debug, Serialize, Clone)] 55 | pub struct WorkspaceAnalysis { 56 | pub adapter_config: AdapterConfiguration, 57 | pub workspaces: DetectWorkspaceResult, 58 | } 59 | 60 | impl WorkspaceAnalysis { 61 | pub fn new(adapter_config: AdapterConfiguration, workspaces: DetectWorkspaceResult) -> Self { 62 | Self { 63 | adapter_config, 64 | workspaces, 65 | } 66 | } 67 | } 68 | 69 | #[derive(Debug, Deserialize, Clone, Serialize, Default)] 70 | pub struct AdapterConfiguration { 71 | pub path: String, 72 | #[serde(default)] 73 | pub extra_arg: Vec, 74 | #[serde(default)] 75 | pub env: HashMap, 76 | pub include: Vec, 77 | pub exclude: Vec, 78 | pub workspace_dir: Option, 79 | } 80 | 81 | /// Result of ` detect-workspace` 82 | #[derive(Debug, Serialize, Clone, Deserialize)] 83 | pub struct DetectWorkspaceResult { 84 | pub data: HashMap>, 85 | } 86 | 87 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] 88 | pub struct FileDiagnostics { 89 | pub path: String, 90 | pub diagnostics: Vec, 91 | } 92 | 93 | /// Result of ` run-file-test` 94 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] 95 | pub struct RunFileTestResult { 96 | pub data: Vec, 97 | #[serde(default)] 98 | pub messages: Vec, 99 | } 100 | 101 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] 102 | pub struct TestItem { 103 | pub id: String, 104 | pub name: String, 105 | /// Although FoundFileTests also has a `path` field, we keep the `path` field in TestItem 106 | /// because sometimes we need to determine where a TestItem is located on its own 107 | /// Example: In Rust tests, determining which file contains a test from IDs like relative::path::tests::id 108 | /// TODO: Remove FoundFileTests.path once we confirm it's no longer needed 109 | pub path: String, 110 | pub start_position: Range, 111 | pub end_position: Range, 112 | } 113 | 114 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] 115 | pub struct FoundFileTests { 116 | pub path: String, 117 | pub tests: Vec, 118 | } 119 | 120 | /// Result of ` discover` 121 | #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] 122 | pub struct DiscoverResult { 123 | pub data: Vec, 124 | } 125 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::error::LSError; 2 | use chrono::NaiveDate; 3 | use chrono::Utc; 4 | use serde::Deserialize; 5 | use serde::Serialize; 6 | use serde_json::json; 7 | use serde_json::Number; 8 | use serde_json::Value; 9 | use std::fs; 10 | use std::io::stdout; 11 | use std::io::Write; 12 | use std::path::Path; 13 | use std::path::PathBuf; 14 | 15 | pub fn send_stdout(message: &T) -> Result<(), LSError> 16 | where 17 | T: ?Sized + Serialize + std::fmt::Debug, 18 | { 19 | tracing::info!("send stdout: {:#?}", message); 20 | let msg = serde_json::to_string(message)?; 21 | let mut stdout = stdout().lock(); 22 | write!(stdout, "Content-Length: {}\r\n\r\n{}", msg.len(), msg)?; 23 | stdout.flush()?; 24 | Ok(()) 25 | } 26 | 27 | #[derive(Debug, Serialize, Deserialize)] 28 | pub struct ErrorMessage { 29 | jsonrpc: String, 30 | id: Option, 31 | pub error: Value, 32 | } 33 | 34 | impl ErrorMessage { 35 | #[allow(dead_code)] 36 | pub fn new>(id: Option, error: Value) -> Self { 37 | Self { 38 | jsonrpc: "2.0".into(), 39 | id: id.map(|i| i.into()), 40 | error, 41 | } 42 | } 43 | } 44 | 45 | pub fn send_error>(id: Option, code: i64, msg: S) -> Result<(), LSError> { 46 | send_stdout(&ErrorMessage::new( 47 | id, 48 | json!({ "code": code, "message": msg.into() }), 49 | )) 50 | } 51 | 52 | pub fn format_uri(uri: &str) -> String { 53 | uri.replace("file://", "") 54 | } 55 | 56 | pub fn resolve_path(base_dir: &Path, relative_path: &str) -> PathBuf { 57 | let absolute = if Path::new(relative_path).is_absolute() { 58 | PathBuf::from(relative_path) 59 | } else { 60 | base_dir.join(relative_path) 61 | }; 62 | 63 | let mut components = Vec::new(); 64 | for component in absolute.components() { 65 | match component { 66 | std::path::Component::ParentDir => { 67 | components.pop(); 68 | } 69 | std::path::Component::Normal(_) | std::path::Component::RootDir => { 70 | components.push(component); 71 | } 72 | _ => {} 73 | } 74 | } 75 | 76 | PathBuf::from_iter(components) 77 | } 78 | 79 | pub fn clean_old_logs( 80 | log_dir: &str, 81 | retention_days: i64, 82 | glob_pattern: &str, 83 | prefix: &str, 84 | ) -> Result<(), LSError> { 85 | let today = Utc::now().date_naive(); 86 | let retention_threshold = today - chrono::Duration::days(retention_days); 87 | 88 | let walker = globwalk::GlobWalkerBuilder::from_patterns(log_dir, &[glob_pattern]) 89 | .build() 90 | .unwrap(); 91 | 92 | for entry in walker.filter_map(Result::ok) { 93 | let path = entry.path(); 94 | if let Some(file_name) = path.file_name().and_then(|f| f.to_str()) { 95 | if let Some(date_str) = file_name.strip_prefix(prefix) { 96 | if let Ok(file_date) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { 97 | if file_date < retention_threshold { 98 | fs::remove_file(path)?; 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | Ok(()) 106 | } 107 | 108 | #[cfg(test)] 109 | mod tests { 110 | use super::*; 111 | use std::fs::File; 112 | 113 | #[test] 114 | fn test_resolve_path() { 115 | let base_dir = PathBuf::from("/Users/test/projects"); 116 | 117 | // relative path 118 | assert_eq!( 119 | resolve_path(&base_dir, "github.com/hoge/fuga"), 120 | PathBuf::from("/Users/test/projects/github.com/hoge/fuga") 121 | ); 122 | 123 | // current directory 124 | assert_eq!( 125 | resolve_path(&base_dir, "./github.com/hoge/fuga"), 126 | PathBuf::from("/Users/test/projects/github.com/hoge/fuga") 127 | ); 128 | 129 | // parent directory 130 | assert_eq!( 131 | resolve_path(&base_dir, "../other/project"), 132 | PathBuf::from("/Users/test/other/project") 133 | ); 134 | 135 | // multiple .. 136 | assert_eq!( 137 | resolve_path(&base_dir, "foo/bar/../../../baz"), 138 | PathBuf::from("/Users/test/baz") 139 | ); 140 | 141 | // absolute path 142 | assert_eq!( 143 | resolve_path(&base_dir, "/absolute/path"), 144 | PathBuf::from("/absolute/path") 145 | ); 146 | 147 | // empty relative path 148 | assert_eq!( 149 | resolve_path(&base_dir, ""), 150 | PathBuf::from("/Users/test/projects") 151 | ); 152 | 153 | // ending / 154 | assert_eq!( 155 | resolve_path(&base_dir, "github.com/hoge/fuga/"), 156 | PathBuf::from("/Users/test/projects/github.com/hoge/fuga") 157 | ); 158 | 159 | // complex path 160 | assert_eq!( 161 | resolve_path(&base_dir, "./foo/../bar/./baz/../qux/"), 162 | PathBuf::from("/Users/test/projects/bar/qux") 163 | ); 164 | } 165 | 166 | #[test] 167 | fn test_clean_old_logs() { 168 | let home_dir = dirs::home_dir().unwrap(); 169 | let log_dir = home_dir.join(".config/testing_language_server/logs"); 170 | std::fs::create_dir_all(&log_dir).unwrap(); 171 | 172 | // Create test log files 173 | let old_file = log_dir.join("prefix.log.2023-01-01"); 174 | File::create(&old_file).unwrap(); 175 | let recent_file = log_dir.join("prefix.log.2099-12-31"); 176 | File::create(&recent_file).unwrap(); 177 | let non_log_file = log_dir.join("not_a_log.txt"); 178 | File::create(&non_log_file).unwrap(); 179 | 180 | // Run the clean_old_logs function 181 | clean_old_logs(log_dir.to_str().unwrap(), 30, "prefix.log.*", "prefix.log.").unwrap(); 182 | 183 | // Check results 184 | assert!(!old_file.exists(), "Old log file should be deleted"); 185 | assert!( 186 | recent_file.exists(), 187 | "Recent log file should not be deleted" 188 | ); 189 | assert!(non_log_file.exists(), "Non-log file should not be deleted"); 190 | } 191 | } 192 | --------------------------------------------------------------------------------