mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 14:17:49 +00:00
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.
This commit is contained in:
1 parent
855818da63
commit
c01d9d06e8
7 files changed
+654
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 <filename>
|
||||
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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <test@example.com>"]
|
||||
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/*"]
|
||||
@@ -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"
|
||||
@@ -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)"
|
||||
Reference in new issue
Block a user