diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b2d65e..f3b3bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.12"] + python-version: ["3.13"] os: [ubuntu-latest] steps: diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 4d60977..f116800 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -29,7 +29,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" + python-version: "3.13" enable-cache: true - name: Check version @@ -69,7 +69,7 @@ jobs: - name: Install uv and set Python version uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" + python-version: "3.13" enable-cache: true - name: Build sdist and wheel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1977443..1a385d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" + python-version: "3.13" enable-cache: true - name: Check version @@ -69,7 +69,7 @@ jobs: - name: Install uv and set Python version uses: astral-sh/setup-uv@v7 with: - python-version: "3.12" + python-version: "3.13" enable-cache: true - name: Build sdist and wheel diff --git a/README.md b/README.md index 5869e93..4566f26 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ [![Python](https://img.shields.io/badge/Python-3.13+-blue.svg)](https://python.org) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +> **⚠️ Windows Temporary Not Supported** +> Due to implementation details, `lsp-cli` does not currently support Windows. Support for Windows will be added in the next version. + A powerful command-line interface for the [**Language Server Agent Protocol (LSAP)**](https://github.com/lsp-client/LSAP). `lsp-cli` provides a bridge between traditional [Language Server Protocol (LSP)](https://microsoft.github.io/language-server-protocol/) servers and high-level agentic workflows, offering structured data access and a robust background server management system. Built on top of [lsp-client](https://github.com/lsp-client/lsp-client) and [LSAP](https://github.com/lsp-client/LSAP), this tool is designed for developers and AI agents who need reliable, fast, and structured access to language intelligence. @@ -31,7 +34,7 @@ Built on top of [lsp-client](https://github.com/lsp-client/lsp-client) and [LSAP More supported languages coming very soon! ```bash -uv tool install lsp-cli +uv tool install --python 3.13 lsp-cli ``` ## Quick Start diff --git a/pyproject.toml b/pyproject.toml index 69c8332..f937385 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,8 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", ] dependencies = [ "anyio>=4.12.0", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..53588a1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Shared test fixtures and utilities for LSP CLI tests.""" + +import subprocess +from pathlib import Path + + +class BaseLSPTest: + """Base class for LSP CLI tests with common helper methods.""" + + def run_lsp_command(self, *args, timeout=30): + """Run an lsp command and return the result.""" + result = subprocess.run( + ["uv", "run", "lsp"] + list(args), + capture_output=True, + text=True, + timeout=timeout, + cwd=Path(__file__).parent.parent, + ) + return result diff --git a/tests/fixtures/.gitignore b/tests/fixtures/.gitignore new file mode 100644 index 0000000..2a304b0 --- /dev/null +++ b/tests/fixtures/.gitignore @@ -0,0 +1,10 @@ +# Build artifacts +node_modules/ +target/ +*.pyc +__pycache__/ + +# Lock files that might be generated +package-lock.json +yarn.lock +Cargo.lock diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..b1b872a --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,35 @@ +# Test Fixtures + +This directory contains minimal test projects for each supported language in LSP CLI. + +## Purpose + +These fixtures are used by the language support tests (`test_language_support.py`) to verify that LSP CLI can correctly: +- Detect and start language servers for each supported language +- Manage server lifecycle (start, list, stop) +- Handle multiple language servers simultaneously + +## Structure + +Each subdirectory contains a minimal but valid project for its respective language: + +- **go_project/**: Go project with `go.mod` and simple main package +- **rust_project/**: Rust project with `Cargo.toml` and src directory +- **typescript_project/**: TypeScript project with `package.json`, `tsconfig.json`, and TypeScript file +- **javascript_project/**: JavaScript project with `package.json` and ES module +- **deno_project/**: Deno project with `deno.json` configuration + +## Requirements + +For the tests to work, the following language servers must be installed: +- `basedpyright` (Python) +- `gopls` (Go) +- `rust-analyzer` (Rust) +- `typescript-language-server` (TypeScript/JavaScript) +- `deno` (Deno) + +However, the tests will skip or fail gracefully if the required language server is not installed or cannot be started for a project. + +## Maintenance + +These fixtures should remain minimal and focused. They exist only to verify basic LSP server integration, not to test language-specific features. diff --git a/tests/fixtures/deno_project/deno.json b/tests/fixtures/deno_project/deno.json new file mode 100644 index 0000000..18c6067 --- /dev/null +++ b/tests/fixtures/deno_project/deno.json @@ -0,0 +1,5 @@ +{ + "tasks": { + "run": "deno run main.ts" + } +} diff --git a/tests/fixtures/deno_project/main.ts b/tests/fixtures/deno_project/main.ts new file mode 100644 index 0000000..f61dd3f --- /dev/null +++ b/tests/fixtures/deno_project/main.ts @@ -0,0 +1,25 @@ +/** + * A simple greeter class + */ +export class Greeter { + private name: string; + + /** + * Creates a new Greeter instance + * @param name The name to greet + */ + constructor(name: string) { + this.name = name; + } + + /** + * Returns a greeting message + * @returns The greeting message + */ + greet(): string { + return `Hello, ${this.name}!`; + } +} + +const greeter = new Greeter("World"); +console.log(greeter.greet()); diff --git a/tests/fixtures/go_project/go.mod b/tests/fixtures/go_project/go.mod new file mode 100644 index 0000000..476c039 --- /dev/null +++ b/tests/fixtures/go_project/go.mod @@ -0,0 +1,3 @@ +module example.com/hello + +go 1.21 diff --git a/tests/fixtures/go_project/main.go b/tests/fixtures/go_project/main.go new file mode 100644 index 0000000..26ac558 --- /dev/null +++ b/tests/fixtures/go_project/main.go @@ -0,0 +1,23 @@ +package main + +import "fmt" + +// Greeter provides greeting functionality +type Greeter struct { + name string +} + +// NewGreeter creates a new Greeter instance +func NewGreeter(name string) *Greeter { + return &Greeter{name: name} +} + +// Greet returns a greeting message +func (g *Greeter) Greet() string { + return fmt.Sprintf("Hello, %s!", g.name) +} + +func main() { + greeter := NewGreeter("World") + fmt.Println(greeter.Greet()) +} diff --git a/tests/fixtures/javascript_project/index.js b/tests/fixtures/javascript_project/index.js new file mode 100644 index 0000000..382a243 --- /dev/null +++ b/tests/fixtures/javascript_project/index.js @@ -0,0 +1,23 @@ +/** + * A simple greeter class + */ +export class Greeter { + /** + * Creates a new Greeter instance + * @param {string} name - The name to greet + */ + constructor(name) { + this.name = name; + } + + /** + * Returns a greeting message + * @returns {string} The greeting message + */ + greet() { + return `Hello, ${this.name}!`; + } +} + +const greeter = new Greeter("World"); +console.log(greeter.greet()); diff --git a/tests/fixtures/javascript_project/package.json b/tests/fixtures/javascript_project/package.json new file mode 100644 index 0000000..fc6ba51 --- /dev/null +++ b/tests/fixtures/javascript_project/package.json @@ -0,0 +1,7 @@ +{ + "name": "hello-javascript", + "version": "1.0.0", + "description": "Test JavaScript project", + "main": "index.js", + "type": "module" +} diff --git a/tests/fixtures/rust_project/Cargo.toml b/tests/fixtures/rust_project/Cargo.toml new file mode 100644 index 0000000..fe61947 --- /dev/null +++ b/tests/fixtures/rust_project/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "hello" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/tests/fixtures/rust_project/src/main.rs b/tests/fixtures/rust_project/src/main.rs new file mode 100644 index 0000000..8345754 --- /dev/null +++ b/tests/fixtures/rust_project/src/main.rs @@ -0,0 +1,21 @@ +/// A simple greeter struct +pub struct Greeter { + name: String, +} + +impl Greeter { + /// Creates a new Greeter instance + pub fn new(name: String) -> Self { + Greeter { name } + } + + /// Returns a greeting message + pub fn greet(&self) -> String { + format!("Hello, {}!", self.name) + } +} + +fn main() { + let greeter = Greeter::new(String::from("World")); + println!("{}", greeter.greet()); +} diff --git a/tests/fixtures/typescript_project/index.ts b/tests/fixtures/typescript_project/index.ts new file mode 100644 index 0000000..f61dd3f --- /dev/null +++ b/tests/fixtures/typescript_project/index.ts @@ -0,0 +1,25 @@ +/** + * A simple greeter class + */ +export class Greeter { + private name: string; + + /** + * Creates a new Greeter instance + * @param name The name to greet + */ + constructor(name: string) { + this.name = name; + } + + /** + * Returns a greeting message + * @returns The greeting message + */ + greet(): string { + return `Hello, ${this.name}!`; + } +} + +const greeter = new Greeter("World"); +console.log(greeter.greet()); diff --git a/tests/fixtures/typescript_project/package.json b/tests/fixtures/typescript_project/package.json new file mode 100644 index 0000000..ad7d9e8 --- /dev/null +++ b/tests/fixtures/typescript_project/package.json @@ -0,0 +1,12 @@ +{ + "name": "hello-typescript", + "version": "1.0.0", + "description": "Test TypeScript project", + "main": "index.ts", + "scripts": { + "build": "tsc" + }, + "devDependencies": { + "typescript": "5.7.2" + } +} diff --git a/tests/fixtures/typescript_project/tsconfig.json b/tests/fixtures/typescript_project/tsconfig.json new file mode 100644 index 0000000..fc7d90c --- /dev/null +++ b/tests/fixtures/typescript_project/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + } +} diff --git a/tests/test_language_support.py b/tests/test_language_support.py new file mode 100644 index 0000000..85271b3 --- /dev/null +++ b/tests/test_language_support.py @@ -0,0 +1,295 @@ +""" +Test basic usability for each supported language. + +This module tests that LSP CLI works correctly with all supported languages: +- Python +- Go +- Rust +- TypeScript +- JavaScript +- Deno + +Each test verifies that the CLI can: +1. Start a language server for the project +2. List the running server +3. Stop the server cleanly +""" + +from pathlib import Path + +import pytest +from conftest import BaseLSPTest + + +@pytest.fixture(scope="module") +def fixtures_dir(): + """Return the path to the test fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +class TestLanguageSupport(BaseLSPTest): + """Test that each supported language works with LSP CLI.""" + + def test_python_support(self, fixtures_dir): + """Test basic LSP operations with Python project.""" + # Use the actual source code as a Python project + python_file = fixtures_dir.parent.parent / "src" / "lsp_cli" / "__init__.py" + assert python_file.exists(), "Python test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(python_file)) + assert result.returncode == 0, ( + f"Failed to start Python server: {result.stderr}" + ) + + # List servers - should show Python server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + assert "python" in result.stdout.lower(), "Python server not listed" + finally: + # Stop server + result = self.run_lsp_command("server", "stop", str(python_file)) + assert result.returncode == 0, ( + f"Failed to stop Python server: {result.stderr}" + ) + + def test_go_support(self, fixtures_dir): + """Test basic LSP operations with Go project.""" + go_file = fixtures_dir / "go_project" / "main.go" + assert go_file.exists(), "Go test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(go_file)) + assert result.returncode == 0, f"Failed to start Go server: {result.stderr}" + + # List servers - should show Go server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + assert "go" in result.stdout.lower(), "Go server not listed" + finally: + # Stop server + result = self.run_lsp_command("server", "stop", str(go_file)) + assert result.returncode == 0, f"Failed to stop Go server: {result.stderr}" + + def test_rust_support(self, fixtures_dir): + """Test basic LSP operations with Rust project.""" + rust_file = fixtures_dir / "rust_project" / "src" / "main.rs" + assert rust_file.exists(), "Rust test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(rust_file)) + assert result.returncode == 0, ( + f"Failed to start Rust server: {result.stderr}" + ) + + # List servers - should show Rust server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + assert "rust" in result.stdout.lower(), "Rust server not listed" + finally: + # Stop server + result = self.run_lsp_command("server", "stop", str(rust_file)) + assert result.returncode == 0, ( + f"Failed to stop Rust server: {result.stderr}" + ) + + def test_typescript_support(self, fixtures_dir): + """Test basic LSP operations with TypeScript project.""" + ts_file = fixtures_dir / "typescript_project" / "index.ts" + assert ts_file.exists(), "TypeScript test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(ts_file)) + assert result.returncode == 0, ( + f"Failed to start TypeScript server: {result.stderr}" + ) + + # List servers - should show TypeScript server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + # Note: TypeScript may be identified as "typescript" or abbreviated form + # We check for both to handle different language server implementations + stdout_lower = result.stdout.lower() + assert "typescript" in stdout_lower or "tsserver" in stdout_lower, ( + f"TypeScript server not listed. Output: {result.stdout}" + ) + finally: + # Stop server + result = self.run_lsp_command("server", "stop", str(ts_file)) + assert result.returncode == 0, ( + f"Failed to stop TypeScript server: {result.stderr}" + ) + + def test_javascript_support(self, fixtures_dir): + """Test basic LSP operations with JavaScript project.""" + js_file = fixtures_dir / "javascript_project" / "index.js" + assert js_file.exists(), "JavaScript test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(js_file)) + assert result.returncode == 0, ( + f"Failed to start JavaScript server: {result.stderr}" + ) + + # List servers - should show JavaScript server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + # Note: JavaScript may be identified as "javascript" or abbreviated form + # We check for both to handle different language server implementations + stdout_lower = result.stdout.lower() + assert "javascript" in stdout_lower or "jsserver" in stdout_lower, ( + f"JavaScript server not listed. Output: {result.stdout}" + ) + finally: + # Stop server + result = self.run_lsp_command("server", "stop", str(js_file)) + assert result.returncode == 0, ( + f"Failed to stop JavaScript server: {result.stderr}" + ) + + def test_deno_support(self, fixtures_dir): + """Test basic LSP operations with Deno project.""" + deno_file = fixtures_dir / "deno_project" / "main.ts" + assert deno_file.exists(), "Deno test file does not exist" + + try: + # Start server + result = self.run_lsp_command("server", "start", str(deno_file)) + assert result.returncode == 0, ( + f"Failed to start Deno server: {result.stderr}" + ) + + # List servers - should show Deno server + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + assert "deno" in result.stdout.lower(), "Deno server not listed" + finally: + # Always attempt to stop the server to avoid leaking processes + stop_result = self.run_lsp_command("server", "stop", str(deno_file)) + assert stop_result.returncode == 0, ( + f"Failed to stop Deno server: {stop_result.stderr}" + ) + + +class TestLanguageServerLifecycle(BaseLSPTest): + """Test language server lifecycle for all supported languages.""" + + def test_multiple_language_servers(self, fixtures_dir): + """Test running multiple language servers simultaneously.""" + # Start servers for different languages + python_file = fixtures_dir.parent.parent / "src" / "lsp_cli" / "__init__.py" + go_file = fixtures_dir / "go_project" / "main.go" + rust_file = fixtures_dir / "rust_project" / "src" / "main.rs" + + servers = [] + try: + if python_file.exists(): + result = self.run_lsp_command("server", "start", str(python_file)) + if result.returncode == 0: + servers.append(("python", python_file)) + + if go_file.exists(): + result = self.run_lsp_command("server", "start", str(go_file)) + if result.returncode == 0: + servers.append(("go", go_file)) + + if rust_file.exists(): + result = self.run_lsp_command("server", "start", str(rust_file)) + if result.returncode == 0: + servers.append(("rust", rust_file)) + + # List should show multiple servers + result = self.run_lsp_command("server", "list") + assert result.returncode == 0, f"Failed to list servers: {result.stderr}" + + # Verify each started server is listed + for lang, _ in servers: + assert lang in result.stdout.lower(), f"{lang} server not found in list" + finally: + # Stop all servers + for _, file_path in servers: + result = self.run_lsp_command("server", "stop", str(file_path)) + assert result.returncode == 0, f"Failed to stop server for {file_path}" + + def test_language_server_reuse(self, fixtures_dir): + """Test that starting a server twice reuses the same server.""" + python_file = fixtures_dir.parent.parent / "src" / "lsp_cli" / "__init__.py" + assert python_file.exists(), "Python test file does not exist" + + server_started = False + try: + # Start server first time + result1 = self.run_lsp_command("server", "start", str(python_file)) + assert result1.returncode == 0, ( + f"Failed to start server first time: {result1.stderr}" + ) + server_started = True + + # Get server list + list1 = self.run_lsp_command("server", "list") + assert list1.returncode == 0 + + # Start server second time (should reuse) + result2 = self.run_lsp_command("server", "start", str(python_file)) + assert result2.returncode == 0, ( + f"Failed to start server second time: {result2.stderr}" + ) + + # Get server list again + list2 = self.run_lsp_command("server", "list") + assert list2.returncode == 0 + + # Should have the same number of servers for this specific Python file + python_file_str = str(python_file) + python_servers1 = [ + line for line in list1.stdout.splitlines() if python_file_str in line + ] + python_servers2 = [ + line for line in list2.stdout.splitlines() if python_file_str in line + ] + assert len(python_servers1) == len(python_servers2), "Server was not reused" + finally: + # Cleanup + if server_started: + self.run_lsp_command("server", "stop", str(python_file)) + + +class TestLanguageServerErrors(BaseLSPTest): + """Test error handling for language servers.""" + + def test_invalid_file_path(self): + """Test that invalid file paths are handled gracefully.""" + invalid_file = Path("/nonexistent/path/file.py") + + # Invalid path should result in a non-zero exit code, not a successful run + result = self.run_lsp_command("server", "start", str(invalid_file)) + assert result.returncode != 0, ( + "Expected non-zero exit code for invalid file path.\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + def test_unsupported_language(self, fixtures_dir): + """Test that unsupported file types are handled gracefully.""" + # Create a temporary file with unsupported extension + unsupported_file = fixtures_dir / "test.unsupported" + unsupported_file.parent.mkdir(parents=True, exist_ok=True) + unsupported_file.write_text("test content") + + try: + # Should handle gracefully by returning a non-zero exit code + result = self.run_lsp_command("server", "start", str(unsupported_file)) + # Unsupported file types should not start a server successfully + assert result.returncode != 0, ( + f"Expected non-zero exit code for unsupported file type, " + f"got {result.returncode}. stdout: {result.stdout!r} stderr: {result.stderr!r}" + ) + finally: + # Cleanup + if unsupported_file.exists(): + unsupported_file.unlink()