From c01d9d06e8c1a7823fd880cc570af574e5978b9b Mon Sep 17 00:00:00 2001 From: w0wl0lxd <199849635+w0wl0lxd@users.noreply.github.com> Date: Mon, 1 Dec 2025 00:22:33 -0600 Subject: [PATCH] feat: Add TOML language support via Taplo Add TOML language server using Taplo with automatic binary download for .toml file validation, formatting, and schema support. Security: - SHA256 checksum verification for downloaded binaries - Zip slip vulnerability protection for archive extraction - Download timeout (120s) to prevent indefinite hangs Fixes #796: serena project index fails on Rust projects when project.yml contains languages: ["toml"]. Without TOML in the Language enum, loading such configurations raises ValueError: 'toml' is not a valid Language. This commonly occurs in Rust projects where users want to index Cargo.toml alongside .rs files. --- CHANGELOG.md | 1 + pyproject.toml | 1 + src/solidlsp/language_servers/taplo_server.py | 330 ++++++++++++++++++ src/solidlsp/ls_config.py | 19 +- .../resources/repos/toml/test_repo/Cargo.toml | 30 ++ .../repos/toml/test_repo/pyproject.toml | 43 +++ test/solidlsp/toml/test_toml_basic.py | 231 ++++++++++++ 7 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/taplo_server.py create mode 100644 test/resources/repos/toml/test_repo/Cargo.toml create mode 100644 test/resources/repos/toml/test_repo/pyproject.toml create mode 100644 test/solidlsp/toml/test_toml_basic.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d8137826..254c723a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,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/pyproject.toml b/pyproject.toml index 618d1b3f..a14c61c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -299,6 +299,7 @@ markers = [ "fortran: language server running for Fortran", "haskell: Haskell language server tests", "yaml: language server running for YAML", + "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..92966cd1 --- /dev/null +++ b/src/solidlsp/language_servers/taplo_server.py @@ -0,0 +1,330 @@ +""" +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.""" + 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}, + "formatting": {"dynamicRegistration": True}, + "semanticTokens": {"dynamicRegistration": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + "configuration": { + "evenBetterToml": { + "schema": { + "enabled": True, + "repositoryEnabled": True, + "repositoryUrl": "https://taplo.tamasfe.dev/schema_index.json", + }, + "formatter": {"alignEntries": False, "alignComments": True, "arrayTrailingComma": True}, + } + } + }, + } + 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 160205e1..26f1b481 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -82,6 +82,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]: @@ -93,7 +97,14 @@ class Language(str, Enum): """ Check if the language server is experimental or deprecated. """ - 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, + } def __str__(self) -> str: return self.value @@ -148,6 +159,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: @@ -269,6 +282,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/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/test_toml_basic.py b/test/solidlsp/toml/test_toml_basic.py new file mode 100644 index 00000000..ec37f771 --- /dev/null +++ b/test/solidlsp/toml/test_toml_basic.py @@ -0,0 +1,231 @@ +""" +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 - should be kind 2 (module/namespace) + 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") == 2, "Top-level table should have kind 2 (module/namespace)" + + 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") == 2, "'dependencies' table should have kind 2 (module/namespace)" + + @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 + 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") == 2, "'project' table should have kind 2 (module/namespace)" + + @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 + assert "body" in package_symbol, "'package' symbol should have body" + package_body = package_symbol["body"] + assert "[package]" in package_body, "Body should contain '[package]'" + 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 "[dependencies]" in deps_body, "Body should contain '[dependencies]'" + assert "serde" in deps_body, "Body should contain serde dependency" + assert "tokio" in deps_body, "Body should contain tokio dependency" + + # Find the features symbol and check its body + features_symbol = next((s for s in all_symbols if s.get("name") == "features"), None) + assert features_symbol is not None, "Should find 'features' symbol" + assert "body" in features_symbol, "'features' symbol should have body" + features_body = features_symbol["body"] + assert "[features]" in features_body, "Body should contain '[features]'" + 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)"