diff --git a/CHANGELOG.md b/CHANGELOG.md index 736b25d5..281dd84b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Status of the `main` branch. Changes prior to the next official version change w * **Add support for Lua** via lua-language-server * **Add support for Nix** requires nixd installation (Windows not supported) * **Add experimental support for YAML** via yaml-language-server with LSP integration for .yaml and .yml files + * **Add support for TOML** via Taplo language server with automatic binary download, validation, formatting, and schema support for .toml files * **Dart now officially supported**: Dart was always working, but now tests were added, and it is promoted to "officially supported" * **Rust now uses already installed rustup**: The rust-analyzer is no longer bundled with Serena. Instead, it uses the rust-analyzer from your Rust toolchain managed by rustup. This ensures compatibility with your Rust version and eliminates outdated bundled binaries. * **Kotlin now officially supported**: We now use the official Kotlin LS, tests run through and performance is good, even though the LS is in an early development stage. diff --git a/README.md b/README.md index 671aa0c5..e5905808 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ than existing solutions that charge a premium. Language servers provide support for a wide range of programming languages. With Serena's LSP library, we provide **support for over 30 programming languages**, including -AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TypeScript, YAML and Zig. +AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, Go, Haskell, Java, Javascript, Julia, Kotlin, Lua, Markdown, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, YAML, and Zig. > [!IMPORTANT] > Some languages require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details. diff --git a/pyproject.toml b/pyproject.toml index 76f12772..b68f07ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -303,6 +303,7 @@ markers = [ "yaml: language server running for YAML", "powershell: language server running for PowerShell", "slow: tests that require additional Expert instances and have long startup times (~60-90s each)", + "toml: language server running for TOML", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py new file mode 100644 index 00000000..dcc418ec --- /dev/null +++ b/src/solidlsp/language_servers/taplo_server.py @@ -0,0 +1,316 @@ +""" +Provides TOML specific instantiation of the LanguageServer class using Taplo. +Contains various configurations and settings specific to TOML files. +""" + +import gzip +import hashlib +import logging +import os +import platform +import shutil +import socket +import stat +import threading +import urllib.request +from typing import Any + +# Download timeout in seconds (prevents indefinite hangs) +DOWNLOAD_TIMEOUT_SECONDS = 120 + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + +# Taplo release version and download URLs +TAPLO_VERSION = "0.10.0" +TAPLO_DOWNLOAD_BASE = f"https://github.com/tamasfe/taplo/releases/download/{TAPLO_VERSION}" + +# SHA256 checksums for Taplo releases (verified from official GitHub releases) +# Source: https://github.com/tamasfe/taplo/releases/tag/0.10.0 +# To update: download each release file and run: sha256sum +TAPLO_SHA256_CHECKSUMS: dict[str, str] = { + "taplo-windows-x86_64.zip": "1615eed140039bd58e7089109883b1c434de5d6de8f64a993e6e8c80ca57bdf9", + "taplo-windows-x86.zip": "b825701daab10dcfc0251e6d668cd1a9c0e351e7f6762dd20844c3f3f3553aa0", + "taplo-darwin-x86_64.gz": "898122cde3a0b1cd1cbc2d52d3624f23338218c91b5ddb71518236a4c2c10ef2", + "taplo-darwin-aarch64.gz": "713734314c3e71894b9e77513c5349835eefbd52908445a0d73b0c7dc469347d", + "taplo-linux-x86_64.gz": "8fe196b894ccf9072f98d4e1013a180306e17d244830b03986ee5e8eabeb6156", + "taplo-linux-aarch64.gz": "033681d01eec8376c3fd38fa3703c79316f5e14bb013d859943b60a07bccdcc3", + "taplo-linux-armv7.gz": "6b728896afe2573522f38b8e668b1ff40eb5928fd9d6d0c253ecae508274d417", +} + + +def _verify_sha256(file_path: str, expected_hash: str) -> bool: + """Verify SHA256 checksum of a downloaded file.""" + sha256_hash = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256_hash.update(chunk) + actual_hash = sha256_hash.hexdigest() + return actual_hash.lower() == expected_hash.lower() + + +def _get_taplo_download_url() -> tuple[str, str]: + """ + Get the appropriate Taplo download URL for the current platform. + + Returns: + Tuple of (download_url, executable_name) + + """ + system = platform.system().lower() + machine = platform.machine().lower() + + # Map machine architecture to Taplo naming convention + arch_map = { + "x86_64": "x86_64", + "amd64": "x86_64", + "x86": "x86", + "i386": "x86", + "i686": "x86", + "aarch64": "aarch64", + "arm64": "aarch64", + "armv7l": "armv7", + } + + arch = arch_map.get(machine, "x86_64") # Default to x86_64 + + if system == "windows": + filename = f"taplo-windows-{arch}.zip" + executable = "taplo.exe" + elif system == "darwin": + filename = f"taplo-darwin-{arch}.gz" + executable = "taplo" + else: # Linux and others + filename = f"taplo-linux-{arch}.gz" + executable = "taplo" + + return f"{TAPLO_DOWNLOAD_BASE}/{filename}", executable + + +class TaploServer(SolidLanguageServer): + """ + Provides TOML specific instantiation of the LanguageServer class using Taplo. + Taplo is a TOML toolkit with LSP support for validation, formatting, and schema support. + """ + + @staticmethod + def _determine_log_level(line: str) -> int: + """Classify Taplo stderr output to avoid false-positive errors.""" + line_lower = line.lower() + + # Known informational messages from Taplo + if any( + [ + "schema" in line_lower and "not found" in line_lower, + "warning" in line_lower, + ] + ): + return logging.DEBUG + + return SolidLanguageServer._determine_log_level(line) + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TaploServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + taplo_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=f"{taplo_executable_path} lsp stdio", cwd=repository_root_path), + "toml", + solidlsp_settings, + ) + self.server_ready = threading.Event() + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for Taplo and return the command to start the server. + """ + # First check if taplo is already installed system-wide + system_taplo = shutil.which("taplo") + if system_taplo: + log.info(f"Using system-installed Taplo at: {system_taplo}") + return system_taplo + + # Setup local installation directory + taplo_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "taplo") + os.makedirs(taplo_dir, exist_ok=True) + + _, executable_name = _get_taplo_download_url() + taplo_executable = os.path.join(taplo_dir, executable_name) + + if os.path.exists(taplo_executable) and os.access(taplo_executable, os.X_OK): + log.info(f"Using cached Taplo at: {taplo_executable}") + return taplo_executable + + # Download and install Taplo + log.info(f"Taplo not found. Downloading version {TAPLO_VERSION}...") + cls._download_taplo(taplo_dir, taplo_executable) + + if not os.path.exists(taplo_executable): + raise FileNotFoundError( + f"Taplo executable not found at {taplo_executable}. " + "Installation may have failed. Try installing manually: cargo install taplo-cli --locked" + ) + + return taplo_executable + + @classmethod + def _download_taplo(cls, install_dir: str, executable_path: str) -> None: + """Download and extract Taplo binary with SHA256 verification.""" + # TODO: consider using existing download utilities in SolidLSP instead of the custom logic here + download_url, _ = _get_taplo_download_url() + archive_filename = os.path.basename(download_url) + + try: + log.info(f"Downloading Taplo from: {download_url}") + archive_path = os.path.join(install_dir, archive_filename) + + # Download the archive with timeout to prevent indefinite hangs + old_timeout = socket.getdefaulttimeout() + try: + socket.setdefaulttimeout(DOWNLOAD_TIMEOUT_SECONDS) + urllib.request.urlretrieve(download_url, archive_path) + finally: + socket.setdefaulttimeout(old_timeout) + + # Verify SHA256 checksum + expected_hash = TAPLO_SHA256_CHECKSUMS.get(archive_filename) + if expected_hash: + if not _verify_sha256(archive_path, expected_hash): + os.remove(archive_path) + raise RuntimeError( + f"SHA256 checksum verification failed for {archive_filename}. " + "The downloaded file may be corrupted or tampered with. " + "Try installing manually: cargo install taplo-cli --locked" + ) + log.info(f"SHA256 checksum verified for {archive_filename}") + else: + log.warning( + f"No SHA256 checksum available for {archive_filename}. " + "Skipping verification - consider installing manually: cargo install taplo-cli --locked" + ) + + # Extract based on format + if archive_path.endswith(".gz") and not archive_path.endswith(".tar.gz"): + # Single file gzip + with gzip.open(archive_path, "rb") as f_in: + with open(executable_path, "wb") as f_out: + f_out.write(f_in.read()) + elif archive_path.endswith(".zip"): + import zipfile + + with zipfile.ZipFile(archive_path, "r") as zip_ref: + # Security: Validate paths to prevent zip slip vulnerability + for member in zip_ref.namelist(): + member_path = os.path.normpath(os.path.join(install_dir, member)) + if not member_path.startswith(os.path.normpath(install_dir)): + raise RuntimeError(f"Zip slip detected: {member} attempts to escape install directory") + zip_ref.extractall(install_dir) + + # Make executable on Unix systems + if os.name != "nt": + os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + # Clean up archive + os.remove(archive_path) + log.info(f"Taplo installed successfully at: {executable_path}") + + except Exception as e: + log.error(f"Failed to download Taplo: {e}") + raise RuntimeError( + f"Failed to download Taplo from {download_url}. Try installing manually: cargo install taplo-cli --locked" + ) from e + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Taplo Language Server. + """ + root_uri = PathUtils.path_to_uri(repository_absolute_path) + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "codeAction": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore + + def _start_server(self) -> None: + """ + Starts the Taplo Language Server and initializes it. + """ + + def register_capability_handler(params: Any) -> None: + return + + def do_nothing(params: Any) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Taplo server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request to Taplo server") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Taplo: {init_response}") + + # Verify document symbol support + capabilities = init_response.get("capabilities", {}) + if capabilities.get("documentSymbolProvider"): + log.info("Taplo server supports document symbols") + else: + log.warning("Taplo server may have limited document symbol support") + + self.server.notify.initialized({}) + + log.info("Taplo server initialization complete") + self.server_ready.set() + self.completions_available.set() + + def is_ignored_dirname(self, dirname: str) -> bool: + """Define TOML-specific directories to ignore.""" + return super().is_ignored_dirname(dirname) or dirname in ["target", ".cargo", "node_modules"] diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 5015f462..3d491842 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -85,6 +85,10 @@ class Language(str, Enum): """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. """ + TOML = "toml" + """TOML language server using Taplo. + Supports TOML validation, formatting, and schema support. + """ @classmethod def iter_all(cls, include_experimental: bool = False) -> Iterable[Self]: @@ -95,8 +99,20 @@ class Language(str, Enum): def is_experimental(self) -> bool: """ Check if the language server is experimental or deprecated. + + Note for serena users/developers: + Experimental languages are not autodetected and must be explicitly specified + in the project.yml configuration. """ - return self in {self.TYPESCRIPT_VTS, self.PYTHON_JEDI, self.CSHARP_OMNISHARP, self.RUBY_SOLARGRAPH, self.MARKDOWN, self.YAML} + return self in { + self.TYPESCRIPT_VTS, + self.PYTHON_JEDI, + self.CSHARP_OMNISHARP, + self.RUBY_SOLARGRAPH, + self.MARKDOWN, + self.YAML, + self.TOML, + } def __str__(self) -> str: return self.value @@ -168,6 +184,8 @@ class Language(str, Enum): return FilenameMatcher("*.sh", "*.bash") case self.YAML: return FilenameMatcher("*.yaml", "*.yml") + case self.TOML: + return FilenameMatcher("*.toml") case self.ZIG: return FilenameMatcher("*.zig", "*.zon") case self.LUA: @@ -304,6 +322,10 @@ class Language(str, Enum): from solidlsp.language_servers.yaml_language_server import YamlLanguageServer return YamlLanguageServer + case self.TOML: + from solidlsp.language_servers.taplo_server import TaploServer + + return TaploServer case self.ZIG: from solidlsp.language_servers.zls import ZigLanguageServer diff --git a/test/resources/repos/toml/test_repo/Cargo.toml b/test/resources/repos/toml/test_repo/Cargo.toml new file mode 100644 index 00000000..a295cc0d --- /dev/null +++ b/test/resources/repos/toml/test_repo/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "test_project" +version = "0.1.0" +edition = "2021" +description = "A test TOML file for Serena TOML language support" +authors = ["Test Author "] +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } + +[dev-dependencies] +proptest = "1.0" + +[features] +default = ["feature1"] +feature1 = [] +feature2 = ["feature1"] + +[profile.release] +lto = true +opt-level = 3 + +[[bin]] +name = "main" +path = "src/main.rs" + +[workspace] +members = ["crates/*"] diff --git a/test/resources/repos/toml/test_repo/config.toml b/test/resources/repos/toml/test_repo/config.toml new file mode 100644 index 00000000..912d2dc7 --- /dev/null +++ b/test/resources/repos/toml/test_repo/config.toml @@ -0,0 +1,66 @@ +# Configuration file with various TOML features for testing + +[server] +host = "localhost" +port = 8080 +debug = false +timeout = 30.5 + +# Inline table example +endpoint = { url = "https://api.example.com", version = "v1" } + +[server.ssl] +enabled = true +cert_path = "/etc/ssl/cert.pem" +key_path = "/etc/ssl/key.pem" + +[database] +connection_string = """ + postgresql://user:password@localhost:5432/mydb? + sslmode=require& + pool_size=10 +""" + +[database.pool] +min_connections = 5 +max_connections = 20 +idle_timeout = 300 + +[logging] +level = "info" +format = "json" +outputs = ["stdout", "file"] + +[logging.file] +path = "/var/log/app.log" +max_size = 10485760 # 10MB +max_backups = 5 +compress = true + +# Array of inline tables +[[endpoints]] +name = "users" +path = "/api/users" +methods = ["GET", "POST", "PUT", "DELETE"] +auth_required = true + +[[endpoints]] +name = "health" +path = "/health" +methods = ["GET"] +auth_required = false + +# Datetime values +[metadata] +created = 1979-05-27T07:32:00Z +updated = 2024-01-15T10:30:00-05:00 + +# Special characters in strings +[messages] +welcome = "Hello, World!" +multiline = ''' +This is a +multiline literal +string. +''' +with_escapes = "Line1\nLine2\tTabbed" diff --git a/test/resources/repos/toml/test_repo/pyproject.toml b/test/resources/repos/toml/test_repo/pyproject.toml new file mode 100644 index 00000000..05028558 --- /dev/null +++ b/test/resources/repos/toml/test_repo/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "test-project" +version = "0.1.0" +description = "A test Python project for TOML support" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Test Author", email = "test@example.com"} +] +dependencies = [ + "pydantic>=2.0", + "httpx>=0.24", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.1", + "mypy>=1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.ruff] +line-length = 88 +target-version = "py311" +select = ["E", "F", "I", "N", "W", "B", "S"] + +[tool.ruff.isort] +known-first-party = ["test_project"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_unreachable = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-v --cov=src" diff --git a/test/solidlsp/toml/__init__.py b/test/solidlsp/toml/__init__.py new file mode 100644 index 00000000..00ae6163 --- /dev/null +++ b/test/solidlsp/toml/__init__.py @@ -0,0 +1 @@ +"""TOML language server tests.""" diff --git a/test/solidlsp/toml/test_toml_basic.py b/test/solidlsp/toml/test_toml_basic.py new file mode 100644 index 00000000..db4ddcf6 --- /dev/null +++ b/test/solidlsp/toml/test_toml_basic.py @@ -0,0 +1,235 @@ +""" +Basic integration tests for the TOML language server functionality. + +These tests validate the functionality of the Taplo language server APIs +like request_document_symbols using the TOML test repository. +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.toml +class TestTomlLanguageServerBasics: + """Test basic functionality of the TOML language server (Taplo).""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_language_server_initialization(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML language server can be initialized successfully.""" + assert language_server is not None + assert language_server.language == Language.TOML + assert language_server.is_running() + assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve() + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_cargo_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in Cargo.toml with specific symbol verification.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, f"Should find symbols in Cargo.toml, found {len(all_symbols)}" + + # Verify specific top-level table names are detected + symbol_names = [sym.get("name") for sym in all_symbols] + assert "package" in symbol_names, "Should detect 'package' table in Cargo.toml" + assert "dependencies" in symbol_names, "Should detect 'dependencies' table in Cargo.toml" + assert "dev-dependencies" in symbol_names, "Should detect 'dev-dependencies' table in Cargo.toml" + assert "features" in symbol_names, "Should detect 'features' table in Cargo.toml" + assert "workspace" in symbol_names, "Should detect 'workspace' table in Cargo.toml" + + # Verify nested symbols exist (keys under 'package') + assert "name" in symbol_names, "Should detect nested 'name' key" + assert "version" in symbol_names, "Should detect nested 'version' key" + assert "edition" in symbol_names, "Should detect nested 'edition' key" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert package_symbol.get("kind") == 19, "Top-level table should have kind 19 (object)" + + dependencies_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert dependencies_symbol is not None, "Should find 'dependencies' symbol" + assert dependencies_symbol.get("kind") == 19, "'dependencies' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_pyproject_file_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test document symbols detection in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for pyproject.toml" + assert len(all_symbols) > 0, f"Should find symbols in pyproject.toml, found {len(all_symbols)}" + + # Verify specific top-level table names + symbol_names = [sym.get("name") for sym in all_symbols] + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + # Verify tool sections (nested tables) + # These could appear as 'tool' or 'tool.ruff' depending on Taplo's parsing + has_tool_section = any("tool" in name for name in symbol_names if name) + assert has_tool_section, "Should detect tool sections" + + # Verify nested keys under project + assert "name" in symbol_names, "Should detect 'name' under project" + assert "version" in symbol_names, "Should detect 'version' under project" + assert "requires-python" in symbol_names or "dependencies" in symbol_names, "Should detect project dependencies" + + # Check symbol kind for tables - Taplo uses kind 19 (object) for TOML tables + project_symbol = next((s for s in all_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' symbol" + assert project_symbol.get("kind") == 19, "'project' table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_kinds(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that TOML symbols have appropriate LSP kinds for different value types.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check boolean symbol kind (lto = true at line 22) + # LSP kind 17 = boolean + lto_symbol = next((s for s in all_symbols if s.get("name") == "lto"), None) + assert lto_symbol is not None, "Should find 'lto' boolean symbol" + assert lto_symbol.get("kind") == 17, "'lto' should have kind 17 (boolean)" + + # Check number symbol kind (opt-level = 3 at line 23) + # LSP kind 16 = number + opt_level_symbol = next((s for s in all_symbols if s.get("name") == "opt-level"), None) + assert opt_level_symbol is not None, "Should find 'opt-level' number symbol" + assert opt_level_symbol.get("kind") == 16, "'opt-level' should have kind 16 (number)" + + # Check string symbol kind (name = "test_project" at line 2) + # LSP kind 15 = string + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # At least one should be a string + string_name_symbol = next((s for s in name_symbols if s.get("kind") == 15), None) + assert string_name_symbol is not None, "Should find 'name' with kind 15 (string)" + + # Check array symbol kind (default = ["feature1"] at line 17) + # LSP kind 18 = array + default_symbol = next((s for s in all_symbols if s.get("name") == "default"), None) + assert default_symbol is not None, "Should find 'default' array symbol" + assert default_symbol.get("kind") == 18, "'default' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbols_with_body(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_symbols with body extraction.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None, "Should return symbols for Cargo.toml" + assert len(all_symbols) > 0, "Should have symbols" + + # Find the 'package' symbol and verify its body + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + + # Check that body exists and contains expected content + # Note: Taplo includes the section header in the body + assert "body" in package_symbol, "'package' symbol should have body" + package_body = package_symbol["body"] + assert 'name = "test_project"' in package_body, "Body should contain 'name' field" + assert 'version = "0.1.0"' in package_body, "Body should contain 'version' field" + assert 'edition = "2021"' in package_body, "Body should contain 'edition' field" + + # Find the dependencies symbol and check its body + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + assert "body" in deps_symbol, "'dependencies' symbol should have body" + deps_body = deps_symbol["body"] + assert "serde" in deps_body, "Body should contain serde dependency" + assert "tokio" in deps_body, "Body should contain tokio dependency" + + # Find the top-level [features] section (not the nested 'features' in serde dependency) + # The [features] section should be kind 19 (object) and at line 15 (0-indexed) + features_symbols = [s for s in all_symbols if s.get("name") == "features"] + # Find the top-level one - should be kind 19 (object) with children + features_symbol = next( + (s for s in features_symbols if s.get("kind") == 19 and s.get("children")), + None, + ) + assert features_symbol is not None, "Should find top-level 'features' table symbol" + assert "body" in features_symbol, "'features' symbol should have body" + features_body = features_symbol["body"] + assert "default" in features_body, "Body should contain 'default' feature" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_symbol_ranges(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbols have proper range information.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + # Check the 'package' symbol range - should start at line 0 (0-indexed, actual line 1) + package_symbol = next((s for s in all_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' symbol" + assert "range" in package_symbol, "'package' symbol should have range" + + package_range = package_symbol["range"] + assert "start" in package_range, "Range should have start" + assert "end" in package_range, "Range should have end" + assert package_range["start"]["line"] == 0, "'package' should start at line 0 (0-indexed, actual line 1)" + # Package block spans from line 1 to line 7 in file (1-indexed) + # In 0-indexed LSP coordinates: line 0 (start) to line 6 or 7 (end) + assert package_range["end"]["line"] >= 6, "'package' should end at or after line 6 (0-indexed)" + + # Check a nested symbol range - 'name' under package at line 2 (1-indexed), line 1 (0-indexed) + name_symbols = [s for s in all_symbols if s.get("name") == "name"] + assert len(name_symbols) > 0, "Should find 'name' symbols" + # Find the one under 'package' (should be at line 1 in 0-indexed) + package_name = next((s for s in name_symbols if s["range"]["start"]["line"] == 1), None) + assert package_name is not None, "Should find 'name' under 'package'" + + # Check the dependencies range - starts at line 9 (1-indexed), line 8 (0-indexed) + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None, "Should find 'dependencies' symbol" + deps_range = deps_symbol["range"] + assert deps_range["start"]["line"] == 8, "'dependencies' should start at line 8 (0-indexed, actual line 9)" + + # Check that range includes line and character positions + assert "line" in package_range["start"], "Start should have line" + assert "character" in package_range["start"], "Start should have character" + assert "line" in package_range["end"], "End should have line" + assert "character" in package_range["end"], "End should have character" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_toml_nested_table_symbols(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of nested table symbols like profile.release and tool.ruff.""" + # Test Cargo.toml for profile.release + cargo_symbols, _ = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + assert cargo_symbols is not None + symbol_names = [sym.get("name") for sym in cargo_symbols] + + # Should detect profile.release or profile section + has_profile = any("profile" in name for name in symbol_names if name) + assert has_profile, "Should detect profile section in Cargo.toml" + + # Test pyproject.toml for tool sections + pyproject_symbols, _ = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + assert pyproject_symbols is not None + pyproject_names = [sym.get("name") for sym in pyproject_symbols] + + # Should detect tool.ruff, tool.mypy sections + has_ruff = any("ruff" in name for name in pyproject_names if name) + has_mypy = any("mypy" in name for name in pyproject_names if name) + assert has_ruff or has_mypy, "Should detect tool sections in pyproject.toml" + + # Verify pyproject has expected boolean: strict = true + strict_symbol = next((s for s in pyproject_symbols if s.get("name") == "strict"), None) + if strict_symbol: + assert strict_symbol.get("kind") == 17, "'strict' should have kind 17 (boolean)" diff --git a/test/solidlsp/toml/test_toml_edge_cases.py b/test/solidlsp/toml/test_toml_edge_cases.py new file mode 100644 index 00000000..3f651682 --- /dev/null +++ b/test/solidlsp/toml/test_toml_edge_cases.py @@ -0,0 +1,273 @@ +""" +Tests for TOML language server edge cases and advanced features. + +These tests cover: +- Inline tables +- Multiline strings +- Arrays of tables +- Nested tables +- Various TOML data types +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlEdgeCases: + """Test TOML language server handling of edge cases and advanced features.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_inline_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that inline tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + assert all_symbols is not None + assert len(all_symbols) > 0 + + symbol_names = [sym.get("name") for sym in all_symbols] + + # The inline table 'endpoint' should be detected + assert "endpoint" in symbol_names, "Should detect 'endpoint' inline table" + + # Find the endpoint symbol and check its properties + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + # Inline tables should be kind 19 (object) + assert endpoint_symbol.get("kind") == 19, "Inline table should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_nested_table_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that deeply nested tables are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect nested tables like server.ssl and database.pool + has_ssl = any("ssl" in str(name).lower() for name in symbol_names if name) + has_pool = any("pool" in str(name).lower() for name in symbol_names if name) + + assert has_ssl, f"Should detect 'server.ssl' nested table, got: {symbol_names}" + assert has_pool, f"Should detect 'database.pool' nested table, got: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[array_of_tables]] syntax is properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect [[endpoints]] array of tables + assert "endpoints" in symbol_names, f"Should detect '[[endpoints]]' array of tables, got: {symbol_names}" + + # Find the endpoints symbol + endpoints_symbol = next((s for s in all_symbols if s.get("name") == "endpoints"), None) + assert endpoints_symbol is not None + + # Array of tables should be kind 18 (array) + assert endpoints_symbol.get("kind") == 18, "Array of tables should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_multiline_string_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that multiline strings are handled correctly.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect connection_string and multiline fields + assert "connection_string" in symbol_names, "Should detect 'connection_string' with multiline value" + assert "multiline" in symbol_names, "Should detect 'multiline' literal string" + + # Find connection_string and verify it's a string type + conn_symbol = next((s for s in all_symbols if s.get("name") == "connection_string"), None) + assert conn_symbol is not None + # String type should be kind 15 + assert conn_symbol.get("kind") == 15, "Multiline string should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that array values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'outputs' and 'methods' arrays + assert "outputs" in symbol_names, "Should detect 'outputs' array" + assert "methods" in symbol_names, "Should detect 'methods' array" + + # Find outputs array and verify kind + outputs_symbol = next((s for s in all_symbols if s.get("name") == "outputs"), None) + assert outputs_symbol is not None + # Arrays should have kind 18 + assert outputs_symbol.get("kind") == 18, "'outputs' should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_float_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that float values are properly detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect 'timeout' which has a float value (30.5) + assert "timeout" in symbol_names, "Should detect 'timeout' float value" + + # Find timeout and verify it's a number + timeout_symbol = next((s for s in all_symbols if s.get("name") == "timeout"), None) + assert timeout_symbol is not None + # Numbers should have kind 16 + assert timeout_symbol.get("kind") == 16, "'timeout' should have kind 16 (number)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_datetime_value_detection(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that datetime values are detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect metadata section with datetime values + assert "metadata" in symbol_names, "Should detect 'metadata' section" + assert "created" in symbol_names, "Should detect 'created' datetime field" + assert "updated" in symbol_names, "Should detect 'updated' datetime field" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_body_with_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol bodies include inline table content.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the endpoint symbol with body + endpoint_symbol = next((s for s in all_symbols if s.get("name") == "endpoint"), None) + assert endpoint_symbol is not None + + if "body" in endpoint_symbol: + body = endpoint_symbol["body"] + # Body should contain the inline table syntax + assert "url" in body or "version" in body, f"Body should contain inline table contents, got: {body}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_ranges_in_config(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol ranges are correct in config.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + # Find the server symbol + server_symbol = next((s for s in all_symbols if s.get("name") == "server"), None) + assert server_symbol is not None + assert "range" in server_symbol + + # Server should start near the beginning (line 2 is [server], 0-indexed: line 2) + server_range = server_symbol["range"] + assert server_range["start"]["line"] >= 0, "Server should start at or near the beginning" + assert server_range["end"]["line"] > server_range["start"]["line"], "Server block should span multiple lines" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_comment_handling(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that comments don't interfere with symbol detection.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # File has comments but symbols should still be detected correctly + expected_sections = {"server", "database", "logging", "endpoints", "metadata", "messages"} + found_sections = expected_sections.intersection(set(symbol_names)) + + assert len(found_sections) >= 4, f"Should find most sections despite comments, found: {found_sections}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_special_characters_in_strings(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that strings with escape sequences are handled.""" + all_symbols, root_symbols = language_server.request_document_symbols("config.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect the messages section with special strings + assert "messages" in symbol_names, "Should detect 'messages' section" + assert "with_escapes" in symbol_names, "Should detect 'with_escapes' field" + assert "welcome" in symbol_names, "Should detect 'welcome' field" + + +class TestTomlDependencyTables: + """Test handling of dependency-style tables common in Cargo.toml and pyproject.toml.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_complex_dependency_inline_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of complex inline table dependencies like serde = { version = "1.0", features = ["derive"] }.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect serde and tokio dependencies + assert "serde" in symbol_names, "Should detect 'serde' dependency" + assert "tokio" in symbol_names, "Should detect 'tokio' dependency" + + # Find serde symbol + serde_symbol = next((s for s in all_symbols if s.get("name") == "serde"), None) + assert serde_symbol is not None + + # Dependency with inline table should be kind 19 (object) + assert serde_symbol.get("kind") == 19, "Complex dependency should have kind 19 (object)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_simple_dependency_string(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of simple string dependencies like proptest = "1.0".""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect proptest dev-dependency + assert "proptest" in symbol_names, "Should detect 'proptest' dependency" + + # Find proptest symbol + proptest_symbol = next((s for s in all_symbols if s.get("name") == "proptest"), None) + assert proptest_symbol is not None + + # Simple string dependency should be kind 15 (string) + assert proptest_symbol.get("kind") == 15, "Simple string dependency should have kind 15 (string)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_pyproject_dependencies_array(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of pyproject.toml dependencies array.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect dependencies array + assert "dependencies" in symbol_names, "Should detect 'dependencies' array" + + # Find dependencies symbol + deps_symbol = next((s for s in all_symbols if s.get("name") == "dependencies"), None) + assert deps_symbol is not None + + # Dependencies array should be kind 18 (array) + assert deps_symbol.get("kind") == 18, "Dependencies array should have kind 18 (array)" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_optional_dependencies_table(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test detection of optional-dependencies in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + symbol_names = [sym.get("name") for sym in all_symbols] + + # Should detect optional-dependencies or its nested form + has_optional_deps = any("optional" in str(name).lower() for name in symbol_names if name) + has_dev = "dev" in symbol_names + + assert has_optional_deps or has_dev, f"Should detect optional-dependencies or dev group, got: {symbol_names}" diff --git a/test/solidlsp/toml/test_toml_ignored_dirs.py b/test/solidlsp/toml/test_toml_ignored_dirs.py new file mode 100644 index 00000000..e5126942 --- /dev/null +++ b/test/solidlsp/toml/test_toml_ignored_dirs.py @@ -0,0 +1,60 @@ +""" +Tests for TOML language server directory ignoring functionality. + +These tests validate that the Taplo language server correctly ignores +TOML-specific directories like target, .cargo, and node_modules. +""" + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +@pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) +class TestTomlIgnoredDirectories: + """Test TOML-specific directory ignoring behavior.""" + + def test_default_ignored_directories(self, language_server: SolidLanguageServer) -> None: + """Test that default TOML directories are ignored.""" + # Test that TOML/Rust/Node-specific directories are ignored by default + assert language_server.is_ignored_dirname("target"), "target should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" + + # Directories starting with . are ignored by base class + assert language_server.is_ignored_dirname(".git"), ".git should be ignored" + assert language_server.is_ignored_dirname(".venv"), ".venv should be ignored" + + def test_important_directories_not_ignored(self, language_server: SolidLanguageServer) -> None: + """Test that important directories are not ignored.""" + # Common project directories should not be ignored + assert not language_server.is_ignored_dirname("src"), "src should not be ignored" + assert not language_server.is_ignored_dirname("crates"), "crates should not be ignored" + assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" + assert not language_server.is_ignored_dirname("tests"), "tests should not be ignored" + assert not language_server.is_ignored_dirname("config"), "config should not be ignored" + + def test_cargo_related_directories(self, language_server: SolidLanguageServer) -> None: + """Test Cargo/Rust-related directory handling.""" + # Rust build directories should be ignored + assert language_server.is_ignored_dirname("target"), "target (Rust build) should be ignored" + assert language_server.is_ignored_dirname(".cargo"), ".cargo should be ignored" + + # But important Rust directories should not be ignored + assert not language_server.is_ignored_dirname("benches"), "benches should not be ignored" + assert not language_server.is_ignored_dirname("examples"), "examples should not be ignored" + + def test_various_cache_directories(self, language_server: SolidLanguageServer) -> None: + """Test various cache and temporary directories are ignored.""" + # Directories starting with . are ignored by base class + assert language_server.is_ignored_dirname(".cache"), ".cache should be ignored" + + # IDE directories (start with .) + assert language_server.is_ignored_dirname(".idea"), ".idea should be ignored" + assert language_server.is_ignored_dirname(".vscode"), ".vscode should be ignored" + + # Note: __pycache__ is NOT ignored by TOML server (only Python servers ignore it) + assert not language_server.is_ignored_dirname("__pycache__"), "__pycache__ is not TOML-specific" diff --git a/test/solidlsp/toml/test_toml_symbol_retrieval.py b/test/solidlsp/toml/test_toml_symbol_retrieval.py new file mode 100644 index 00000000..9458c51a --- /dev/null +++ b/test/solidlsp/toml/test_toml_symbol_retrieval.py @@ -0,0 +1,191 @@ +""" +Tests for TOML language server symbol retrieval functionality. + +These tests focus on advanced symbol operations: +- request_containing_symbol +- request_document_overview +- request_full_symbol_tree +- request_dir_overview +""" + +from pathlib import Path + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.toml + + +class TestTomlSymbolRetrieval: + """Test advanced symbol retrieval functionality for TOML files.""" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_containing_symbol_behavior(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_containing_symbol behavior for TOML files. + + Note: Taplo LSP doesn't support definition/containing symbol lookups for TOML files + since TOML is a configuration format, not code. This test verifies the behavior. + """ + # Line 2 (0-indexed: 1) is inside the [package] table + containing_symbol = language_server.request_containing_symbol("Cargo.toml", 1, 5) + + # Taplo doesn't support containing symbol lookup - returns None + # This is expected behavior for a configuration file format + assert containing_symbol is None, "TOML LSP doesn't support containing symbol lookup" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for Cargo.toml.""" + overview = language_server.request_document_overview("Cargo.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + expected_tables = {"package", "dependencies", "dev-dependencies", "features", "workspace"} + assert expected_tables.issubset(symbol_names), f"Missing expected tables in overview: {expected_tables - symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_document_overview_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_document_overview for pyproject.toml.""" + overview = language_server.request_document_overview("pyproject.toml") + + assert overview is not None + assert len(overview) > 0 + + # Get symbol names from overview + symbol_names = {symbol.get("name") for symbol in overview if "name" in symbol} + + # Verify expected top-level tables appear + assert "project" in symbol_names, "Should detect 'project' table" + assert "build-system" in symbol_names, "Should detect 'build-system' table" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_full_symbol_tree(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_full_symbol_tree returns TOML files.""" + symbol_tree = language_server.request_full_symbol_tree() + + assert symbol_tree is not None + assert len(symbol_tree) > 0 + + # The root should be test_repo + root = symbol_tree[0] + assert root["name"] == "test_repo" + assert "children" in root + + # Children should include TOML files + child_names = {child["name"] for child in root.get("children", [])} + # Note: File names are stripped of extension in some cases + assert ( + "Cargo" in child_names or "Cargo.toml" in child_names or any("cargo" in name.lower() for name in child_names) + ), f"Should find Cargo.toml in tree, got: {child_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_request_dir_overview(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test request_dir_overview returns symbols for TOML files.""" + overview = language_server.request_dir_overview(".") + + assert overview is not None + assert len(overview) > 0 + + # Should have entries for both Cargo.toml and pyproject.toml + file_paths = list(overview.keys()) + assert any("Cargo.toml" in path for path in file_paths), f"Should find Cargo.toml in overview, got: {file_paths}" + assert any("pyproject.toml" in path for path in file_paths), f"Should find pyproject.toml in overview, got: {file_paths}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_cargo(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in Cargo.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Find the 'package' table + package_symbol = next((s for s in root_symbols if s.get("name") == "package"), None) + assert package_symbol is not None, "Should find 'package' as root symbol" + + # Verify it has children (nested keys) + assert "children" in package_symbol, "'package' should have children" + child_names = {child.get("name") for child in package_symbol.get("children", [])} + + # Package should have name, version, edition at minimum + assert "name" in child_names, "'package' should have 'name' child" + assert "version" in child_names, "'package' should have 'version' child" + assert "edition" in child_names, "'package' should have 'edition' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_symbol_hierarchy_in_pyproject(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that symbol hierarchy is properly preserved in pyproject.toml.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Find the 'project' table + project_symbol = next((s for s in root_symbols if s.get("name") == "project"), None) + assert project_symbol is not None, "Should find 'project' as root symbol" + + # Verify it has children + assert "children" in project_symbol, "'project' should have children" + child_names = {child.get("name") for child in project_symbol.get("children", [])} + + # Project should have name, version, dependencies at minimum + assert "name" in child_names, "'project' should have 'name' child" + assert "version" in child_names, "'project' should have 'version' child" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_tool_section_hierarchy(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that tool sections in pyproject.toml are properly structured.""" + all_symbols, root_symbols = language_server.request_document_symbols("pyproject.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect tool.ruff, tool.mypy, or tool.pytest + has_ruff = any("ruff" in name.lower() for name in all_names if name) + has_mypy = any("mypy" in name.lower() for name in all_names if name) + has_pytest = any("pytest" in name.lower() for name in all_names if name) + + assert has_ruff or has_mypy or has_pytest, f"Should detect tool sections, got names: {all_names}" + + @pytest.mark.parametrize("language_server", [Language.TOML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.TOML], indirect=True) + def test_array_of_tables_symbol(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that [[bin]] array of tables is detected.""" + all_symbols, root_symbols = language_server.request_document_symbols("Cargo.toml").get_all_symbols_and_roots() + + # Get all symbol names + all_names = [s.get("name") for s in all_symbols] + + # Should detect bin array of tables + has_bin = "bin" in all_names + assert has_bin, f"Should detect [[bin]] array of tables, got names: {all_names}" + + # Find the bin symbol and verify its structure + bin_symbol = next((s for s in all_symbols if s.get("name") == "bin"), None) + assert bin_symbol is not None, "Should find bin symbol" + + # Array of tables should be kind 18 (array) + assert bin_symbol.get("kind") == 18, "[[bin]] should have kind 18 (array)" + + # Children of array of tables are indexed by position ('0', '1', etc.) + if "children" in bin_symbol: + bin_children = bin_symbol.get("children", []) + assert len(bin_children) > 0, "[[bin]] should have at least one child element" + # First child is index '0' + first_child = bin_children[0] + assert first_child.get("name") == "0", f"First array element should be named '0', got: {first_child.get('name')}" + + # The '0' element should contain name and path as grandchildren + if "children" in first_child: + grandchild_names = {gc.get("name") for gc in first_child.get("children", [])} + assert "name" in grandchild_names, f"[[bin]] element should have 'name' field, got: {grandchild_names}" + assert "path" in grandchild_names, f"[[bin]] element should have 'path' field, got: {grandchild_names}"