diff --git a/pyproject.toml b/pyproject.toml index 587bbf2cc..fe456bc59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -326,6 +326,7 @@ markers = [ "toml: language server running for TOML", "matlab: language server running for MATLAB (requires MATLAB R2021b+)", "systemverilog: language server running for SystemVerilog (uses verible-verilog-ls)", + "nim: language server running for Nim", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/nim_language_server.py b/src/solidlsp/language_servers/nim_language_server.py new file mode 100644 index 000000000..0f4d63abe --- /dev/null +++ b/src/solidlsp/language_servers/nim_language_server.py @@ -0,0 +1,1056 @@ +""" +Provides Nim specific instantiation of the LanguageServer class using nimlangserver. +Contains various configurations and settings specific to Nim language. +""" + +import copy +import json +import logging +import os +import pathlib +import shutil +import threading +import time +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar + +from overrides import override + +from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ReferenceInSymbol, SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_process import LanguageServerProcess +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +if TYPE_CHECKING: + from collections.abc import Hashable + + from solidlsp.ls import DocumentSymbols, LSPFileBuffer + from solidlsp.ls_types import Hover + from solidlsp.lsp_protocol_handler.lsp_types import ( + Definition, + DefinitionParams, + DocumentSymbol, + Location, + LocationLink, + SymbolInformation, + ) + +log = logging.getLogger(__name__) + +ENCODING = "utf-8" + +# Default nimsuggest.cfg content — helps nimsuggest operate reliably with the language server. +# Only written when the project does not already have a nimsuggest.cfg. +_NIMSUGGEST_CFG = """\ +# Auto-generated nimsuggest.cfg for language server stability +# This supplements the project's nim.cfg without overriding it + +-d:nimsuggest +-d:nimSuggestSkipStatic +-d:nimscript + +--errorMax:100 +--maxLoopIterationsVM:10000000 + +--skipProjCfg:off +--skipUserCfg:on +--skipParentCfg:on +--verbosity:0 +--hints:off +--notes:off +""" + +# Template for auto-generated nim.cfg — provides path hints so nimsuggest can +# resolve imports. Only written when no nim.cfg exists and a .nimble file is present. +_NIM_CFG_TEMPLATE = """\ +# Auto-generated nim.cfg for nimsuggest/nimlangserver + +--path:"." +{extra_paths} +--define:ssl +--define:useStdLib + +--hint:XDeclaredButNotUsed:off +--hint:XCannotRaiseY:off +--hint:User:off +""" + + +class NimLanguageServerProcess(LanguageServerProcess): + """Custom process handler for nimlangserver. + + nimlangserver only supports the Content-Length header in LSP messages. + The standard implementation also sends Content-Type, which causes + nimlangserver to fail to parse messages. + """ + + @override + def _send_payload(self, payload: StringDict) -> None: + if not self.process or not self.process.stdin: + return + self._trace("solidlsp", "ls", payload) + + body = json.dumps(payload, check_circular=False, ensure_ascii=False, separators=(",", ":")).encode(ENCODING) + header = f"Content-Length: {len(body)}\r\n\r\n".encode(ENCODING) + + with self._stdin_lock: + try: + self.process.stdin.write(header + body) + self.process.stdin.flush() + except (BrokenPipeError, ConnectionResetError, OSError) as e: + log.error("Failed to write to stdin: %s", e) + + +class NimLanguageServer(SolidLanguageServer): + """Nim language server integration using nimlangserver. + + Uses nimlangserver (installed via nimble) as the LSP backend. Key implementation notes: + + - **Content-Type workaround**: nimlangserver (as of 1.12.0) cannot parse LSP messages + that include a Content-Type header. NimLanguageServerProcess overrides _send_payload + to send only the Content-Length header. + - **Config file generation**: On startup, creates nimsuggest.cfg and nim.cfg in the + project root if they don't already exist. These are required for reliable nimsuggest + operation (import resolution, stability). Existing config files are never overwritten. + - **Retry logic for document symbols**: nimlangserver delegates analysis to nimsuggest, + which runs as a separate process on a TCP port. When nimsuggest crashes or is still + initializing, it reports port=0 (meaning "no port assigned / not listening"). Requests + made during this window return empty results. The request_document_symbols override + retries with backoff, waiting for nimsuggest to restart and obtain a valid port. + This primarily affects document symbols because it is typically the first heavy request + sent after server initialization, hitting the window where nimsuggest may not be ready. + Other LSP methods (definition, references) are usually called later, after nimsuggest + has stabilized. + """ + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """Find or install nimlangserver and return the path to the executable.""" + nimble_bin = os.path.expanduser(os.path.join("~", ".nimble", "bin")) + + # Check if nimlangserver is already available on PATH + system_nimlangserver = shutil.which("nimlangserver") + if system_nimlangserver: + log.info("Found nimlangserver at %s", system_nimlangserver) + return system_nimlangserver + + # Also check the standard nimble bin directory (may not be on PATH) + nimlangserver_path = os.path.join(nimble_bin, "nimlangserver") + if os.path.exists(nimlangserver_path): + log.info("Found nimlangserver at %s", nimlangserver_path) + return nimlangserver_path + + # Check if nim and nimble are installed + is_nim_installed = shutil.which("nim") is not None or os.path.exists(os.path.join(nimble_bin, "nim")) + is_nimble_installed = shutil.which("nimble") is not None or os.path.exists(os.path.join(nimble_bin, "nimble")) + + if not is_nim_installed or not is_nimble_installed: + missing = [] + if not is_nim_installed: + missing.append("nim") + if not is_nimble_installed: + missing.append("nimble") + raise RuntimeError( + f"{' and '.join(missing)} not found in PATH.\n" + "Please install Nim using one of these methods:\n" + " - Using choosenim: curl https://nim-lang.org/choosenim/init.sh -sSf | sh\n" + " - From official website: https://nim-lang.org/install.html\n" + " - Using package manager (brew install nim, apt install nim, etc.)\n" + "After installation, ensure nim and nimble are in your PATH." + ) + + # Install nimlangserver via nimble + log.info("Installing nimlangserver via nimble") + nimble_cmd = shutil.which("nimble") or os.path.join(nimble_bin, "nimble") + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="nimlangserver", + description="Nim Language Server", + command=[nimble_cmd, "install", "nimlangserver", "-y"], + platform_id=None, + ) + ] + ) + + try: + deps.install(nimble_bin) + except Exception as e: + raise RuntimeError( + f"Failed to install nimlangserver via nimble: {e}\nPlease try installing manually with: nimble install nimlangserver" + ) from e + + # After install, check PATH and nimble bin + installed_nimlangserver = shutil.which("nimlangserver") + if installed_nimlangserver: + log.info("Found nimlangserver in PATH at %s", installed_nimlangserver) + return installed_nimlangserver + + if os.path.exists(nimlangserver_path): + log.info("Found nimlangserver at %s", nimlangserver_path) + return nimlangserver_path + + raise RuntimeError( + "nimlangserver installation appeared to succeed but the binary was not found.\n" + "Please verify installation with: nimble list -i | grep nimlangserver" + ) + + def _create_launch_command(self, core_path: str) -> list[str]: + return [core_path] + + @override + def create_launch_command_env(self) -> dict[str, str]: + """Ensure nimble bin is in PATH so nimlangserver can find nimsuggest.""" + nimble_bin = os.path.expanduser(os.path.join("~", ".nimble", "bin")) + current_path = os.environ.get("PATH", "") + if nimble_bin not in current_path.split(os.pathsep): + return {"PATH": f"{nimble_bin}{os.pathsep}{current_path}"} + return {} + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a NimLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + # Initialize server_ready before parent class initialization + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + # Track nimsuggest port health to avoid marking server as ready when port=0. + # port=0 means nimsuggest has crashed or hasn't started listening yet. + # These are threading.Events because they're written by notification handler threads + # and read by request threads. + self._has_port_error = threading.Event() + self._nimsuggest_functional = threading.Event() + + super().__init__( + config, + repository_root_path, + None, + "nim", + solidlsp_settings, + cache_version_raw_document_symbols=2, + ) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + @override + def _create_server_process( # type: ignore[override] + self, + process_launch_info: ProcessLaunchInfo, + logging_fn: "Callable[[str, str, StringDict | str], None] | None", + config: LanguageServerConfig, + ) -> "NimLanguageServerProcess": + return NimLanguageServerProcess( + process_launch_info, + language=self.language, + determine_log_level=self._determine_log_level, + logger=logging_fn, + start_independent_lsp_process=config.start_independent_lsp_process, + ) + + @staticmethod + @override + def _determine_log_level(line: str) -> int: + """Classify nimlangserver stderr lines to appropriate log levels.""" + if line.startswith("DBG ") or "DBG " in line[:20]: + return logging.DEBUG + elif line.startswith("INF ") or "INF " in line[:20]: + return logging.INFO + elif line.startswith("WRN ") or "WRN " in line[:20]: + return logging.WARNING + elif line.startswith("ERR ") or "ERR " in line[:20]: + # SVG/resource missing errors are non-critical + if "cannot open:" in line and ".svg" in line: + return logging.WARNING + return logging.ERROR + return logging.INFO + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "nimcache", + "htmldocs", + "node_modules", + ] + + def _create_nim_config_if_needed(self) -> None: + """Create Nim config files if the project doesn't already have them. + + nimsuggest (the analysis backend used by nimlangserver) needs configuration to + reliably resolve imports and avoid noisy diagnostics. Without a nimsuggest.cfg, + it may crash or produce spurious errors. Without a nim.cfg with --path hints, + cross-module imports often fail to resolve. + + These files are only written when absent — existing project config is never overwritten. + """ + try: + nim_cfg_path = os.path.join(self.repository_root_path, "nim.cfg") + nimsuggest_cfg_path = os.path.join(self.repository_root_path, "nimsuggest.cfg") + + if not os.path.exists(nimsuggest_cfg_path): + with open(nimsuggest_cfg_path, "w") as f: + f.write(_NIMSUGGEST_CFG) + log.warning("Created nimsuggest.cfg in project root for nimsuggest stability. Consider adding it to .gitignore.") + + if not os.path.exists(nim_cfg_path): + nimble_files = list(pathlib.Path(self.repository_root_path).glob("*.nimble")) + if nimble_files: + extra_paths = "" + if os.path.exists(os.path.join(self.repository_root_path, "src")): + extra_paths += '--path:"src"\n' + if os.path.exists(os.path.join(self.repository_root_path, "tests")): + extra_paths += '--path:"tests"\n' + with open(nim_cfg_path, "w") as f: + f.write(_NIM_CFG_TEMPLATE.format(extra_paths=extra_paths)) + log.warning("Created nim.cfg in project root with path hints for nimsuggest. Consider adding it to .gitignore.") + else: + log.debug("Found existing nim.cfg, respecting project configuration") + except Exception as e: + log.debug("Could not create config files: %s", e) + + def _detect_nim_project_mapping(self) -> list[dict[str, str]] | None: + """Detect the Nim project entry point and return a ``projectMapping`` list. + + nimlangserver uses ``projectMapping`` to decide which file to pass as root + to nimsuggest. Without an explicit mapping it may pick ``config.nims`` + (a NimScript config file, not a compilable source) which prevents + nimsuggest from properly analysing the project. + + When a ``.nimble`` file is present we look for the conventional entry + ``.nim`` file and create a mapping so all ``.nim`` files use it. + """ + root = pathlib.Path(self.repository_root_path) + + nimble_files = list(root.glob("*.nimble")) + if not nimble_files: + return None + + nimble_name = nimble_files[0].stem # e.g. "myproject" from "myproject.nimble" + + candidates = [ + root / f"{nimble_name}.nim", + root / "src" / f"{nimble_name}.nim", + root / "main.nim", + root / "src" / "main.nim", + ] + + entry_file: pathlib.Path | None = None + for candidate in candidates: + if candidate.exists(): + entry_file = candidate.relative_to(root) + break + + if entry_file is None: + # Fall back to first .nim file in root + nim_files = sorted(root.glob("*.nim")) + if nim_files: + entry_file = nim_files[0].relative_to(root) + + if entry_file is None: + return None + + log.info("Detected Nim project entry point: %s (from %s)", entry_file, nimble_files[0].name) + return [{"projectFile": str(entry_file), "fileRegex": r".*\.nim$"}] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "codeAction": {"dynamicRegistration": True}, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + "documentLink": {"dynamicRegistration": True}, + "typeDefinition": {"dynamicRegistration": True}, + "implementation": {"dynamicRegistration": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": {"dynamicRegistration": True, "rangeLimit": 5000, "lineFoldingOnly": True}, + "declaration": {"dynamicRegistration": True}, + "selectionRange": {"dynamicRegistration": True}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + "workspaceFolders": True, + "configuration": True, + }, + }, + "initializationOptions": { + "nim": { + "timeout": 120000, + "autoRestart": True, + "nimsuggestIdleTimeout": 300000, + "notificationVerbosity": "warning", + "workingDirectoryMapping": [ + {"projectFile": "*.nimble", "directory": "."}, + {"projectFile": "src/*.nim", "directory": "."}, + ], + } + }, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params # type: ignore[return-value] + + def _start_server(self) -> None: + """ + Starts the Nim Language Server, waits for the server to be ready and yields the LanguageServer instance. + """ + self._create_nim_config_if_needed() + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + + def execute_client_command_handler(_: dict) -> list: + return [] + + def do_nothing(_: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info("LSP: window/logMessage: %s", msg) + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Nim language server ready signal detected") + self.server_ready.set() + + def window_show_message(msg: dict) -> None: + log.info("LSP: window/showMessage: %s", msg) + message_text = msg.get("message", "") + msg_type = msg.get("type", 3) + + if msg_type == 1: # Error + if "cannot open:" in message_text and ".svg" in message_text: + log.warning("Non-critical resource missing: %s", message_text) + elif "Failed to parse nimsuggest port" in message_text: + log.error("Nimsuggest port parsing failed: %s", message_text) + self._has_port_error.set() + return + else: + log.error("Nim server error: %s", message_text) + elif "initialized" in message_text.lower() or "ready" in message_text.lower(): + if self._has_port_error.is_set(): + log.warning( + "Ignoring 'initialized' signal because nimsuggest has port errors. " + "Waiting for extension/statusUpdate with port > 0." + ) + return + log.info("Nim language server ready signal detected from showMessage") + self.server_ready.set() + + def workspace_configuration_handler(params: dict) -> list: + """Handle workspace/configuration requests - nimlangserver expects an array.""" + items = params.get("items", []) + return [None for _ in items] + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("window/showMessage", window_show_message) + self.server.on_request("workspace/executeClientCommand", execute_client_command_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + def extension_status_update(params: dict) -> None: + """Handle Nim-specific status updates which include nimsuggest instance info.""" + if "projectErrors" in params: + errors = params["projectErrors"] + has_port_error_in_update = False + for error in errors: + error_msg = error.get("errorMessage", "") + project_file = error.get("projectFile", "") + + if "cannot open:" in error_msg and any(ext in error_msg for ext in [".svg", ".png", ".ico"]): + log.warning("Non-critical resource missing in %s: %s", project_file, error_msg) + elif "Failed to parse nimsuggest port" in error_msg: + log.error("Nimsuggest port issue for %s: %s", project_file, error_msg) + has_port_error_in_update = True + else: + log.error("Project error in %s: %s", project_file, error_msg) + + if has_port_error_in_update: + self._has_port_error.set() + + if "nimsuggestInstances" in params: + instances = params["nimsuggestInstances"] + any_functional = False + for instance in instances: + port = instance.get("port", 0) + project = instance.get("projectFile", "") + if port > 0: + any_functional = True + log.debug("Nimsuggest instance running for %s on port %d", project, port) + else: + log.warning("Nimsuggest instance for %s has port=0 (not functional)", project) + + if any_functional: + self._has_port_error.clear() + self._nimsuggest_functional.set() + if not self.server_ready.is_set(): + log.info("Nimsuggest has valid port, marking server as ready") + self.server_ready.set() + elif instances: + self._nimsuggest_functional.clear() + if self.server_ready.is_set(): + log.warning("All nimsuggest instances have port=0, clearing server ready state") + self.server_ready.clear() + + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("extension/statusUpdate", extension_status_update) + + log.info("Starting Nim server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + # When a .nimble project exists, configure projectMapping so nimlangserver + # starts nimsuggest with the correct entry-point .nim file instead of + # potentially picking up config.nims (which is a NimScript config, not a + # compilable source file). + project_mapping = self._detect_nim_project_mapping() + if project_mapping: + nim_opts: dict = initialize_params["initializationOptions"]["nim"] # type: ignore[call-overload,index,assignment] + nim_opts["projectMapping"] = project_mapping + + log.info("Sending initialize request to Nim language server") + init_response = self.server.send.initialize(initialize_params) + log.debug("Received initialize response from Nim server: %s", init_response) + + caps = init_response["capabilities"] + for cap_name in ("completionProvider", "documentSymbolProvider", "definitionProvider", "referencesProvider"): + if cap_name in caps: + log.info("Nim server supports %s", cap_name) + + self.server.notify.initialized({}) + + # Wait for server readiness with timeout + log.info("Waiting for Nim language server to be ready...") + if not self.server_ready.wait(timeout=15.0): + try: + test_response = self.server.send.workspace_symbol({"query": ""}) + if test_response is not None: + log.info("Nim server responded to test query, marking as ready") + else: + log.warning("Nim server not responding, may need manual restart") + except Exception as e: + log.warning("Error testing Nim server readiness: %s", e) + # Proceed anyway + self.server_ready.set() + else: + log.info("Nim server initialization complete") + + # Version key for the symbol range fix. Bump this whenever + # _fix_nim_symbol_ranges logic changes to invalidate cached results. + _NIM_SYMBOL_RANGE_FIX_VERSION = 2 + + _RETRY_DELAYS = (1.0, 2.0, 3.0) + + # Maximum number of goto-definition LSP requests during the fallback + # reference search in _find_references_via_goto_definition. + _GOTO_DEF_FALLBACK_MAX_REQUESTS = 200 + + _T = TypeVar("_T") + + def _retry_on_empty( + self, + fn: Callable[[], _T], + description: str, + *, + check: Callable[[_T], bool] = bool, # type: ignore[assignment] + ) -> _T: + """Retry *fn()* with backoff while *check(result)* is falsy.""" + for attempt in range(len(self._RETRY_DELAYS) + 1): + result = fn() + if check(result): + if attempt > 0: + log.info("Got %s after %d retries", description, attempt) + return result + if attempt >= len(self._RETRY_DELAYS): + log.warning( + "No %s after %d retries (port_error=%s, functional=%s).", + description, + len(self._RETRY_DELAYS), + self._has_port_error.is_set(), + self._nimsuggest_functional.is_set(), + ) + return result + delay = self._RETRY_DELAYS[attempt] + log.info( + "Empty %s, nimsuggest may need more time. Retry %d/%d in %ss.", + description, + attempt + 1, + len(self._RETRY_DELAYS), + delay, + ) + time.sleep(delay) + return result # unreachable but satisfies type checker + + @override + def _cache_context_fingerprint(self) -> "Hashable | None": + return ("nim_range_fix", self._NIM_SYMBOL_RANGE_FIX_VERSION) + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: "LSPFileBuffer | None" + ) -> "list[SymbolInformation] | list[DocumentSymbol] | None": + """Override to fix nimlangserver's incomplete range bug. + + nimlangserver reports ranges that only cover the symbol name instead of + the full body. This applies to both ``DocumentSymbol`` (top-level + ``range``) and ``SymbolInformation`` (``location.range``) formats. + This override extends ranges using indentation-based analysis so that + ``SymbolBody`` can extract the complete text. + """ + symbols = super()._request_document_symbols(relative_file_path, file_data) + + if not symbols: + return symbols + + first: dict = symbols[0] # type: ignore[assignment] # LSP returns TypedDicts + + # Determine where the range lives depending on the LSP response format: + # - DocumentSymbol: top-level "range" key + # - SymbolInformation: nested "location" -> "range" + is_document_symbol = "range" in first + location = first.get("location") + is_symbol_information = not is_document_symbol and isinstance(location, dict) and "range" in location + if not is_document_symbol and not is_symbol_information: + return symbols + + # Read file content for indentation analysis + absolute_path = os.path.join(self.repository_root_path, relative_file_path) + with open(absolute_path, encoding=ENCODING) as f: + lines = f.read().split("\n") + + self._fix_nim_symbol_ranges(symbols, lines, len(lines) - 1, is_symbol_information=is_symbol_information) # type: ignore[arg-type] + return symbols + + @classmethod + def _fix_nim_symbol_ranges( + cls, + symbols: list[dict], + lines: list[str], + container_end_line: int, + *, + is_symbol_information: bool = False, + ) -> None: + """Fix symbol ranges in-place by extending them to cover the full body. + + nimlangserver reports ranges that only cover the symbol name. + This method extends each symbol's range to span from the start of the + declaration line through all indented body lines. + + Handles both ``DocumentSymbol`` (top-level ``range``) and + ``SymbolInformation`` (``location.range``) formats. + """ + + def _get_range(symbol: dict) -> dict | None: + if is_symbol_information: + loc = symbol.get("location") + return loc.get("range") if loc else None + return symbol.get("range") + + for i, symbol in enumerate(symbols): + range_info = _get_range(symbol) + if not range_info: + continue + + # Preserve the original name range as selectionRange before extending. + # nimlangserver's original range covers just the symbol name; after we + # extend it to the full body, consumers need selectionRange to locate + # the name (e.g. for hover requests). + if "selectionRange" not in symbol: + symbol["selectionRange"] = copy.deepcopy(range_info) + + range_start_line = range_info["start"]["line"] + range_end_line = range_info["end"]["line"] + + # If the range already spans multiple lines, assume it is correct + if range_start_line != range_end_line: + if symbol.get("children"): + cls._fix_nim_symbol_ranges(symbol["children"], lines, range_end_line, is_symbol_information=is_symbol_information) + continue + + # Upper bound: start of next sibling, or container end + if i + 1 < len(symbols): + next_range = _get_range(symbols[i + 1]) + upper_bound = next_range["start"]["line"] - 1 if next_range else container_end_line + else: + upper_bound = container_end_line + + # Expand start to beginning of the declaration (respecting indent) + if range_start_line < len(lines): + base_line = lines[range_start_line] + indent = len(base_line) - len(base_line.lstrip()) + range_info["start"]["character"] = indent + + # Find the end of the body via indentation + new_end_line = cls._find_nim_body_end(lines, range_start_line, upper_bound) + + if new_end_line < len(lines): + range_info["end"]["line"] = new_end_line + range_info["end"]["character"] = len(lines[new_end_line]) + else: + range_info["end"]["line"] = len(lines) - 1 + range_info["end"]["character"] = len(lines[-1]) + + # Fix children recursively (DocumentSymbol only) + if symbol.get("children"): + cls._fix_nim_symbol_ranges(symbol["children"], lines, new_end_line, is_symbol_information=is_symbol_information) + + @staticmethod + def _find_nim_body_end(lines: list[str], start_line: int, upper_bound: int) -> int: + """Find the last line of a Nim symbol's body using indentation analysis. + + Starting from the declaration line, scans forward to find all contiguous lines + that are more indented than the declaration. Empty lines within the body are + included. The scan stops at the first non-empty line with indentation <= the + declaration line, or at ``upper_bound``. + + :param lines: all lines of the source file. + :param start_line: 0-indexed line number of the symbol declaration. + :param upper_bound: maximum line index (inclusive) to scan. + :return: 0-indexed line number of the last body line. + """ + if start_line >= len(lines): + return start_line + + base_line = lines[start_line] + base_indent = len(base_line) - len(base_line.lstrip()) + last_content_line = start_line + + for line_idx in range(start_line + 1, min(upper_bound + 1, len(lines))): + line = lines[line_idx] + stripped = line.strip() + + if not stripped: + # Empty line — might be inside the body; continue looking + continue + + line_indent = len(line) - len(line.lstrip()) + if line_indent <= base_indent: + # Reached a line at the same or lower indent — body ends + break + + last_content_line = line_idx + + return last_content_line + + @override + def request_document_symbols(self, relative_file_path: str, file_buffer: "LSPFileBuffer | None" = None) -> "DocumentSymbols": + """Override to add bounded retry when nimsuggest has port issues. + + nimlangserver delegates code analysis to nimsuggest, which runs as a separate process + and communicates over a TCP port. When nimsuggest crashes (e.g. due to malformed code + or internal errors) nimlangserver automatically restarts it, but during the restart + window nimsuggest reports port=0 (meaning "not listening / no port assigned"). Any + LSP request forwarded to nimsuggest during this window returns empty results. + + This primarily affects document symbols because it is typically the first heavy + request after server startup, hitting the initialization window. Later requests + (definition, references, hover) are usually made after nimsuggest has stabilized. + + We retry with backoff rather than detecting the crash and restarting, because + nimlangserver already handles the restart internally — we just need to wait for + nimsuggest to come back up with a valid port. + """ + from solidlsp.ls import DocumentSymbols + + max_retries = 3 + retry_delays = [2.0, 5.0, 10.0] + + for attempt in range(max_retries + 1): + result = super().request_document_symbols(relative_file_path, file_buffer) + + if result.root_symbols: + if attempt > 0: + log.info( + "Got %d root symbols for %s after %d retries", + len(result.root_symbols), + relative_file_path, + attempt, + ) + return result + + # Empty result - check if it's likely due to nimsuggest port issues + if not self._has_port_error.is_set() and self._nimsuggest_functional.is_set(): + return result + + if attempt >= max_retries: + log.warning( + "No document symbols for %s after %d retries. Nimsuggest may be non-functional (port_error=%s, functional=%s).", + relative_file_path, + max_retries, + self._has_port_error.is_set(), + self._nimsuggest_functional.is_set(), + ) + return result + + delay = retry_delays[attempt] + log.info( + "Empty document symbols for %s, nimsuggest may not be ready (port_error=%s). Retry %d/%d in %ss.", + relative_file_path, + self._has_port_error.is_set(), + attempt + 1, + max_retries, + delay, + ) + + # Invalidate cached empty result before retry + cache_key = relative_file_path + self._document_symbols_cache.pop(cache_key, None) + + self.server_ready.wait(timeout=delay) + + return DocumentSymbols([]) + + @override + def _request_hover(self, uri: str, line: int, column: int) -> "Hover | None": + """Override to add bounded retry for Nim hover requests. + + nimsuggest may need additional analysis time after initialization before + hover results become available, even when it reports as functional. + """ + return self._retry_on_empty( + lambda: super(NimLanguageServer, self)._request_hover(uri, line, column), + "hover info", + check=lambda r: r is not None, + ) + + @override + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> "list[Location] | None": + """Override to add bounded retry for Nim references requests. + + nimsuggest may need additional analysis time after initialization before + references results become available, even when it reports as functional. + """ + return self._retry_on_empty( + lambda: super(NimLanguageServer, self)._send_references_request(relative_file_path, line, column), + f"references for {relative_file_path}", + ) + + @override + def _send_definition_request(self, definition_params: "DefinitionParams") -> "Definition | list[LocationLink] | None": + """Override to add bounded retry for Nim goto-definition requests. + + nimsuggest may need additional analysis time after initialization before + definition results become available. + """ + return self._retry_on_empty( + lambda: super(NimLanguageServer, self)._send_definition_request(definition_params), + "definition result", + ) + + @override + def request_referencing_symbols( + self, + relative_file_path: str, + line: int, + column: int, + include_imports: bool = True, + include_self: bool = False, + include_body: bool = False, + include_file_symbols: bool = False, + ) -> list[ReferenceInSymbol]: + """Override to add a goto-definition fallback for Nim. + + nimsuggest's ``textDocument/references`` can return incomplete results, + especially for cross-file references. When the standard path returns + nothing we fall back to scanning all project symbols and checking + whether their goto-definition resolves to the target location. + """ + result = super().request_referencing_symbols( + relative_file_path, + line, + column, + include_imports=include_imports, + include_self=include_self, + include_body=include_body, + include_file_symbols=include_file_symbols, + ) + if result: + return result + + log.info( + "No references found via LSP for %s:%d:%d, trying goto-definition fallback.", + relative_file_path, + line, + column, + ) + + return self._find_references_via_goto_definition( + relative_file_path, line, column, include_self=include_self, include_body=include_body + ) + + def _find_references_via_goto_definition( + self, + target_file: str, + target_line: int, + target_col: int, + *, + include_self: bool = False, + include_body: bool = False, + ) -> list[ReferenceInSymbol]: + """Find references by text-searching for the target symbol name across all + source files and verifying each occurrence via goto-definition. + + For each text occurrence of the symbol name we call goto-definition. + If it resolves to ``(target_file, target_line, target_col)`` we find + the containing symbol and include it in the results. + """ + import re + + from solidlsp import ls_types + + # Resolve the target symbol's name from its position + target_symbols = self.request_document_symbols(target_file) + target_name: str | None = None + for sym in target_symbols.iter_symbols(): + sel = sym.get("selectionRange", {}).get("start", {}) + if sel.get("line") == target_line and sel.get("character") == target_col: + target_name = sym["name"] + break + + if not target_name: + log.warning("Could not determine symbol name at %s:%d:%d for fallback.", target_file, target_line, target_col) + return [] + + log.info("Goto-definition fallback: searching for '%s' across project files.", target_name) + + name_pattern = re.compile(r"\b" + re.escape(target_name) + r"\b") + + result: list[ReferenceInSymbol] = [] + seen: set[tuple[str, int, int]] = set() + lsp_request_count = 0 + + overview = self.request_overview("") + + cap_reached = False + for file_path in overview: + if cap_reached: + break + if not file_path.endswith(".nim"): + continue + content = self.retrieve_full_file_content(file_path) + lines = content.split("\n") + + for line_idx, line_text in enumerate(lines): + if cap_reached: + break + for match in name_pattern.finditer(line_text): + col_idx = match.start() + + # Skip the target definition itself unless include_self + if file_path == target_file and line_idx == target_line and col_idx == target_col: + if not include_self: + continue + + # Guard against unbounded LSP requests + if lsp_request_count >= self._GOTO_DEF_FALLBACK_MAX_REQUESTS: + log.warning( + "Goto-definition fallback hit request cap (%d). Stopping scan.", + self._GOTO_DEF_FALLBACK_MAX_REQUESTS, + ) + cap_reached = True + break + + # Call goto-definition to verify this occurrence points to the target + lsp_request_count += 1 + try: + definitions = self.request_definition(file_path, line_idx, col_idx) + except Exception: + log.debug("Goto-definition failed at %s:%d:%d, skipping.", file_path, line_idx, col_idx) + continue + + is_reference = False + for defn in definitions: + def_start = defn.get("range", {}).get("start", {}) + if ( + defn.get("relativePath") == target_file + and def_start.get("line") == target_line + and def_start.get("character") == target_col + ): + is_reference = True + break + + if not is_reference: + continue + + # Find the containing symbol for this reference + containing = self.request_containing_symbol(file_path, line_idx, col_idx, include_body=include_body) + + if containing is None: + # Module-level code — create a file symbol + file_range = self._get_range_from_file_content(content) + location = ls_types.Location( + uri=pathlib.Path(os.path.join(self.repository_root_path, file_path)).as_uri(), + range=file_range, + absolutePath=str(os.path.join(self.repository_root_path, file_path)), + relativePath=file_path, + ) + containing = ls_types.UnifiedSymbolInformation( + kind=ls_types.SymbolKind.File, + range=file_range, + selectionRange=file_range, + location=location, + name=os.path.splitext(os.path.basename(file_path))[0], + children=[], + ) + + # Deduplicate by containing symbol location + sym_sel = containing.get("selectionRange", {}).get("start", {}) + dedup_key: tuple[str, int, int] = ( + containing.get("location", {}).get("relativePath", "") or "", + sym_sel.get("line", -1), + sym_sel.get("character", -1), + ) + if dedup_key in seen: + continue + seen.add(dedup_key) + + result.append(ReferenceInSymbol(symbol=containing, line=line_idx, character=col_idx)) + + if result: + log.info( + "Goto-definition fallback found %d references for %s:%d:%d.", + len(result), + target_file, + target_line, + target_col, + ) + + return result diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1b53045a7..ba35524e4 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -9,7 +9,7 @@ import threading from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Hashable, Iterator +from collections.abc import Callable, Hashable, Iterator from contextlib import contextmanager from copy import copy from pathlib import Path, PurePath @@ -509,13 +509,7 @@ def logging_fn(source: str, target: str, msg: StringDict | str) -> None: self._dependency_provider = self._create_dependency_provider() process_launch_info = self._create_process_launch_info() log.debug(f"Creating language server instance with {language_id=} and process launch info: {process_launch_info}") - self.server = LanguageServerProcess( - process_launch_info, - language=self.language, - determine_log_level=self._determine_log_level, - logger=logging_fn, - start_independent_lsp_process=config.start_independent_lsp_process, - ) + self.server = self._create_server_process(process_launch_info, logging_fn, config) # Set up the pathspec matcher for the ignored paths # for all absolute paths in ignored_paths, convert them to relative paths @@ -544,6 +538,24 @@ def _create_dependency_provider(self) -> LanguageServerDependencyProvider: f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" ) + def _create_server_process( + self, + process_launch_info: ProcessLaunchInfo, + logging_fn: Callable[[str, str, StringDict | str], None] | None, + config: LanguageServerConfig, + ) -> LanguageServerProcess: + """Creates the LanguageServerProcess instance. + + Subclasses can override this to provide a custom process implementation. + """ + return LanguageServerProcess( + process_launch_info, + language=self.language, + determine_log_level=self._determine_log_level, + logger=logging_fn, + start_independent_lsp_process=config.start_independent_lsp_process, + ) + def _create_process_launch_info(self) -> ProcessLaunchInfo: assert self._dependency_provider is not None cmd = self._dependency_provider.create_launch_command() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 935ce98da..a62d22a11 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -75,6 +75,10 @@ class Language(str, Enum): Requires MATLAB R2021b or later and Node.js. Set MATLAB_PATH environment variable or configure matlab_path in ls_specific_settings. """ + NIM = "nim" + """Nim Language Server (nimlangserver) for Nim projects. + Requires Nim and nimble installed. Automatically installs nimlangserver via nimble if not found. + """ # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -253,6 +257,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.m", "*.mlx", "*.mlapp") case self.SYSTEMVERILOG: return FilenameMatcher("*.sv", "*.svh", "*.v", "*.vh") + case self.NIM: + return FilenameMatcher("*.nim", "*.nims", "*.nimble") case _: raise ValueError(f"Unhandled language: {self}") @@ -438,6 +444,10 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.systemverilog_server import SystemVerilogLanguageServer return SystemVerilogLanguageServer + case self.NIM: + from solidlsp.language_servers.nim_language_server import NimLanguageServer + + return NimLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/nim/test_repo/config.nims b/test/resources/repos/nim/test_repo/config.nims new file mode 100644 index 000000000..02a544455 --- /dev/null +++ b/test/resources/repos/nim/test_repo/config.nims @@ -0,0 +1,22 @@ +# Nim configuration file + +switch("warning", "UnusedImport:off") + +when defined(release): + switch("opt", "speed") + switch("checks", "off") + switch("assertions", "off") +else: + switch("opt", "none") + switch("checks", "on") + switch("assertions", "on") + +task test, "Run tests": + exec "nim c -r tests/test_main.nim" + +task build, "Build the project": + exec "nim c -d:release main.nim" + +task clean, "Clean build artifacts": + exec "rm -rf nimcache" + exec "rm -f main" diff --git a/test/resources/repos/nim/test_repo/main.nim b/test/resources/repos/nim/test_repo/main.nim new file mode 100644 index 000000000..eb23aeb9b --- /dev/null +++ b/test/resources/repos/nim/test_repo/main.nim @@ -0,0 +1,69 @@ +# Main module for testing Nim language server functionality + +import std/[strutils, sequtils, json] +import utils +import types + +proc greet(name: string): string = + ## Greets a person with their name + result = "Hello, " & name & "!" + +proc calculate(a, b: int): int = + ## Adds two integers + result = a + b + +proc processData(data: JsonNode): string = + ## Processes JSON data and returns a formatted string + let name = data["name"].getStr() + let age = data["age"].getInt() + result = "$1 is $2 years old" % [name, $age] + +type + Person* = object + name*: string + age*: int + email*: string + +proc newPerson*(name: string, age: int, email: string = ""): Person = + ## Creates a new Person object + result = Person(name: name, age: age, email: email) + +proc describe*(p: Person): string = + ## Returns a description of a person + result = "$1 ($2 years old)" % [p.name, $p.age] + if p.email != "": + result.add(", email: " & p.email) + +type + Animal* = object + name*: string + species*: string + +proc newAnimal*(name: string, species: string): Animal = + ## Creates a new Animal object + result = Animal(name: name, species: species) + +proc speak*(self: Animal): string = + ## Returns the sound the animal makes + case self.species + of "dog": "Woof!" + of "cat": "Meow!" + else: "..." + +when isMainModule: + echo greet("World") + echo "2 + 3 = ", calculate(2, 3) + + let john = newPerson("John Doe", 30, "john@example.com") + echo describe(john) + + let jsonData = %* {"name": "Alice", "age": 25} + echo processData(jsonData) + + # Test utils module + echo formatNumber(1234567) + echo reverseString("Hello") + + # Test types module + let point = newPoint(10.0, 20.0) + echo "Point: ", point.toString() diff --git a/test/resources/repos/nim/test_repo/nim.cfg b/test/resources/repos/nim/test_repo/nim.cfg new file mode 100644 index 000000000..776cb986e --- /dev/null +++ b/test/resources/repos/nim/test_repo/nim.cfg @@ -0,0 +1,10 @@ +# Auto-generated nim.cfg for nimsuggest/nimlangserver + +--path:"." + +--define:ssl +--define:useStdLib + +--hint:XDeclaredButNotUsed:off +--hint:XCannotRaiseY:off +--hint:User:off diff --git a/test/resources/repos/nim/test_repo/nimsuggest.cfg b/test/resources/repos/nim/test_repo/nimsuggest.cfg new file mode 100644 index 000000000..ae47a3c43 --- /dev/null +++ b/test/resources/repos/nim/test_repo/nimsuggest.cfg @@ -0,0 +1,20 @@ +# Auto-generated nimsuggest.cfg for language server stability +# This supplements the project's nim.cfg without overriding it + +# Define nimsuggest flag for conditional compilation +# Projects can use: when not defined(nimsuggest) +-d:nimsuggest +-d:nimSuggestSkipStatic +-d:nimscript + +# Limit error reporting +--errorMax:100 +--maxLoopIterationsVM:10000000 + +# Performance tuning +--skipProjCfg:off +--skipUserCfg:on +--skipParentCfg:on +--verbosity:0 +--hints:off +--notes:off diff --git a/test/resources/repos/nim/test_repo/test_repo.nimble b/test/resources/repos/nim/test_repo/test_repo.nimble new file mode 100644 index 000000000..0149904aa --- /dev/null +++ b/test/resources/repos/nim/test_repo/test_repo.nimble @@ -0,0 +1,8 @@ +# Package +version = "0.1.0" +author = "Test" +description = "Test repo for Nim language server integration" +license = "MIT" + +# Dependencies +requires "nim >= 2.0.0" diff --git a/test/resources/repos/nim/test_repo/types.nim b/test/resources/repos/nim/test_repo/types.nim new file mode 100644 index 000000000..a49f5cfe2 --- /dev/null +++ b/test/resources/repos/nim/test_repo/types.nim @@ -0,0 +1,130 @@ +# Type definitions module + +import std/[strformat, tables, options, math] + +type + Point* = object + x*, y*: float + + Rectangle* = object + topLeft*: Point + width*, height*: float + + Shape* = ref object of RootObj + color*: string + + Circle* = ref object of Shape + center*: Point + radius*: float + + Triangle* = ref object of Shape + a*, b*, c*: Point + + Color* = enum + Red = "red" + Green = "green" + Blue = "blue" + Yellow = "yellow" + Purple = "purple" + + Status* = enum + Pending + InProgress + Completed + Failed + + Result*[T, E] = object + case kind*: bool + of true: + value*: T + of false: + error*: E + +proc newPoint*(x, y: float): Point = + ## Creates a new Point + Point(x: x, y: y) + +proc toString*(p: Point): string = + ## Converts a Point to string + fmt"({p.x:.2f}, {p.y:.2f})" + +proc distance*(p1, p2: Point): float = + ## Calculates distance between two points + let dx = p2.x - p1.x + let dy = p2.y - p1.y + sqrt(dx * dx + dy * dy) + +proc newRectangle*(x, y, width, height: float): Rectangle = + ## Creates a new Rectangle + Rectangle(topLeft: newPoint(x, y), width: width, height: height) + +proc area*(r: Rectangle): float = + ## Calculates area of a rectangle + r.width * r.height + +proc perimeter*(r: Rectangle): float = + ## Calculates perimeter of a rectangle + 2 * (r.width + r.height) + +proc contains*(r: Rectangle, p: Point): bool = + ## Checks if a point is inside the rectangle + p.x >= r.topLeft.x and + p.x <= r.topLeft.x + r.width and + p.y >= r.topLeft.y and + p.y <= r.topLeft.y + r.height + +method draw*(s: Shape): string {.base.} = + ## Base draw method for shapes + "Drawing a shape with color: " & s.color + +method draw*(c: Circle): string = + ## Draw method for Circle + fmt"Drawing a circle at {c.center.toString()} with radius {c.radius:.2f}" + +method draw*(t: Triangle): string = + ## Draw method for Triangle + "Drawing a triangle with vertices at " & + t.a.toString() & ", " & t.b.toString() & ", " & t.c.toString() + +proc ok*[T, E](value: T): Result[T, E] = + ## Creates a successful Result + Result[T, E](kind: true, value: value) + +proc err*[T, E](error: E): Result[T, E] = + ## Creates an error Result + Result[T, E](kind: false, error: error) + +proc isOk*[T, E](r: Result[T, E]): bool = + ## Checks if Result is successful + r.kind + +proc isErr*[T, E](r: Result[T, E]): bool = + ## Checks if Result is an error + not r.kind + +type + Database* = ref object + data: Table[string, string] + +proc newDatabase*(): Database = + ## Creates a new Database + Database(data: initTable[string, string]()) + +proc set*(db: Database, key, value: string) = + ## Sets a value in the database + db.data[key] = value + +proc get*(db: Database, key: string): Option[string] = + ## Gets a value from the database + if key in db.data: + some(db.data[key]) + else: + none(string) + +proc delete*(db: Database, key: string): bool = + ## Deletes a key from the database + if key in db.data: + db.data.del(key) + true + else: + false diff --git a/test/resources/repos/nim/test_repo/utils.nim b/test/resources/repos/nim/test_repo/utils.nim new file mode 100644 index 000000000..b27c5a174 --- /dev/null +++ b/test/resources/repos/nim/test_repo/utils.nim @@ -0,0 +1,74 @@ +# Utility functions module + +import std/[strutils, math, algorithm] + +proc formatNumber*(n: int): string = + ## Formats a number with thousand separators + let s = $n + var result = "" + var count = 0 + for i in countdown(s.high, 0): + if count == 3: + result = "," & result + count = 0 + result = s[i] & result + inc count + return result + +proc reverseString*(s: string): string = + ## Reverses a string + result = s + result.reverse() + +proc isPalindrome*(s: string): bool = + ## Checks if a string is a palindrome + let cleaned = s.toLowerAscii.multiReplace((" ", ""), (",", ""), (".", "")) + return cleaned == cleaned.reversed.join("") + +proc fibonacci*(n: int): seq[int] = + ## Generates fibonacci sequence up to n terms + if n <= 0: return @[] + if n == 1: return @[0] + if n == 2: return @[0, 1] + + result = @[0, 1] + for i in 2.. None: + """Test that Nim language server can find symbols across the project.""" + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "greet"), "greet procedure not found" + assert SymbolUtils.symbol_tree_contains_name(symbols, "calculate"), "calculate procedure not found" + assert SymbolUtils.symbol_tree_contains_name(symbols, "processData"), "processData procedure not found" + assert SymbolUtils.symbol_tree_contains_name(symbols, "newPerson"), "newPerson procedure not found" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Person"), "Person type not found" + assert SymbolUtils.symbol_tree_contains_name(symbols, "Animal"), "Animal type not found" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_request_document_symbols(self, language_server: SolidLanguageServer) -> None: + """Test request_document_symbols for Nim files.""" + doc_symbols = language_server.request_document_symbols("main.nim") + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + # Extract function symbols (LSP Symbol Kind 12) + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + assert "greet" in function_names, "Should find greet procedure" + assert "calculate" in function_names, "Should find calculate procedure" + assert "processData" in function_names, "Should find processData procedure" + assert "newPerson" in function_names, "Should find newPerson procedure" + assert "describe" in function_names, "Should find describe procedure" + assert "newAnimal" in function_names, "Should find newAnimal procedure" + assert "speak" in function_names, "Should find speak procedure" + + # Extract type symbols (LSP Symbol Kind 5 for Class, 23 for Struct) + type_symbols = [symbol for symbol in all_symbols if symbol.get("kind") in [5, 23]] + type_names = [symbol["name"] for symbol in type_symbols] + + assert "Person" in type_names, "Should find Person type" + assert "Animal" in type_names, "Should find Animal type" + + # Verify that symbol body ranges span the full proc/type definition, not just the name. + # nimlangserver returns SymbolInformation with ranges covering only the symbol name; + # the range-fix logic should extend them to cover the full body. + greet_symbol = next(s for s in function_symbols if s["name"] == "greet") + body = greet_symbol.get("body") + assert body is not None, "greet should have a body" + body_text = body.get_text() + assert "proc greet" in body_text, f"Body should contain the proc signature, got: {body_text!r}" + assert "result =" in body_text, f"Body should contain the proc body, got: {body_text!r}" + assert body._end_line > body._start_line, f"greet body should span multiple lines (start={body._start_line}, end={body._end_line})" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_utils_module(self, language_server: SolidLanguageServer) -> None: + """Test symbol detection in utils.nim module.""" + doc_symbols = language_server.request_document_symbols("utils.nim") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + expected_utils_functions = [ + "formatNumber", + "reverseString", + "isPalindrome", + "fibonacci", + "factorial", + "gcd", + "lcm", + "mapSeq", + ] + + for func_name in expected_utils_functions: + assert func_name in function_names, f"Should find {func_name} procedure in utils.nim" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_types_module(self, language_server: SolidLanguageServer) -> None: + """Test type detection in types.nim module.""" + doc_symbols = language_server.request_document_symbols("types.nim") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + + type_symbols = [symbol for symbol in all_symbols if symbol.get("kind") in [5, 23, 10]] + type_names = [symbol["name"] for symbol in type_symbols] + + expected_types = ["Point", "Rectangle", "Shape", "Circle", "Triangle", "Color", "Status", "Result", "Database"] + for type_name in expected_types: + assert type_name in type_names, f"Should find {type_name} type in types.nim" + + function_symbols = [symbol for symbol in all_symbols if symbol.get("kind") == 12] + function_names = [symbol["name"] for symbol in function_symbols] + + # Note: 'draw' is defined as 'method', not 'proc' — nimlangserver does NOT report methods + expected_procs = [ + "newPoint", + "toString", + "distance", + "newRectangle", + "area", + "perimeter", + "contains", + "ok", + "err", + "isOk", + "isErr", + "newDatabase", + "set", + "get", + "delete", + ] + + for proc_name in expected_procs: + assert proc_name in function_names, f"Should find {proc_name} procedure in types.nim" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer) -> None: + """Test find references functionality for Nim within a single file.""" + doc_symbols = language_server.request_document_symbols("main.nim") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + + person_symbol = None + for sym in all_symbols: + if sym.get("name") == "Person": + person_symbol = sym + break + assert person_symbol is not None, "Could not find 'Person' symbol in main.nim" + + sel_start = person_symbol["selectionRange"]["start"] + refs = language_server.request_references("main.nim", sel_start["line"], sel_start["character"]) + assert any("main.nim" in ref.get("relativePath", "") for ref in refs), "main.nim should reference Person" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_find_references_across_files(self, language_server: SolidLanguageServer) -> None: + """Test cross-file awareness using references and goto-definition. + + formatNumber is defined in utils.nim and called in main.nim. + nimlangserver's request_references only returns usages within the queried + file's nimsuggest instance, so we verify cross-file resolution via + goto-definition (which correctly navigates from main.nim to utils.nim) + combined with a reference query from the call site. + """ + # Ensure main.nim is indexed + language_server.request_document_symbols("main.nim") + + # Find the call to formatNumber in main.nim + content = language_server.retrieve_full_file_content("main.nim") + target_line = target_col = None + for i, line in enumerate(content.split("\n")): + col = line.find("formatNumber") + if col >= 0: + target_line, target_col = i, col + break + assert target_line is not None, "Could not find formatNumber call in main.nim" + + # Verify references finds the usage in main.nim + refs = language_server.request_references("main.nim", target_line, target_col) + ref_paths = [ref.get("relativePath", "") for ref in refs] + assert any("main.nim" in p for p in ref_paths), f"Expected main.nim in references, got: {ref_paths}" + + # Verify goto-definition resolves to the definition in utils.nim (cross-file) + definition = language_server.request_definition("main.nim", target_line, target_col) + assert definition, "Should find definition for formatNumber" + assert "utils.nim" in definition[0]["uri"], f"Definition should point to utils.nim, got: {definition[0]['uri']}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_goto_definition(self, language_server: SolidLanguageServer) -> None: + """Test goto definition from main.nim to utils.nim.""" + content = language_server.retrieve_full_file_content("main.nim") + target_line = target_col = None + for i, line in enumerate(content.split("\n")): + col = line.find("formatNumber") + if col >= 0: + target_line, target_col = i, col + break + assert target_line is not None, "Could not find formatNumber call in main.nim" + + definition = language_server.request_definition("main.nim", target_line, target_col) + assert definition, "Should find definition for formatNumber call" + assert "utils.nim" in definition[0]["uri"], "Definition should point to utils.nim" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_completions(self, language_server: SolidLanguageServer) -> None: + """Test completion for Person fields after dot operator.""" + content = language_server.retrieve_full_file_content("main.nim") + target_line = target_col = None + for i, line in enumerate(content.split("\n")): + col = line.find("p.email") + if col >= 0: + # Position cursor on the 'e' of 'email' (right after the dot) + target_line, target_col = i, col + 2 + break + assert target_line is not None, "Could not find p.email in main.nim" + + # nimlangserver filters completions by prefix at cursor, so at col of 'e' we get 'e'-prefixed fields + completions = language_server.request_completions("main.nim", target_line, target_col) + assert completions, "Should return completions" + + completion_labels = [item["completionText"] for item in completions] + assert "email" in completion_labels, "Should suggest email field for Person type" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_nim_hover(self, language_server: SolidLanguageServer) -> None: + """Test that hover returns type/doc information for a symbol.""" + # Hover over the 'greet' proc definition in main.nim + doc_symbols = language_server.request_document_symbols("main.nim") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + + greet_symbol = None + for sym in all_symbols: + if sym.get("name") == "greet": + greet_symbol = sym + break + assert greet_symbol is not None, "Could not find 'greet' symbol in main.nim" + + sel_start = greet_symbol["selectionRange"]["start"] + hover_info = language_server.request_hover("main.nim", sel_start["line"], sel_start["character"]) + + assert hover_info is not None, "Hover should return information for greet proc" + assert "contents" in hover_info, "Hover should have contents" + + contents = hover_info["contents"] + if isinstance(contents, str): + hover_text = contents + elif isinstance(contents, dict) and "value" in contents: + hover_text = contents["value"] + else: + hover_text = str(contents) + + assert "greet" in hover_text, f"Hover should mention 'greet', got: {hover_text}" + + @pytest.mark.parametrize("language_server", [Language.NIM], indirect=True) + def test_request_referencing_symbols_cross_file(self, language_server: SolidLanguageServer) -> None: + """Test request_referencing_symbols finds cross-file usages. + + formatNumber is defined in utils.nim and called in main.nim. + request_referencing_symbols should find the usage, either via LSP + references or via the goto-definition fallback. + """ + # Get the position of formatNumber's definition in utils.nim + doc_symbols = language_server.request_document_symbols("utils.nim") + all_symbols, _ = doc_symbols.get_all_symbols_and_roots() + + fmt_symbol = None + for sym in all_symbols: + if sym.get("name") == "formatNumber": + fmt_symbol = sym + break + assert fmt_symbol is not None, "Could not find 'formatNumber' in utils.nim" + + sel_start = fmt_symbol["selectionRange"]["start"] + refs = language_server.request_referencing_symbols( + "utils.nim", sel_start["line"], sel_start["character"], include_file_symbols=True + ) + + # Should find at least one reference in main.nim + ref_paths = [r.symbol["location"]["relativePath"] for r in refs] + assert any("main.nim" in p for p in ref_paths), f"Expected main.nim in referencing symbols for formatNumber, got: {ref_paths}"