From 103a17072e9b915c9c9980f946902be856695978 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 29 Jun 2026 17:24:28 +0200 Subject: [PATCH 01/57] Update change log --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b914c21c..c12bb3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status of the `main` branch. Changes prior to the next official version change w * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically upon project activation + - Fix: `jet_brains_list_inspections` failed when only default parameters were used #1615 * Dashboard: - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only From 6e518b5f7cb80d06ec3bdf899b4dc825a770929c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 13:50:53 +0200 Subject: [PATCH 02/57] Refactor search in files/iterative file processing * Introduce abstractions: FileCollection, FileProxy * Unify handling of pattern search in project files (source code vs. all files) This prepares for processing of external files, which can implement FileProxy differently. --- src/serena/project.py | 28 +++++-- src/serena/tools/file_tools.py | 45 +++------- src/serena/util/file_proxy.py | 83 +++++++++++++++++++ src/serena/util/text_utils.py | 69 ++++++--------- test/serena/test_text_utils.py | 37 ++++++--- test/solidlsp/clojure/test_clojure_basic.py | 4 +- .../elixir/test_elixir_integration.py | 8 +- test/solidlsp/python/test_python_basic.py | 12 +-- test/solidlsp/swift/test_swift_basic.py | 6 +- 9 files changed, 179 insertions(+), 113 deletions(-) create mode 100644 src/serena/util/file_proxy.py diff --git a/src/serena/project.py b/src/serena/project.py index 1ddc53ac..e40c2dab 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -14,7 +14,8 @@ from serena.config.serena_config import ( ) from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.memories.memory_manager import MemoryManager -from serena.util.file_system import GitignoreParser, match_path +from serena.util.file_proxy import FileCollection +from serena.util.file_system import GitignoreParser, match_path, scan_directory from serena.util.text_utils import MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language @@ -344,7 +345,7 @@ class Project(ToStringMixin): ) return rel_file_paths - def search_source_files_for_pattern( + def search_project_files_for_pattern( self, pattern: str, relative_path: str = "", @@ -353,6 +354,7 @@ class Project(ToStringMixin): paths_include_glob: str | None = None, paths_exclude_glob: str | None = None, multiline: bool = True, + code_files_only: bool = True, ) -> list[MatchedConsecutiveLines]: """ Search for a pattern across all (non-ignored) source files @@ -366,12 +368,26 @@ class Project(ToStringMixin): :param multiline: Whether to compile the regex with the DOTALL flag (``.`` matches newlines). :return: List of matched consecutive lines with context """ - relative_file_paths = self.gather_source_files(relative_path=relative_path) + if code_files_only: + relative_file_paths = self.gather_source_files(relative_path=relative_path) + file_collection = FileCollection.from_local_project_paths(relative_file_paths, self) + else: + abs_path = os.path.join(self.project_root, relative_path) + if os.path.isfile(abs_path): + rel_paths_to_search = [relative_path] + else: + _dirs, rel_paths_to_search = scan_directory( + path=abs_path, + recursive=True, + is_ignored_dir=self.is_ignored_path, + is_ignored_file=self.is_ignored_path, + relative_to=self.project_root, + ) + file_collection = FileCollection.from_local_project_paths(rel_paths_to_search, self) + return search_files( - relative_file_paths, + file_collection, pattern, - root_path=self.project_root, - file_reader=self.read_file, context_lines_before=context_lines_before, context_lines_after=context_lines_after, paths_include_glob=paths_include_glob, diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index dd61903a..8a3a3e5e 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -20,7 +20,6 @@ from serena.util.text_utils import ( ReplacementOccurrence, expand_braces, glob_match, - search_files, ) @@ -599,40 +598,16 @@ class SearchForPatternTool(Tool): if not os.path.exists(abs_path): raise FileNotFoundError(f"Relative path {relative_path} does not exist.") - if restrict_search_to_code_files: - matches = self.project.search_source_files_for_pattern( - pattern=substring_pattern, - relative_path=relative_path, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - paths_include_glob=paths_include_glob.strip(), - paths_exclude_glob=paths_exclude_glob.strip(), - multiline=multiline, - ) - else: - if os.path.isfile(abs_path): - rel_paths_to_search = [relative_path] - else: - _dirs, rel_paths_to_search = scan_directory( - path=abs_path, - recursive=True, - is_ignored_dir=self.project.is_ignored_path, - is_ignored_file=self.project.is_ignored_path, - relative_to=self.get_project_root(), - ) - # TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided - # but it probably never matters and this version required no further refactoring - matches = search_files( - rel_paths_to_search, - substring_pattern, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - file_reader=self.project.read_file, - root_path=self.get_project_root(), - paths_include_glob=paths_include_glob, - paths_exclude_glob=paths_exclude_glob, - multiline=multiline, - ) + matches = self.project.search_project_files_for_pattern( + pattern=substring_pattern, + relative_path=relative_path, + context_lines_before=context_lines_before, + context_lines_after=context_lines_after, + paths_include_glob=paths_include_glob.strip(), + paths_exclude_glob=paths_exclude_glob.strip(), + multiline=multiline, + code_files_only=restrict_search_to_code_files, + ) # group matches by file file_to_matches: dict[str, list[str]] = defaultdict(list) diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py new file mode 100644 index 00000000..13fc070a --- /dev/null +++ b/src/serena/util/file_proxy.py @@ -0,0 +1,83 @@ +import logging +import os +from abc import ABC, abstractmethod +from collections.abc import Iterator +from typing import TYPE_CHECKING, Self + +if TYPE_CHECKING: + from serena.project import Project + +log = logging.getLogger(__name__) + + +class FileProxy(ABC): + @abstractmethod + def get_contents(self) -> str: + """:return: the contents of the file as a string.""" + + @abstractmethod + def get_relative_path(self) -> str: + """:return: the relative path reported by Serena (actual relative path or encoded external path)""" + + +class LocalProjectFileProxy(FileProxy): + def __init__(self, relative_path: str, project: "Project"): + self._relative_path = relative_path + self._project = project + + def get_contents(self) -> str: + abs_path = os.path.join(self._project.project_root, self._relative_path) + with open(abs_path, encoding=self._project.project_config.encoding) as f: + return f.read() + + def get_relative_path(self) -> str: + return self._relative_path + + +class FileCollection: + def __init__(self, file_proxies: list[FileProxy]): + self._file_proxies = file_proxies + + def __len__(self) -> int: + return len(self._file_proxies) + + def __iter__(self) -> Iterator[FileProxy]: + return iter(self._file_proxies) + + @classmethod + def from_local_project_paths(cls, relative_paths: list[str], project: "Project") -> Self: + return cls([LocalProjectFileProxy(path, project) for path in relative_paths]) + + def filter_glob(self, paths_include_glob: str | None = None, paths_exclude_glob: str | None = None) -> "FileCollection": + """ + Filters the collection based on the given patterns. + Note: Filtering is applied only to local project files. Other files are always retained. + + :param paths_include_glob: optional glob pattern to include files from the list + :param paths_exclude_glob: optional glob pattern to exclude files from the list + :return: the filtered collection + """ + from serena.util.text_utils import GlobMatcher + + if paths_include_glob is None and paths_exclude_glob is None: + return self + + include_glob_matcher = GlobMatcher(paths_include_glob) if paths_include_glob else None + exclude_glob_matcher = GlobMatcher(paths_exclude_glob) if paths_exclude_glob else None + + filtered_files = [] + for f in self._file_proxies: + if isinstance(f, LocalProjectFileProxy): + local_file: LocalProjectFileProxy = f + path = local_file.get_relative_path() + if include_glob_matcher: + if not include_glob_matcher.matches(path): + log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") + continue + if exclude_glob_matcher: + if exclude_glob_matcher.matches(path): + log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") + continue + filtered_files.append(f) + + return FileCollection(filtered_files) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index aff92fdb..b8020e12 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -1,7 +1,6 @@ import fnmatch import hashlib import logging -import os import re from collections.abc import Callable from dataclasses import dataclass, field @@ -11,7 +10,7 @@ from typing import Any, Literal, Self from bs4 import BeautifulSoup from joblib import Parallel, delayed -from serena.constants import DEFAULT_SOURCE_FILE_ENCODING +from serena.util.file_proxy import FileCollection, FileProxy from solidlsp.ls_utils import TextUtils log = logging.getLogger(__name__) @@ -237,12 +236,6 @@ def search_text( return matches -def default_file_reader(file_path: str) -> str: - """Reads using the default encoding.""" - with open(file_path, encoding=DEFAULT_SOURCE_FILE_ENCODING) as f: - return f.read() - - def expand_braces(pattern: str) -> list[str]: """ Expands brace patterns in a glob string. @@ -319,11 +312,17 @@ def glob_match(pattern: str, path: str) -> bool: return fnmatch.fnmatch(path, pattern) +class GlobMatcher: + def __init__(self, expr: str): + self._patterns = expand_braces(expr) + + def matches(self, path: str) -> bool: + return any(glob_match(p, path) for p in self._patterns) + + def search_files( - relative_file_paths: list[str], + file_collection: FileCollection, pattern: str, - root_path: str = "", - file_reader: Callable[[str], str] = default_file_reader, context_lines_before: int = 0, context_lines_after: int = 0, paths_include_glob: str | None = None, @@ -333,11 +332,8 @@ def search_files( """ Search for a pattern in a list of files. - :param relative_file_paths: list of relative file paths in which to search + :param file_collection: the collection of files to search (will be optionally filtered by glob patterns) :param pattern: pattern to search for - :param root_path: root path to resolve relative paths against (by default, current working directory). - :param file_reader: function to read a file, by default will just use os.open. - All files that can't be read by it will be skipped. :param context_lines_before: number of context lines to include before matches :param context_lines_after: number of context lines to include after matches :param paths_include_glob: optional glob pattern to include files from the list @@ -345,53 +341,36 @@ def search_files( :param multiline: whether to apply multi-line matching, enabling the flags re.DOTALL and re.MULTILINE (default: True) :return: list of MatchedConsecutiveLines objects """ - # Pre-filter paths (done sequentially to avoid overhead) - # Use proper glob matching instead of gitignore patterns - include_patterns = expand_braces(paths_include_glob) if paths_include_glob else None - exclude_patterns = expand_braces(paths_exclude_glob) if paths_exclude_glob else None + # apply glob filter + file_collection = file_collection.filter_glob(paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob) + log.info(f"Processing {len(file_collection)} files.") - filtered_paths = [] - for path in relative_file_paths: - if include_patterns: - if not any(glob_match(p, path) for p in include_patterns): - log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") - continue - - if exclude_patterns: - if any(glob_match(p, path) for p in exclude_patterns): - log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}") - continue - - filtered_paths.append(path) - - log.info(f"Processing {len(filtered_paths)} files.") - - def process_single_file(path: str) -> dict[str, Any]: + def process_single_file(file_proxy: FileProxy) -> dict[str, Any]: """Process a single file - this function will be parallelized.""" + relative_path = file_proxy.get_relative_path() try: - abs_path = os.path.join(root_path, path) - file_content = file_reader(abs_path) + file_content = file_proxy.get_contents() search_results = search_text( pattern, content=file_content, - source_file_path=path, + source_file_path=relative_path, allow_multiline_match=True, context_lines_before=context_lines_before, context_lines_after=context_lines_after, multiline=multiline, ) if len(search_results) > 0: - log.debug(f"Found {len(search_results)} matches in {path}") - return {"path": path, "results": search_results, "error": None} + log.debug(f"Found {len(search_results)} matches in {relative_path}") + return {"path": relative_path, "results": search_results, "error": None} except Exception as e: - log.debug(f"Error processing {path}: {e}") - return {"path": path, "results": [], "error": str(e)} + log.debug(f"Error processing {relative_path}: {e}") + return {"path": relative_path, "results": [], "error": str(e)} # Execute in parallel using joblib results = Parallel( n_jobs=-1, backend="threading", - )(delayed(process_single_file)(path) for path in filtered_paths) + )(delayed(process_single_file)(file_proxy) for file_proxy in file_collection) # Collect results and errors matches = [] @@ -406,7 +385,7 @@ def search_files( if skipped_file_error_tuples: log.debug(f"Failed to read {len(skipped_file_error_tuples)} files: {skipped_file_error_tuples}") - log.info(f"Found {len(matches)} total matches across {len(filtered_paths)} files") + log.info(f"Found {len(matches)} total matches across {len(file_collection)} files") return matches diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index e6cd4539..408aadac 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,7 +1,9 @@ import re +from collections.abc import Callable import pytest +from serena.util.file_proxy import FileCollection, FileProxy from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text @@ -221,6 +223,23 @@ def mock_reader_always_match(file_path: str) -> str: return "This line contains a match." +class MockFileProxy(FileProxy): + def __init__(self, relative_path: str, mock_reader: Callable[[str], str] = mock_reader_always_match): + self.relative_path = relative_path + self.mock_reader = mock_reader + + def get_contents(self) -> str: + return self.mock_reader(self.relative_path) + + def get_relative_path(self) -> str: + return self.relative_path + + +class MockFileCollection(FileCollection): + def __init__(self, file_paths, mock_reader: Callable[[str], str] = mock_reader_always_match): + super().__init__([MockFileProxy(path, mock_reader) for path in file_paths]) + + class TestSearchFiles: @pytest.mark.parametrize( "file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description", @@ -254,9 +273,8 @@ class TestSearchFiles: Test the include/exclude glob filtering logic in search_files using PathSpec patterns. """ results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, # No context needed for this test focus @@ -333,9 +351,8 @@ class TestSearchFiles: Test glob patterns that were problematic with the previous gitignore-based implementation. """ results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, context_lines_before=0, @@ -414,9 +431,8 @@ class TestSearchFiles: ): """Test search_files with glob patterns containing brace expansions.""" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, paths_include_glob=paths_include_glob, paths_exclude_glob=paths_exclude_glob, ) @@ -429,9 +445,8 @@ class TestSearchFiles: file_paths = ["a.py", "b.txt"] pattern = "non_existent_pattern_in_mock_content" # This won't match mock_reader_always_match content results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths), pattern=pattern, - file_reader=mock_reader_always_match, # Content is "This line contains a match." paths_include_glob=None, # Both files would pass filters paths_exclude_glob=None, ) @@ -454,9 +469,8 @@ class TestSearchFiles: pattern = r"value=(\d+)" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths, specific_mock_reader), pattern=pattern, - file_reader=specific_mock_reader, paths_include_glob="*.py", # Only include .py files paths_exclude_glob="b.*", # Exclude files starting with b ) @@ -484,9 +498,8 @@ class TestSearchFiles: pattern = "MATCH HERE" results = search_files( - relative_file_paths=file_paths, + MockFileCollection(file_paths, context_mock_reader), pattern=pattern, - file_reader=context_mock_reader, paths_include_glob="*.txt", # Only include .txt files paths_exclude_glob=None, context_lines_before=1, diff --git a/test/solidlsp/clojure/test_clojure_basic.py b/test/solidlsp/clojure/test_clojure_basic.py index c42067ac..3dd06d8e 100644 --- a/test/solidlsp/clojure/test_clojure_basic.py +++ b/test/solidlsp/clojure/test_clojure_basic.py @@ -211,7 +211,7 @@ class TestProjectBasics: @pytest.mark.parametrize("project", [Language.CLOJURE], indirect=True) def test_search_files_for_pattern(self, project: Project) -> None: - result = project.search_source_files_for_pattern("defn.*greet") + result = project.search_project_files_for_pattern("defn.*greet") assert result is not None, "Pattern search should return results" assert len(result) > 0, "Should find at least one match for 'defn.*greet'" @@ -219,7 +219,7 @@ class TestProjectBasics: core_matches = [match for match in result if match.source_file_path and "core.clj" in match.source_file_path] assert len(core_matches) > 0, "Should find greet function in core.clj" - result = project.search_source_files_for_pattern(":require") + result = project.search_project_files_for_pattern(":require") assert result is not None, "Should find require statements" utils_matches = [match for match in result if match.source_file_path and "utils.clj" in match.source_file_path] diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py index 678ce762..1658993f 100644 --- a/test/solidlsp/elixir/test_elixir_integration.py +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -115,7 +115,7 @@ class TestElixirProject: """Test comprehensive symbol search across the entire project.""" # Search for all function definitions function_pattern = r"def\s+\w+\s*[\(\s]" - function_matches = project.search_source_files_for_pattern(function_pattern) + function_matches = project.search_project_files_for_pattern(function_pattern) # Should find functions across multiple files if function_matches: @@ -131,7 +131,7 @@ class TestElixirProject: # Search for struct definitions struct_pattern = r"defstruct\s+\[" - struct_matches = project.search_source_files_for_pattern(struct_pattern) + struct_matches = project.search_project_files_for_pattern(struct_pattern) if struct_matches: # Should find structs primarily in models.ex @@ -143,7 +143,7 @@ class TestElixirProject: """Test that the language server understands Elixir protocols and implementations.""" # Search for protocol definitions protocol_pattern = r"defprotocol\s+\w+" - protocol_matches = project.search_source_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") + protocol_matches = project.search_project_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") if protocol_matches: # Should find the Serializable protocol @@ -152,7 +152,7 @@ class TestElixirProject: # Search for protocol implementations impl_pattern = r"defimpl\s+\w+" - impl_matches = project.search_source_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") + impl_matches = project.search_project_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") if impl_matches: # Should find multiple implementations diff --git a/test/solidlsp/python/test_python_basic.py b/test/solidlsp/python/test_python_basic.py index f3d46f0f..283e3e56 100644 --- a/test/solidlsp/python/test_python_basic.py +++ b/test/solidlsp/python/test_python_basic.py @@ -214,28 +214,28 @@ class TestProjectBasics: """Test search_files_for_pattern with various patterns and glob filters.""" # Test 1: Search for class definitions across all files class_pattern = r"class\s+\w+\s*(?:\([^{]*\)|:)" - matches = project.search_source_files_for_pattern(class_pattern) + matches = project.search_project_files_for_pattern(class_pattern) assert len(matches) > 0 # Should find multiple classes like User, Item, BaseModel, etc. assert len(matches) >= 5 # Test 2: Search for specific class with include glob user_class_pattern = r"class\s+User\s*(?:\([^{]*\)|:)" - matches = project.search_source_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") + matches = project.search_project_files_for_pattern(user_class_pattern, paths_include_glob="**/models.py") assert len(matches) == 1 # Should only find User class in models.py assert matches[0].source_file_path is not None assert "models.py" in matches[0].source_file_path # Test 3: Search for method definitions with exclude glob method_pattern = r"def\s+\w+\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") + matches = project.search_project_files_for_pattern(method_pattern, paths_exclude_glob="**/models.py") assert len(matches) > 0 # Should find methods in services.py but not in models.py assert all(match.source_file_path is not None and "models.py" not in match.source_file_path for match in matches) # Test 4: Search for specific method with both include and exclude globs create_user_pattern = r"def\s+create_user\s*\([^)]*\)(?:\s*->[^:]+)?:" - matches = project.search_source_files_for_pattern( + matches = project.search_project_files_for_pattern( create_user_pattern, paths_include_glob="**/*.py", paths_exclude_glob="**/models.py" ) assert len(matches) == 1 # Should only find create_user in services.py @@ -244,7 +244,7 @@ class TestProjectBasics: # Test 5: Search for a pattern that should appear in multiple files init_pattern = r"def\s+__init__\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(init_pattern) + matches = project.search_project_files_for_pattern(init_pattern) assert len(matches) > 1 # Should find __init__ in multiple classes # Should find __init__ in both models.py and services.py assert any(match.source_file_path is not None and "models.py" in match.source_file_path for match in matches) @@ -252,7 +252,7 @@ class TestProjectBasics: # Test 6: Search with a pattern that should have no matches no_match_pattern = r"def\s+this_method_does_not_exist\s*\([^)]*\):" - matches = project.search_source_files_for_pattern(no_match_pattern) + matches = project.search_project_files_for_pattern(no_match_pattern) assert len(matches) == 0 @pytest.mark.parametrize("language_server", PYTHON_BACKEND_LANGUAGES, indirect=True) diff --git a/test/solidlsp/swift/test_swift_basic.py b/test/solidlsp/swift/test_swift_basic.py index a2282fb2..f4395f4c 100644 --- a/test/solidlsp/swift/test_swift_basic.py +++ b/test/solidlsp/swift/test_swift_basic.py @@ -198,7 +198,7 @@ class TestSwiftProjectBasics: # Scenario 3: Search for struct definitions struct_pattern = r"struct\s+\w+" - matches = project.search_source_files_for_pattern(struct_pattern) + matches = project.search_project_files_for_pattern(struct_pattern) assert len(matches) > 0, "Should find struct definitions" # Should find User struct user_matches = [m for m in matches if "User" in str(m)] @@ -206,7 +206,7 @@ class TestSwiftProjectBasics: # Scenario 4: Search for class definitions class_pattern = r"class\s+\w+" - matches = project.search_source_files_for_pattern(class_pattern) + matches = project.search_project_files_for_pattern(class_pattern) assert len(matches) > 0, "Should find class definitions" # Should find Calculator and Circle classes calculator_matches = [m for m in matches if "Calculator" in str(m)] @@ -216,7 +216,7 @@ class TestSwiftProjectBasics: # Scenario 5: Search for enum definitions enum_pattern = r"enum\s+\w+" - matches = project.search_source_files_for_pattern(enum_pattern) + matches = project.search_project_files_for_pattern(enum_pattern) assert len(matches) > 0, "Should find enum definitions" # Should find Status enum status_matches = [m for m in matches if "Status" in str(m)] From f39906728da524fd0d1669466ad1c0de9685682c Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 13:56:41 +0200 Subject: [PATCH 03/57] search_text: Remove obsolete option allow_multiline_match The obsolete option was only used in tests, where adjusting the `multiline` parameter restored the tested functionality. --- src/serena/util/text_utils.py | 76 +++++++++++----------------------- test/serena/test_text_utils.py | 17 ++++---- 2 files changed, 32 insertions(+), 61 deletions(-) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index b8020e12..67110f70 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -141,7 +141,6 @@ def search_text( pattern: str, content: str | None = None, source_file_path: str | None = None, - allow_multiline_match: bool = False, context_lines_before: int = 0, context_lines_after: int = 0, is_glob: bool = False, @@ -154,8 +153,6 @@ def search_text( :param content: The text content to search. May be None if source_file_path is provided. :param source_file_path: Optional path to the source file. If content is None, this has to be passed and the file will be read. - :param allow_multiline_match: Whether to search across multiple lines. Currently, the default - option (False) is very inefficient, so it is recommended to set this to True. :param context_lines_before: Number of context lines to include before matches :param context_lines_after: Number of context lines to include after matches :param is_glob: If True, pattern is treated as a glob-like pattern (e.g., "*.py", "test_??.py") @@ -178,60 +175,36 @@ def search_text( # Convert pattern to a compiled regex if it's a string if is_glob: pattern = glob_to_regex(pattern) - if allow_multiline_match: - # For multiline matches, optionally use DOTALL so '.' matches newlines - flags = (re.MULTILINE | re.DOTALL) if multiline else 0 - compiled_pattern = re.compile(pattern, flags) - # Search across the entire content as a single string - for match in compiled_pattern.finditer(content): - start_pos = match.start() - end_pos = match.end() - # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") - end_line_num = content[:end_pos].count("\n") + # For multiline matches, optionally use DOTALL so '.' matches newlines + flags = (re.MULTILINE | re.DOTALL) if multiline else 0 + compiled_pattern = re.compile(pattern, flags) + # Search across the entire content as a single string + for match in compiled_pattern.finditer(content): + start_pos = match.start() + end_pos = match.end() - # Calculate the range of lines to include in the context - context_start = max(0, start_line_num - context_lines_before) - context_end = min(total_lines - 1, end_line_num + context_lines_after) + # Find the line numbers for the start and end positions + start_line_num = content[:start_pos].count("\n") + end_line_num = content[:end_pos].count("\n") - # Create TextLine objects for the context - context_lines = [] - for line_num in range(context_start, context_end + 1): - if context_start <= line_num < start_line_num: - match_type = LineType.BEFORE_MATCH - elif end_line_num < line_num <= context_end: - match_type = LineType.AFTER_MATCH - else: - match_type = LineType.MATCH + # Calculate the range of lines to include in the context + context_start = max(0, start_line_num - context_lines_before) + context_end = min(total_lines - 1, end_line_num + context_lines_after) - context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) + # Create TextLine objects for the context + context_lines = [] + for line_num in range(context_start, context_end + 1): + if context_start <= line_num < start_line_num: + match_type = LineType.BEFORE_MATCH + elif end_line_num < line_num <= context_end: + match_type = LineType.AFTER_MATCH + else: + match_type = LineType.MATCH - matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) - else: - # TODO: extremely inefficient! Since we currently don't use this option in SerenaAgent or LanguageServer, - # it is not urgent to fix, but should be either improved or the option should be removed. - # Search line by line, normal compile without DOTALL - compiled_pattern = re.compile(pattern) - for i, line in enumerate(lines): - if compiled_pattern.search(line): - # Calculate the range of lines to include in the context - context_start = max(0, i - context_lines_before) - context_end = min(total_lines - 1, i + context_lines_after) + context_lines.append(TextLine(line_number=line_num, line_content=lines[line_num], match_type=match_type)) - # Create TextLine objects for the context - context_lines = [] - for j in range(context_start, context_end + 1): - if j < i: - match_type = LineType.BEFORE_MATCH - elif j > i: - match_type = LineType.AFTER_MATCH - else: - match_type = LineType.MATCH - - context_lines.append(TextLine(line_number=j, line_content=lines[j], match_type=match_type)) - - matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) + matches.append(MatchedConsecutiveLines(lines=context_lines, source_file_path=source_file_path)) return matches @@ -354,7 +327,6 @@ def search_files( pattern, content=file_content, source_file_path=relative_path, - allow_multiline_match=True, context_lines_before=context_lines_before, context_lines_after=context_lines_after, multiline=multiline, diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 408aadac..bdfa6e76 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,4 +1,3 @@ -import re from collections.abc import Callable import pytest @@ -49,7 +48,7 @@ class TestSearchText: assert "def process" in matches[1].lines[0].line_content assert "def filter" in matches[2].lines[0].line_content - def test_search_text_with_compiled_regex(self): + def test_search_text_with_regex_pattern2(self): """Test searching with a pre-compiled regex pattern.""" content = """ import os @@ -66,8 +65,8 @@ class TestSearchText: """ # Search for variable assignments with a compiled regex - pattern = re.compile(r"^\s*[A-Z_]+ = .+$") - matches = search_text(pattern, content=content) + pattern = r"^\s*[A-Z_]+ = .*?$" + matches = search_text(pattern, content=content, multiline=True) assert len(matches) == 2 assert "DEBUG = True" in matches[0].lines[0].line_content @@ -117,7 +116,7 @@ class TestSearchText: # Search for a pattern that spans multiple lines (if-else block) pattern = r"if.*?else.*?return" - matches = search_text(pattern, content=content, allow_multiline_match=True) + matches = search_text(pattern, content=content) assert len(matches) == 1 multiline_match = matches[0] @@ -145,7 +144,7 @@ class TestSearchText: """ # Search with a glob pattern for all user methods - matches = search_text("*_user*", content=content, is_glob=True) + matches = search_text("*_user*", content=content, is_glob=True, multiline=False) assert len(matches) == 3 assert "get_user" in matches[0].lines[0].line_content @@ -170,7 +169,7 @@ class TestSearchText: """ # Search with a simplified glob pattern to find all isinstance occurrences - matches = search_text("*isinstance*", content=content, is_glob=True) + matches = search_text("*isinstance*", content=content, is_glob=True, multiline=False) # Should match lines with isinstance(item, dict) and isinstance(item, list) assert len(matches) >= 2 @@ -194,11 +193,11 @@ class TestSearchText: print("value{bar}") """ - matches_square = search_text(r"*\[42\]*", content=content, is_glob=True) + matches_square = search_text(r"*\[42\]*", content=content, is_glob=True, multiline=False) assert len(matches_square) == 1 assert "[42]" in matches_square[0].lines[0].line_content - matches_curly = search_text("*{bar}*", content=content, is_glob=True) + matches_curly = search_text("*{bar}*", content=content, is_glob=True, multiline=False) assert len(matches_curly) == 1 assert "{bar}" in matches_curly[0].lines[0].line_content From 423c04101d99c5ec9f74135d44ffdc22c82b5a4e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 14:15:16 +0200 Subject: [PATCH 04/57] Make Project aware of the language backend (as it can affect its behaviour) --- src/serena/agent.py | 10 ++++------ src/serena/config/serena_config.py | 11 +++++++++++ src/serena/project.py | 11 +++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index d5b69269..953e4241 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -640,12 +640,10 @@ class SerenaAgent: # determine the effective language backend for this session. # If a startup project is provided and has a per-project override, use it; otherwise use the global config. # Since we don't want to change the toolset after startup, the language backend cannot be changed within a running Serena session - self._language_backend = self.serena_config.language_backend - if registered_project_to_activate is not None and registered_project_to_activate.project_config.language_backend is not None: - self._language_backend = registered_project_to_activate.project_config.language_backend - log.info(f"Using language backend as configured in project.yml: {self._language_backend.name}") - else: - log.info(f"Using language backend from global configuration: {self._language_backend.name}") + self._language_backend = self.serena_config.determine_language_backend( + project_config=registered_project_to_activate.project_config if registered_project_to_activate is not None else None, + log_choice=True, + ) # create the tool names mapping for prompts self._prompt_tool_names_mapping = self._create_prompt_tool_names_mapping(self._language_backend) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 0b8fc673..2b580082 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -1297,3 +1297,14 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): if glob_match(pattern, project_root_str): return True return False + + def determine_language_backend(self, project_config: ProjectConfig | None = None, log_choice: bool = False): + language_backend = self.language_backend + if project_config and project_config.language_backend is not None: + language_backend = project_config.language_backend + if log_choice: + log.info(f"Using language backend as configured in project: {language_backend.name}") + else: + if log_choice: + log.info(f"Using language backend from global configuration: {language_backend.name}") + return language_backend diff --git a/src/serena/project.py b/src/serena/project.py index e40c2dab..87d0f969 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -9,6 +9,7 @@ from sensai.util.logging import LogTime from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( + LanguageBackend, ProjectConfig, SerenaConfig, ) @@ -121,6 +122,16 @@ class Project(ToStringMixin): def project_name(self) -> str: return self.project_config.project_name + @property + def language_backend(self) -> LanguageBackend: + # The backend configuration is fundamentally owned by the agent, so it takes + # precedence. (Note: The agent does not necessary honour the project's choice, + # as it may be invalid.) + if self._agent is not None: + return self._agent.get_language_backend() + else: + return self.serena_config.determine_language_backend(self.project_config) + @classmethod def load( cls, From 3096ac4fe2567c11f3156bd65c51d4c435de3afd Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 14:19:25 +0200 Subject: [PATCH 05/57] Fix in Project._is_ignored_relative_path: Filtering of files for source files ignored the language backend The check is really only possible for LSP, so the flag is now ignored for JetBrains (where there is no known list of languages). --- CHANGELOG.md | 2 ++ src/serena/project.py | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c12bb3ec..8a26a59a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Status of the `main` branch. Changes prior to the next official version change w - Update prompts/instructions: Serena instructions manual, modes (editing, interactive) - Allow structured tool output to be configured on a per-context basis, disabling it for Claude Code (which does not correctly unpack structured output) #1042 + - Fix: Project-specific filtering of files for source files ignored the language backend. + The check is really only possible for LSP. * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/project.py b/src/serena/project.py index 87d0f969..8152a52a 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -231,17 +231,20 @@ class Project(ToStringMixin): log.debug(f"Path {abs_path} does not exist, skipping ignore check") return False - # Check file extension if it's a file - is_file = os.path.isfile(abs_path) - if is_file and ignore_non_source_files: - is_file_in_supported_language = False - for language in self.project_config.languages: - fn_matcher = language.get_source_fn_matcher() - if fn_matcher.is_relevant_filename(abs_path): - is_file_in_supported_language = True - break - if not is_file_in_supported_language: - return True + # check code file restriction (depending on backend) + if ignore_non_source_files: + # apply restriction only for LSP backend, which enumerates known languages + # and therefore can determine whether a file is a source file or not + if self.language_backend.is_lsp(): + if os.path.isfile(abs_path): + is_file_in_supported_language = False + for language in self.project_config.languages: + fn_matcher = language.get_source_fn_matcher() + if fn_matcher.is_relevant_filename(abs_path): + is_file_in_supported_language = True + break + if not is_file_in_supported_language: + return True # Create normalized path for consistent handling rel_path = Path(relative_path) From d5d88d72eb3379115fa8f4e3895d5c4dd28d9d3a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 16:55:36 +0200 Subject: [PATCH 06/57] Improve handling of glob support in FileProxy, fixing tests --- src/serena/util/file_proxy.py | 14 +++++++++++--- test/serena/test_text_utils.py | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/serena/util/file_proxy.py b/src/serena/util/file_proxy.py index 13fc070a..53685dc8 100644 --- a/src/serena/util/file_proxy.py +++ b/src/serena/util/file_proxy.py @@ -19,6 +19,12 @@ class FileProxy(ABC): def get_relative_path(self) -> str: """:return: the relative path reported by Serena (actual relative path or encoded external path)""" + @abstractmethod + def is_glob_supported(self): + """ + :return: whether the proxy supports glob filtering based on its relative path + """ + class LocalProjectFileProxy(FileProxy): def __init__(self, relative_path: str, project: "Project"): @@ -33,6 +39,9 @@ class LocalProjectFileProxy(FileProxy): def get_relative_path(self) -> str: return self._relative_path + def is_glob_supported(self): + return True + class FileCollection: def __init__(self, file_proxies: list[FileProxy]): @@ -67,9 +76,8 @@ class FileCollection: filtered_files = [] for f in self._file_proxies: - if isinstance(f, LocalProjectFileProxy): - local_file: LocalProjectFileProxy = f - path = local_file.get_relative_path() + if f.is_glob_supported(): + path = f.get_relative_path() if include_glob_matcher: if not include_glob_matcher.matches(path): log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}") diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index bdfa6e76..71b0aad6 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -233,6 +233,9 @@ class MockFileProxy(FileProxy): def get_relative_path(self) -> str: return self.relative_path + def is_glob_supported(self): + return True + class MockFileCollection(FileCollection): def __init__(self, file_paths, mock_reader: Callable[[str], str] = mock_reader_always_match): From dcbd8ce064fd057858ba49c9f74b433b95d9f17a Mon Sep 17 00:00:00 2001 From: Jordan Carney Date: Wed, 1 Jul 2026 12:12:05 -0400 Subject: [PATCH 07/57] Add activation_command to project config (#1623) Add activation_command to project config Adds two optional fields to ProjectConfig: - activation_command: shell command run in the project root before the language backend (LSP or JetBrains) initialises - activation_command_timeout: safety backstop (default 180s); on expiry the process tree is killed and activation continues The command is only executed for trusted projects (trusted_project_path_patterns). Exit code is the primary completion signal; failures and timeouts are logged but never abort activation (no return channel to the LLM at that point). Closes #1606 --------- Co-authored-by: Dominik Jain --- CHANGELOG.md | 8 +- docs/02-usage/040_workflow.md | 1 + src/serena/agent.py | 44 ++++++++++ src/serena/config/serena_config.py | 13 +++ src/serena/resources/project.template.yml | 13 +++ test/serena/config/test_serena_config.py | 48 +++++++++++ test/serena/test_activation_command.py | 100 ++++++++++++++++++++++ 7 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 test/serena/test_activation_command.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a26a59a..3fcd8b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,13 @@ Status of the `main` branch. Changes prior to the next official version change w * General: - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. Current effects: - - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `ls_specific_settings` defined in project configurations will only be applied for trusted projects + - `activation_command` (and `activation_command_timeout`) defined in project configurations will only + be executed for trusted projects: an optional shell command run in the project root before the + language backend initialises (e.g. to generate source files a language server needs to index). + Exit code is the primary completion signal; `activation_command_timeout` (default 180s) is a safety + backstop — on expiry the process is killed and activation continues. Failures and timeouts are + logged but do not abort activation. - Fix: context or mode argument referencing a known name (e.g. `--context anitgravity`) could result in incorrect file access if a corresponding local file existed (e.g. `./antigravity` binary); file access is now guarded with path detection (file ending or path separator must be present) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 8fa10eca..fa710498 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -53,6 +53,7 @@ The file allows you to configure ... * write access * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos * an initial prompt that shall be passed to the LLM whenever the project is activated + * a shell command to run upon project activation (prior to language backend initialisation) * the set of tools and modes to use for the project * and some other settings. diff --git a/src/serena/agent.py b/src/serena/agent.py index 953e4241..637e8ae1 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -63,6 +63,7 @@ from serena.util.inspection import iter_subclasses from serena.util.logging import MemoryLogHandler from solidlsp.ls_config import Language from solidlsp.util import subprocess_util +from solidlsp.util.subprocess_util import terminate_process_tree_with_kill_fallback if TYPE_CHECKING: from serena.gui_log_viewer import GuiLogViewer @@ -1200,6 +1201,7 @@ class SerenaAgent: self._update_active_tools() def init_project_services() -> None: + self._run_project_activation_command(project) self._init_active_project_language_backend() # initialise the project's language backend in the background @@ -1214,6 +1216,48 @@ class SerenaAgent: return True + @staticmethod + def _run_project_activation_command(project: Project) -> None: + """ + Runs the given project's activation_command (if set and the project is trusted). + Failures are logged. + """ + activation_command = project.project_config.activation_command + if not activation_command: + return + if not project.is_trusted(): + log.warning( + f"Project path {project.project_root} is not trusted, ignoring activation_command " + "from project configuration. To trust the project, modify the trusted path patterns " + "in the global configuration." + ) + return + timeout = project.project_config.activation_command_timeout + cmd = subprocess_util.convert_shell_cmd(activation_command) + log.info(f"Running activation_command for project '{project.project_name}': {cmd}") + try: + with LogTime("Project activation command", logger=log): + p = subprocess.Popen( + cmd, + shell=True, + cwd=project.project_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + **subprocess_util.subprocess_kwargs(), + ) + try: + _, stderr = p.communicate(timeout=timeout) + if p.returncode != 0: + log.error(f"activation_command for project '{project.project_name}' failed (exit {p.returncode}): {stderr.strip()}") + except subprocess.TimeoutExpired: + log.error( + f"Activation_command for project '{project.project_name}' timed out after " + f"{timeout}s; terminating process and continuing with backend initialisation." + ) + terminate_process_tree_with_kill_fallback(p, terminate_timeout=5.0, process_name="activation_command") + except Exception: + log.exception(f"Unexpected error running activation_command for project '{project.project_name}'") + def _init_active_project_language_backend(self) -> None: """ Initialises the active project's language backend diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 2b580082..390eb2b2 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -308,6 +308,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + activation_command: str | None = None + activation_command_timeout: float = 180.0 # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) @@ -493,6 +495,15 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): f"Invalid language: {orig_language_str}.\nValid language_strings are: {[l.value for l in Language]}" ) from e + # Validate activation_command_timeout + activation_command_timeout_raw = data.get("activation_command_timeout", 180.0) + try: + activation_command_timeout = float(activation_command_timeout_raw) + except (TypeError, ValueError) as e: + raise ValueError(f"activation_command_timeout must be a number, got: {activation_command_timeout_raw}") from e + if activation_command_timeout <= 0: + raise ValueError(f"activation_command_timeout must be positive, got: {activation_command_timeout}") + # Validate symbol_info_budget symbol_info_budget_raw = data["symbol_info_budget"] symbol_info_budget = symbol_info_budget_raw @@ -540,6 +551,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, ls_specific_settings=data.get("ls_specific_settings", {}), + activation_command=data.get("activation_command"), + activation_command_timeout=activation_command_timeout, _local_override_keys=local_override_keys, ) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index a4dc26ee..5d0b7441 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -37,6 +37,19 @@ languages: ["python"] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180 + # line ending convention to use when writing source files. # Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) # This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 0a3cb43c..e66ad32e 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -607,3 +607,51 @@ class TestMemoriesManagerCustomPath: manager.save_memory("topic_b", "content b", is_tool_context=False) memories = manager.list_project_memories() assert sorted(memories.get_full_list()) == ["topic_a", "topic_b"] + + +class TestProjectConfigActivationCommand: + """Tests for the activation_command and activation_command_timeout fields.""" + + def _base_data(self) -> dict: + data, _ = ProjectConfig._load_yaml_dict(PROJECT_TEMPLATE_FILE) + data["project_name"] = "test" + data["languages"] = ["python"] + return data + + def test_activation_command_defaults_to_none(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command is None + + def test_activation_command_timeout_default(self): + config = ProjectConfig(project_name="test", languages=[Language.PYTHON]) + assert config.activation_command_timeout == 180.0 + + def test_activation_command_parsed_from_dict(self): + data = self._base_data() + data["activation_command"] = "npx nx run-many -t build" + data["activation_command_timeout"] = 300 + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command == "npx nx run-many -t build" + assert config.activation_command_timeout == 300.0 + + def test_activation_command_defaults_when_absent(self): + data = self._base_data() + data.pop("activation_command", None) + data.pop("activation_command_timeout", None) + config = ProjectConfig._from_dict(data, local_override_keys=[]) + assert config.activation_command is None + assert config.activation_command_timeout == 180.0 + + def test_activation_command_timeout_zero_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = 0 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) + + def test_activation_command_timeout_negative_raises(self): + data = self._base_data() + data["activation_command"] = "echo hi" + data["activation_command_timeout"] = -10 + with pytest.raises(ValueError, match="activation_command_timeout must be positive"): + ProjectConfig._from_dict(data, local_override_keys=[]) diff --git a/test/serena/test_activation_command.py b/test/serena/test_activation_command.py new file mode 100644 index 00000000..39bca274 --- /dev/null +++ b/test/serena/test_activation_command.py @@ -0,0 +1,100 @@ +"""Tests for SerenaAgent._run_activation_command.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from serena.config.serena_config import ProjectConfig, SerenaConfig +from serena.project import Project +from solidlsp.ls_config import Language + + +def _make_project( + project_root: Path, + activation_command: str | None = None, + activation_command_timeout: float = 180.0, + trusted: bool = True, +) -> Project: + """Create a minimal Project pointing at project_root with the given activation settings.""" + serena_config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + trusted_project_path_patterns=["**"] if trusted else [], + ) + project_config = ProjectConfig( + project_name="test-activation", + languages=[Language.PYTHON], + activation_command=activation_command, + activation_command_timeout=activation_command_timeout, + ) + return Project( + project_root=str(project_root), + project_config=project_config, + serena_config=serena_config, + ) + + +class TestRunActivationCommand: + """Unit tests for SerenaAgent._run_activation_command.""" + + def _make_agent(self, project: Project): + from serena.agent import SerenaAgent + + agent = SerenaAgent.__new__(SerenaAgent) + # wire up only what _run_activation_command needs + agent.serena_config = project.serena_config + return agent + + def test_no_command_does_nothing(self, tmp_path: Path, caplog): + project = _make_project(tmp_path, activation_command=None) + agent = self._make_agent(project) + with caplog.at_level(logging.INFO): + agent._run_project_activation_command(project) + assert "activation_command" not in caplog.text + + def test_trusted_command_runs_in_project_root(self, tmp_path: Path): + sentinel = tmp_path / "sentinel.txt" + # write a sentinel file from within the command to confirm cwd is project_root + if sys.platform == "win32": + cmd = "type nul > sentinel.txt" + else: + cmd = "touch sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + agent._run_project_activation_command(project) + assert sentinel.exists(), "Command did not run in project root (sentinel file missing)" + + def test_untrusted_project_skips_command(self, tmp_path: Path, caplog): + sentinel = tmp_path / "sentinel.txt" + cmd = "touch sentinel.txt" if sys.platform != "win32" else "type nul > sentinel.txt" + project = _make_project(tmp_path, activation_command=cmd, trusted=False) + agent = self._make_agent(project) + with caplog.at_level(logging.WARNING): + agent._run_project_activation_command(project) + assert not sentinel.exists(), "Command must not run for untrusted project" + assert "not trusted" in caplog.text + + def test_failing_command_logs_error_and_continues(self, tmp_path: Path, caplog): + cmd = "exit 1" if sys.platform != "win32" else "cmd /c exit 1" + project = _make_project(tmp_path, activation_command=cmd, trusted=True) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not raise + assert any("failed" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) + + @pytest.mark.slow + def test_timeout_kills_process_and_continues(self, tmp_path: Path, caplog): + """A hanging command must be killed and activation must continue.""" + cmd = 'python3 -c "import time; time.sleep(60)"' + project = _make_project( + tmp_path, + activation_command=cmd, + activation_command_timeout=1.0, # 1s — fast for the test + trusted=True, + ) + agent = self._make_agent(project) + with caplog.at_level(logging.ERROR): + agent._run_project_activation_command(project) # must not hang or raise + assert any("timed out" in r.message.lower() for r in caplog.records if r.levelno == logging.ERROR) From b8a0882d299bcc56734ebc2df4f50d69174fc843 Mon Sep 17 00:00:00 2001 From: weiconghe <46336277+weiconghe@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:53:22 +0800 Subject: [PATCH 08/57] fix(file_system): handle PermissionError on scandir in gitignore walker (#1634) Wrap os.scandir() with PermissionError guard in _iter_gitignore_files' scan() function to prevent crash when a subdirectory is not readable. Previously, PermissionError on the scandir call itself crashed the entire gitignore walker, even when the path was listed in ignored_paths. Fixes oraios/serena#1624 Co-authored-by: Claude --- CHANGELOG.md | 1 + src/serena/util/file_system.py | 7 ++++- test/serena/util/test_file_system.py | 40 +++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fcd8b5f..387e9146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 - Fix: Project-specific filtering of files for source files ignored the language backend. The check is really only possible for LSP. + - Fix: File system permission errors during gitignore scanning were not caught #1624 * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/file_system.py b/src/serena/util/file_system.py index 6ec1582e..06cedc8c 100644 --- a/src/serena/util/file_system.py +++ b/src/serena/util/file_system.py @@ -164,7 +164,12 @@ class GitignoreParser: queue: list[str] = [self.repo_root] def scan(abs_path: str | None) -> Iterator[str]: - for entry in os.scandir(abs_path): + try: + entries = os.scandir(abs_path) + except PermissionError as ex: + log.debug(f"Skipping unreadable directory {abs_path}: {ex}") + return + for entry in entries: try: if entry.is_dir(follow_symlinks=follow_symlinks): queue.append(entry.path) diff --git a/test/serena/util/test_file_system.py b/test/serena/util/test_file_system.py index 95ce9e61..ca8a0872 100644 --- a/test/serena/util/test_file_system.py +++ b/test/serena/util/test_file_system.py @@ -3,7 +3,6 @@ import shutil import tempfile from pathlib import Path -# Assuming the gitignore parser code is in a module named 'gitignore_parser' from pathspec import PathSpec from serena.util.file_system import GitignoreParser, GitignoreSpec, match_path @@ -715,3 +714,42 @@ src/*.o # foo.txt in other/ should NOT be ignored (outside foo/ subtree) assert not parser.should_ignore("other/foo.txt"), "other/foo.txt should NOT be ignored by foo/.gitignore" + + +class TestGitignoreParserPermissionError: + """Test PermissionError handling in GitignoreParser.""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.repo_path = Path(self.test_dir) + + def teardown_method(self): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def test_scandir_permission_error_on_subdirectory(self): + """ + Test that GitignoreParser does not crash when a subdirectory + is not readable (PermissionError on os.scandir). + + Regression test for https://github.com/oraios/serena/issues/1624 + """ + # Create a root .gitignore + gitignore = self.repo_path / ".gitignore" + gitignore.write_text("*.log\n") + + # Create an unreadable subdirectory + unreadable = self.repo_path / "unreadable_dir" + unreadable.mkdir() + + # Remove read permissions (Unix only; on Windows this is a no-op) + old_mode = os.stat(unreadable).st_mode + os.chmod(unreadable, 0o000) + + try: + # This should not raise PermissionError + parser = GitignoreParser(str(self.repo_path)) + # Parser should still function + assert parser.should_ignore("test.log") + finally: + # Restore permissions so teardown can clean up + os.chmod(unreadable, old_mode) From 7d0481b8a4a344f2205a586fcfc16954eb312af8 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 10:54:53 +0200 Subject: [PATCH 09/57] Add poe ty as alias for type-check --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 232a4dea..bdd04a6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -173,6 +173,7 @@ format = ["_ruff_fix", "_ruff_format"] _ty_core = "ty check src/serena src/solidlsp" _ty_test = "ty check test --exclude test/resources" type-check = ["_ty_core", "_ty_test"] +ty = ["type-check"] # docs _autogen_docs = "python docs/autogen_docs.py" _sphinx_build = "sphinx-build -b html docs docs/_build -W --keep-going" From 3c241bab0d07d8adba8ef4f6c49676c954559dee Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 14:16:41 +0200 Subject: [PATCH 10/57] Update project file --- .serena/project.yml | 46 ++++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index e6db2f97..f738bebf 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -3,24 +3,27 @@ project_name: "serena" # list of languages for which language servers are started; choose from: -# al ansible bash clojure cpp -# cpp_ccls crystal csharp csharp_omnisharp dart -# elixir elm erlang fortran fsharp -# go groovy haskell haxe hlsl -# java json julia kotlin lean4 -# lua luau markdown matlab msl -# nix ocaml pascal perl php -# php_phpactor powershell python python_jedi python_ty -# r rego ruby ruby_solargraph rust -# scala solidity swift systemverilog terraform -# toml typescript typescript_vts vue yaml -# zig +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py # For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) # - For Free Pascal/Lazarus, use pascal # Special requirements: # Some languages require additional setup/installations. @@ -73,7 +76,7 @@ initial_prompt: | Comments: When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase clearly defines *what* it is. Any details then follow in subsequent sentences. - + Pull requests: Read `mem:creating_pull_requests` when asked to participate in the creation of a pull request. @@ -136,8 +139,8 @@ ignored_memory_patterns: [] # advanced configuration option allowing to configure language server-specific options. # Maps the language key to the options. -# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. -# No documentation on options means no options are available. +# The settings are considered only if the project is trusted (see global configuration to define trusted projects). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} # list of mode names to be activated additionally for this project, e.g. ["query-projects"] @@ -155,3 +158,16 @@ added_modes: # - ../sibling-package # - ../shared-lib additional_workspace_folders: [] + +# optional shell command to run before the language backend (LSP or JetBrains) is initialised. +# the command runs in the project root directory and is only executed if the project is trusted +# (see trusted_project_path_patterns in the global configuration). +# serena waits for the command to exit: a non-zero exit code is logged as an error but does not +# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety +# backstop for non-terminating commands; on expiry the process is killed and activation continues. +# example: activation_command: "npx nx run-many -t build" +activation_command: + +# maximum time in seconds to wait for activation_command to complete before killing it (default 180s). +# must be a positive number. +activation_command_timeout: 180.0 From e93bbeedad9d39a506c6934dc48c97a1999eeb7f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 14:27:00 +0200 Subject: [PATCH 11/57] solidlsp: Introduce LanguageServerDependencyProviderBaseCommand Generalises launch command creation from a base command which the user can override via custom settings. SinglePath and Uvx dependency providers are now specialisations of it; the uvx command construction was moved from common.py into the Uvx provider. The language support guide memory is updated accordingly. --- .../adding_new_language_support_guide.md | 26 +++- src/solidlsp/language_servers/common.py | 37 ------ src/solidlsp/ls.py | 123 +++++++++++++++--- 3 files changed, 127 insertions(+), 59 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index bafa7526..2ac65b31 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -33,16 +33,30 @@ To implement a new language server using the DependencyProvider pattern: ``` The resource dir that is passed is the directory in which installed dependencies should be stored! -**Base Classes:** +**Base Classes** (choose the most specific one that fits): -- **`LanguageServerDependencyProviderSinglePath`** - For language servers with a single core dependency (e.g., an executable or JAR file) - - Provides automatic support for the `ls_path` custom setting, allowing users to override the core dependency path (if they have it installed it themselves) +- **`LanguageServerDependencyProviderUvx`** - For language servers distributed as a PyPI package, run on demand + via `uvx` / `uv x` (no installation step to implement) + - Simply instantiate it with the package name, pinned default version, entrypoint (console script) and optional `extra_args`; + the version can be overridden by the user via the configured `version_setting_key` custom setting + - Reference implementation: `PyrightServer` + +- **`LanguageServerDependencyProviderBaseCommand`** - For the common case where the + launch command is constructed from a *base command* (which the user can override via custom settings; handled generically) + - Implement `_create_default_base_command()` to return the default base command (executable + args), downloading/installing + dependencies beforehand if necessary + - Implement `_create_launch_command_from_base_command(base_command)` to add any further arguments, producing the + final launch command + +- **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case + of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new + implementations, prefer `...BaseCommand` - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary - Implement `_create_launch_command(core_path)` to build the full command from the core path - - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer`, `PyrightServer` + - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer` -- **`LanguageServerDependencyProvider`** - The base class, which can be directly inherited from for complex cases with multiple dependencies or custom setup - - Implement `create_launch_command()` directly +- **`LanguageServerDependencyProvider`** - The root base class, for complex cases with multiple dependencies or custom setup + - Implement `create_launch_command()` directly (note: no automatic support for user-level launch command overrides in this case) - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` **Implementation Pointers::** diff --git a/src/solidlsp/language_servers/common.py b/src/solidlsp/language_servers/common.py index ddc3fddd..4a28cca3 100644 --- a/src/solidlsp/language_servers/common.py +++ b/src/solidlsp/language_servers/common.py @@ -4,7 +4,6 @@ import logging import os import pathlib import platform -import shutil import subprocess from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace @@ -151,42 +150,6 @@ class RuntimeDependencyCollection: ) -DEFAULT_UVX_PYTHON_VERSION = "3.13" - - -def build_uvx_launch_command( - package: str, - version: str, - entrypoint: str, - extra_args: Sequence[str] = (), - python_version: str = DEFAULT_UVX_PYTHON_VERSION, -) -> list[str]: - """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. - - Resolution order: - 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). - 2. Fall back to ``uv x`` if only ``uv`` is on PATH. - 3. Raise ``RuntimeError`` if neither is available. - - :param package: PyPI package name (e.g. ``"pyright"``). - :param version: Pinned package version. - :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). - :param extra_args: Arguments appended after the entrypoint (e.g. ``("--stdio",)``). - :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). - """ - base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint, *extra_args] - - uvx_path = os.environ.get("UVX") or shutil.which("uvx") - if uvx_path is not None: - return [uvx_path, *base_args] - - uv_path = shutil.which("uv") - if uv_path is not None: - return [uv_path, "x", *base_args] - - raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") - - def build_npm_install_command(package_name: str, version: str, registry: str | None = None) -> list[str]: """Build a pinned npm install command for a package in a Serena-managed install directory.""" command = ["npm", "install", "--prefix", "./"] diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 558a9148..1147e1a7 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -22,7 +22,6 @@ from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types -from solidlsp.language_servers.common import build_uvx_launch_command from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer @@ -300,7 +299,42 @@ class LanguageServerDependencyProvider(ABC): return {} -class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvider, ABC): +class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): + """ + Special case of a dependency provider, where the launch command is constructed from a base command + (which can potentially be overridden by the user in LS-specific settings). + """ + + @abstractmethod + def _create_default_base_command(self) -> list[str]: + """ + Obtains the default base command for this language server, potentially downloading and installing dependencies + beforehand. + + Note: The user can override the base command, so this will only be run if no custom base command is provided. + + :return: the base command as a list containing the executable and its arguments + """ + + @abstractmethod + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + """ + Adds any additional arguments to the base command to create the final launch command. + + :param base_command: the base command + :return: the extended command + """ + + def create_launch_command(self) -> list[str]: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + base_command = self._create_default_base_command() + return self._create_launch_command_from_base_command(list(base_command)) + + +class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): """ Special case of a dependency provider, where there is a single core dependency which provides the basis for the launch command. @@ -308,6 +342,13 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) via the key "ls_path". If the user provides the key, the specified path is used directly. Otherwise, the provider implementation is called to get or install the core dependency. + + Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled + centrally, but implementations of this class do not necessarily follow the principle that + the base command is strictly extended. + Yet incompatibility arises only if (a) the user overrides the base command with a command that + has arguments, and (b) the implementation of this class does not construct the launch command + by appending arguments to the core dependency's path. """ @abstractmethod @@ -318,14 +359,6 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide :return: the core dependency's path (e.g. executable, jar, etc.) """ - def create_launch_command(self) -> list[str]: - path = self._custom_settings.get("ls_path", None) - if path is not None: - core_path = path - else: - core_path = self._get_or_install_core_dependency() - return self._create_launch_command(core_path) - @abstractmethod def _create_launch_command(self, core_path: str) -> list[str]: """ @@ -333,8 +366,33 @@ class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProvide :return: the launch command as a list containing the executable and its arguments """ + def _create_default_base_command(self) -> list[str]: + # We treat the core path as the only element of the base command, + # noting that the construction of the launch command from the base command + # is not necessarily to append arguments only. + core_path = self._get_or_install_core_dependency() + return [core_path] -class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + core_path = base_command[0] + cmd = self._create_launch_command(core_path) + if len(base_command) == 1: + # This is the regular case, where the base command consists only of the core + # dependency's path, and the launch command is constructed from it. + return cmd + else: + # In this case, the user has overridden the base command via LS-specific settings + # with a command that has arguments and therefore does not map cleanly to + # the single path assumption. However, in special cases where the full command + # was constructed by appending arguments to the core dependency's path, + # we simply assume that the provided base command can be substituted. + if cmd[0] == core_path: + return base_command + cmd[1:] + else: + raise ValueError("Language server base launch command with arguments unsupported") + + +class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand): """ Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. @@ -343,6 +401,8 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): already-installed language server executable, in which case it is launched directly, bypassing uv entirely. """ + DEFAULT_UVX_PYTHON_VERSION = "3.13" + def __init__( self, custom_settings: "SolidLSPSettings.CustomLSSettings", @@ -368,12 +428,43 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProvider): self._version_setting_key = version_setting_key self._extra_args = tuple(extra_args) - def create_launch_command(self) -> list[str]: - ls_path = self._custom_settings.get("ls_path") - if ls_path is not None: - return [ls_path, *self._extra_args] + @staticmethod + def _build_uvx_base_command( + package: str, + version: str, + entrypoint: str, + python_version: str = DEFAULT_UVX_PYTHON_VERSION, + ) -> list[str]: + """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. + + Resolution order: + 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). + 2. Fall back to ``uv x`` if only ``uv`` is on PATH. + 3. Raise ``RuntimeError`` if neither is available. + + :param package: PyPI package name (e.g. ``"pyright"``). + :param version: Pinned package version. + :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). + :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). + """ + base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint] + + uvx_path = os.environ.get("UVX") or shutil.which("uvx") + if uvx_path is not None: + return [uvx_path, *base_args] + + uv_path = shutil.which("uv") + if uv_path is not None: + return [uv_path, "x", *base_args] + + raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") + + def _create_default_base_command(self): version = self._custom_settings.get(self._version_setting_key, self._default_version) - return build_uvx_launch_command(self._package, version, self._entrypoint, self._extra_args) + return self._build_uvx_base_command(self._package, version, self._entrypoint) + + def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: + return base_command + list(self._extra_args) class SolidLanguageServer(ABC): From 60abdd5297be9d1b499a80bd0ebf5a3b2d8e1374 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 15:29:00 +0200 Subject: [PATCH 12/57] Support ls_args and ls_extra_args centrally in LanguageServerDependencyProviderBaseCommand * ls_args overrides the internal command builder, fully defining the arguments * ls_extra_args appends additional arguments to the launch command --- CHANGELOG.md | 1 + docs/02-usage/050_configuration.md | 36 ++++++++++++++++++------------ src/solidlsp/ls.py | 33 ++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..c616bbea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. + - Add new launch command customization options: `ls_args`, `ls_extra_args` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 8aa12542..ee00ea57 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -216,31 +216,39 @@ ls_specific_settings: ``` (override-ls-path)= -#### Overriding the Language Server Path +#### Customizing the Language Server Launch Command -Most of Serena's language servers, particularly those that use a single core path for the language server (e.g. the main executable), -support overriding that path via the `ls_path` setting. -Therefore, if you have installed the language server yourself and want to use your installation -instead of Serena's managed installation, you can set the `ls_path` setting as follows: +Most of Serena's language servers construct the command that launches the language server process from +a *base command* or a *core dependency*. +For these language servers, the following settings can be used to customize the launch command: + +| Setting | Description | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | + +* If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. + You can define the full launch command by providing both `ls_path` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. +* `ls_extra_args` is always appended to the end of the launch command. + +Example: ```yaml ls_specific_settings: : ls_path: "/path/to/language-server" + ls_extra_args: ["--log-level=debug"] ``` -This is supported by all language servers deriving their dependency provider from `LanguageServerDependencyProviderSinglePath`, -and by some additional wrappers that explicitly expose `ls_path`. +These settings are supported by all language servers whose dependency provider derives from +`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some wrappers explicitly. Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. -Note: `angular` does **not** support `ls_path` — the Angular language server is part of a multi-process orchestration -(`ngserver` plus a companion TypeScript language server with the `@angular/language-service` plugin and an HTML -companion) where the dependency layout matters; use the version overrides documented in the Angular section below -to pin specific releases of the bundled stack. - -If a language server supports `ls_path`, setting it bypasses Serena's managed download or install for that server. -In that case, any server-specific version or registry settings only apply when `ls_path` is not set. +If `ls_path` is set, Serena's managed download or install is bypassed for that language server. +In that case, any server-specific version or registry settings do not apply. #### AL diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 1147e1a7..7b116419 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -301,8 +301,14 @@ class LanguageServerDependencyProvider(ABC): class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): """ - Special case of a dependency provider, where the launch command is constructed from a base command - (which can potentially be overridden by the user in LS-specific settings). + Special case of a dependency provider, where the launch command is constructed from a base command. + + The user can configure aspects of the launch command in LS-specific settings: + + * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), + from which the base command is formed, bypassing Serena's managed installation + * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command + * ``ls_extra_args``: additional arguments to append to the launch command """ @abstractmethod @@ -326,12 +332,33 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid """ def create_launch_command(self) -> list[str]: + # obtain base command ls_path = self._custom_settings.get("ls_path", None) if ls_path is not None: base_command = [ls_path] else: base_command = self._create_default_base_command() - return self._create_launch_command_from_base_command(list(base_command)) + + # create launch command from base command + ls_args = self._custom_settings.get("ls_args") + if ls_args is not None: + if not isinstance(ls_args, list): + log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) + ls_args = None + if ls_args is not None: + cmd = list(base_command) + ls_args + else: + cmd = self._create_launch_command_from_base_command(list(base_command)) + + # add user-provided extra arguments (if any) + ls_extra_args = self._custom_settings.get("ls_extra_args", []) + if ls_extra_args: + if not isinstance(ls_extra_args, list): + log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) + else: + cmd = cmd + ls_extra_args + + return cmd class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): From 8c418dc7992c470bb4e181d10255f060b8543223 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 16:11:59 +0200 Subject: [PATCH 13/57] Support ls_base_cmd in LanguageServerDependencyProviderBaseCommand ls_base_cmd allows the user to override the base command with a multi-element command (e.g. ["npx", "-y", "/my/local/package"]), generalising ls_path, which it takes precedence over. --- CHANGELOG.md | 2 +- docs/02-usage/050_configuration.md | 16 ++++++++-------- src/solidlsp/ls.py | 17 ++++++++++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c616bbea..02c78676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,7 +59,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - - Add new launch command customization options: `ls_args`, `ls_extra_args` + - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ee00ea57..c69faaaf 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -222,15 +222,15 @@ Most of Serena's language servers construct the command that launches the langua a *base command* or a *core dependency*. For these language servers, the following settings can be used to customize the launch command: -| Setting | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ls_path` (string) | overrides the path of the language server's core dependency (e.g. its executable or a JAR file). Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | -| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | -| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | +| Setting | Description | +|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ls_path` (string) or `ls_base_cmd` (list) | overrides the path of the language server's core dependency (`ls_path`), e.g. its executable or a JAR file, or a base command for its execution (`ls_base_cmd`), e.g. `["npx", "-y", "/my/local/package"]`. Use this if you have installed the language server yourself and want Serena to use your installation instead of its managed installation. | +| `ls_args` (list) | overrides the internal command construction completely and simply adds `ls_args` to the base command | +| `ls_extra_args` (list) | a list of additional arguments to append to the launch command | * If you set `ls_args`, the internal command construction (which may do more than to append arguments to a base command) is bypassed. - You can define the full launch command by providing both `ls_path` and `ls_args`. -* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` to override the path of the core dependency. + You can define the full launch command by providing both `ls_path`/`ls_base_cmd` and `ls_args`. +* If `ls_args` is not set, the internal command construction (which sets default arguments) is applied, and you can use `ls_path` or `ls_base_cmd` to override the path of the core dependency/the base command. * `ls_extra_args` is always appended to the end of the launch command. Example: @@ -243,7 +243,7 @@ ls_specific_settings: ``` These settings are supported by all language servers whose dependency provider derives from -`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some wrappers explicitly. +`LanguageServerDependencyProviderBaseCommand`, and `ls_path` is additionally exposed by some implementations explicitly. Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, `hlsl`, `html`, `kotlin`, `lean4`, `luau`, `markdown`, `php`, `php_phpactor`, `python`, `rust`, `scss`, `solidity`, `systemverilog`, `toml`, `typescript`, and `yaml`. diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 7b116419..a769a3b3 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -305,6 +305,7 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid The user can configure aspects of the launch command in LS-specific settings: + * ``ls_base_cmd`: overrides the base command * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), from which the base command is formed, bypassing Serena's managed installation * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command @@ -333,11 +334,17 @@ class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvid def create_launch_command(self) -> list[str]: # obtain base command - ls_path = self._custom_settings.get("ls_path", None) - if ls_path is not None: - base_command = [ls_path] - else: - base_command = self._create_default_base_command() + base_command = self._custom_settings.get("ls_base_cmd", None) + if base_command is not None and not isinstance(base_command, list): + log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) + base_command = None + if base_command is None: + ls_path = self._custom_settings.get("ls_path", None) + if ls_path is not None: + base_command = [ls_path] + else: + # default case: base command is constructed by the provider implementation + base_command = self._create_default_base_command() # create launch command from base command ls_args = self._custom_settings.get("ls_args") From a7501b1c69574558790b7b9f43690b77d719b1cb Mon Sep 17 00:00:00 2001 From: bymebyu Date: Fri, 3 Jul 2026 02:39:28 +0900 Subject: [PATCH 14/57] Fix: language composition percentages diluted by unrecognised files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit determine_programming_language_composition computed percentages over the total file count, so files belonging to no supported language (images, plain text, licenses, lock files) diluted the numbers — a repository with 2 Python files and 23 assets reported Python at 8%. Percentages are now relative to files matching at least one supported language, and the counting runs in a single pass over the file list instead of one pass per language. The only consumer (ProjectConfig.autogenerate) sorts languages by percentage, which is unaffected by the uniform rescaling. --- CHANGELOG.md | 5 ++- src/serena/util/inspection.py | 48 ++++++++++++++++------------- test/serena/util/test_inspection.py | 45 +++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 23 deletions(-) create mode 100644 test/serena/util/test_inspection.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 387e9146..d6e516d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,10 @@ Status of the `main` branch. Changes prior to the next official version change w (which does not correctly unpack structured output) #1042 - Fix: Project-specific filtering of files for source files ignored the language backend. The check is really only possible for LSP. - - Fix: File system permission errors during gitignore scanning were not caught #1624 + - Fix: File system permission errors during gitignore scanning were not caught #1624 + - During project creation, language composition percentages are now computed relative to the total number + of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage + computation. * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index a0e3e96a..440d7c5b 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -31,36 +31,40 @@ def determine_programming_language_composition(repo_path: str) -> dict[Language, """ Determine the programming language composition of a repository. - :param repo_path: Path to the repository to analyze + Percentages are computed relative to the number of files that match at least + one supported language, not the total file count. This prevents files that + belong to no supported language (images, plain text, licenses, lock files, etc.) + from diluting language percentages in repositories where such files dominate. - :return: Dictionary mapping languages to percentages of files matching each language + :param repo_path: Path to the repository to analyze + :return: Dictionary mapping languages to percentages of recognised source files + matching each language (denominator = files matched by at least one language) """ all_files = find_all_non_ignored_files(repo_path) if not all_files: return {} - # Count files for each language + # collect all language matchers once + all_languages = list(Language.iter_all(include_experimental=False)) + matchers = {lang: lang.get_source_fn_matcher() for lang in all_languages} + + # count files per language in a single pass over the files language_counts: dict[Language, int] = {} - total_files = len(all_files) - - for language in Language.iter_all(include_experimental=False): - matcher = language.get_source_fn_matcher() - count = 0 - - for file_path in all_files: - # Use just the filename for matching, not the full path - filename = os.path.basename(file_path) + recognised_files = 0 + for file_path in all_files: + # Use just the filename for matching, not the full path + filename = os.path.basename(file_path) + matched_any = False + for lang, matcher in matchers.items(): if matcher.is_relevant_filename(filename): - count += 1 + language_counts[lang] = language_counts.get(lang, 0) + 1 + matched_any = True + if matched_any: + recognised_files += 1 - if count > 0: - language_counts[language] = count + if recognised_files == 0: + return {} - # Convert counts to percentages - language_percentages: dict[Language, float] = {} - for language, count in language_counts.items(): - percentage = (count / total_files) * 100 - language_percentages[language] = round(percentage, 2) - - return language_percentages + # convert to percentages relative to recognised source files only + return {lang: round(count / recognised_files * 100, 2) for lang, count in language_counts.items()} diff --git a/test/serena/util/test_inspection.py b/test/serena/util/test_inspection.py new file mode 100644 index 00000000..90378aac --- /dev/null +++ b/test/serena/util/test_inspection.py @@ -0,0 +1,45 @@ +"""Unit tests for :func:`serena.util.inspection.determine_programming_language_composition`.""" + +from pathlib import Path + +from serena.util.inspection import determine_programming_language_composition +from solidlsp.ls_config import Language + + +def _touch(directory: Path, *names: str) -> None: + for name in names: + (directory / name).write_text("content", encoding="utf-8") + + +class TestDetermineProgrammingLanguageComposition: + def test_single_language_repo(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py") + composition = determine_programming_language_composition(str(tmp_path)) + assert composition[Language.PYTHON] == 100.0 + + def test_unrecognised_files_do_not_dilute_percentages(self, tmp_path: Path) -> None: + # 2 source files vs many files that belong to no supported language + _touch(tmp_path, "main.py", "util.py") + _touch(tmp_path, *[f"note_{i}.txt" for i in range(20)]) + _touch(tmp_path, "logo.png", "LICENSE", "data.csv") + + composition = determine_programming_language_composition(str(tmp_path)) + + # previously: 2 / 25 files = 8% — now the denominator is recognised source files only + assert composition[Language.PYTHON] == 100.0 + + def test_mixed_language_percentages_relative_to_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "a.py", "b.py", "c.py", "d.go") + _touch(tmp_path, *[f"asset_{i}.dat" for i in range(50)]) + + composition = determine_programming_language_composition(str(tmp_path)) + + assert composition[Language.PYTHON] == 75.0 + assert composition[Language.GO] == 25.0 + + def test_repo_without_recognised_files(self, tmp_path: Path) -> None: + _touch(tmp_path, "readme.txt", "logo.png") + assert determine_programming_language_composition(str(tmp_path)) == {} + + def test_empty_repo(self, tmp_path: Path) -> None: + assert determine_programming_language_composition(str(tmp_path)) == {} From 6f1f05407d727a856f435ab1b22d84ffb3316f83 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 1 Jul 2026 16:43:29 +0200 Subject: [PATCH 15/57] Introduce InitializeParamsBuilder base class and default implementation which sets common settings centrally: - processId - rootPath - rootUri - clientInfo - workspaceFolders Apply builder in SolidLanguageServer, adding new abstract method _create_base_initialize_params to be implemented by subclasses. Applied in PyrightServer. --- src/solidlsp/initialize_params.py | 58 +++++++++++++++++++ .../language_servers/pyright_server.py | 21 +------ src/solidlsp/ls.py | 32 ++++++++++ 3 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 src/solidlsp/initialize_params.py diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py new file mode 100644 index 00000000..b8d81c0e --- /dev/null +++ b/src/solidlsp/initialize_params.py @@ -0,0 +1,58 @@ +import logging +import os +import pathlib +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Self, cast + +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, WorkspaceFolder + +if TYPE_CHECKING: + from solidlsp import SolidLanguageServer + + +log = logging.getLogger(__name__) + + +class InitializeParamsBuilder(ABC): + def __init__(self): + self._params = {} + + def with_base_options(self, options: dict | InitializeParams) -> Self: + self._params.update(options) + return self + + def _set(self, key: str, value: Any) -> None: + if key in self._params: + log.debug("Overriding existing option '%s' with new value: %s (old value: %s)", key, value, self._params[key]) + self._params[key] = value + + @abstractmethod + def _apply_updates(self) -> None: + """ + Applies implementation-specific updates to the options. + """ + + def build(self) -> InitializeParams: + self._apply_updates() + return cast(InitializeParams, self._params) + + +class DefaultInitializeParamsBuilder(InitializeParamsBuilder): + def __init__(self, ls: "SolidLanguageServer", set_workspace_folders: bool = True): + super().__init__() + self._ls = ls + self._set_workspace_folders = set_workspace_folders + + @staticmethod + def _create_workspace_folder_entry(path: str) -> WorkspaceFolder: + abs_path = os.path.abspath(path) + return {"uri": pathlib.Path(abs_path).as_uri(), "name": os.path.basename(abs_path)} + + def _apply_updates(self): + root_abs_path = self._ls.repository_root_path + self._set("processId", os.getpid()) + self._set("rootPath", root_abs_path) + self._set("rootUri", pathlib.Path(root_abs_path).as_uri()) + self._set("clientInfo", {"name": "Serena"}) + if self._set_workspace_folders: + self._set("workspaceFolders", [self._create_workspace_folder_entry(root_abs_path)]) diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index f369706c..b808d8f7 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -3,8 +3,6 @@ Provides Python specific instantiation of the LanguageServer class. Contains var """ import logging -import os -import pathlib import re import threading from typing import cast @@ -13,7 +11,6 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -59,16 +56,8 @@ class PyrightServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the Pyright Language Server. - """ - # Create basic initialization parameters + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "initializationOptions": { "exclude": [ "**/__pycache__", @@ -111,12 +100,8 @@ class PyrightServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [ - {"uri": pathlib.Path(repository_absolute_path).as_uri(), "name": os.path.basename(repository_absolute_path)} - ], } - - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -230,7 +215,7 @@ class PyrightServer(SolidLanguageServer): self.server.start() # Send proper initialization parameters - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to pyright server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index a769a3b3..ab130be5 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -22,6 +22,7 @@ from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types +from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer @@ -35,6 +36,7 @@ from solidlsp.lsp_protocol_handler.lsp_types import ( DefinitionParams, DocumentSymbol, ImplementationParams, + InitializeParams, LocationLink, RenameParams, SymbolInformation, @@ -3281,3 +3283,33 @@ class SolidLanguageServer(ABC): def is_running(self) -> bool: return self.server.is_running() + + def _create_initialize_params_builder(self) -> InitializeParamsBuilder: + return DefaultInitializeParamsBuilder(self) + + def _create_base_initialize_params(self) -> dict | InitializeParams: + """ + Subclasses should override this method to provide server-specific InitializeParams settings, + which provide the basis for the InitializeParams object sent to the language server during initialization. + + The returned dictionary is passed to the builder constructed by _create_initialize_params_builder() + in order to create the final InitializeParams object. + + The default builder implementation already sets the following keys, so implementations of this method + should not set them: + + - processId + - rootPath + - rootUri + - clientInfo + - workspaceFolders + + :return: the base InitializeParams settings + """ + raise NotImplementedError + + def _create_initialize_params(self) -> InitializeParams: + """ + Create the InitializeParams object to send to the language server during initialization. + """ + return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() From c904efed5e6369f2c7f71c989cd436400d419d44 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 20:56:19 +0200 Subject: [PATCH 16/57] SolidLSP: Allow workspace folders to be flexibly configured across all language servers that use the newly introduced InitializeParamsBuilder - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified explicitly. In monorepos, this allows the set of indexed folders to be restricted to a subset of the repository. #1627 - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` and support the option across all language servers that use an InitializeParamsBuilder (previously limited strictly to the TypeScript server). Moved the option from SolidLSPSettings to LanguageServerConfig, where it belongs. Apply InitializeParamsBuilder in TypeScriptLanguageServer and its subclasses. --- .serena/project.yml | 70 ++++---- CHANGELOG.md | 5 + docs/02-usage/040_workflow.md | 29 +--- docs/02-usage/050_configuration.md | 3 - src/serena/config/serena_config.py | 32 ++-- src/serena/ls_manager.py | 9 +- src/serena/project.py | 2 +- src/serena/resources/project.template.yml | 55 ++++--- src/solidlsp/initialize_params.py | 13 +- .../angular_language_server.py | 4 +- .../svelte_language_server.py | 4 +- .../typescript_language_server.py | 18 +-- .../language_servers/vue_language_server.py | 4 +- src/solidlsp/ls.py | 153 +++++++++--------- src/solidlsp/ls_config.py | 54 ++++++- test/conftest.py | 14 +- test/solidlsp/test_ls_common.py | 37 ++++- 17 files changed, 313 insertions(+), 193 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index f738bebf..e2cafdba 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,23 +1,25 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "serena" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown # matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript @@ -148,17 +150,6 @@ ls_specific_settings: {} # See https://oraios.github.io/serena/02-usage/050_configuration.html#modes added_modes: -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). -# Paths can be absolute or relative to the project root. -# Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. -# Example: -# additional_workspace_folders: -# - ../sibling-package -# - ../shared-lib -additional_workspace_folders: [] - # optional shell command to run before the language backend (LSP or JetBrains) is initialised. # the command runs in the project root directory and is only executed if the project is trusted # (see trusted_project_path_patterns in the global configuration). @@ -171,3 +162,26 @@ activation_command: # maximum time in seconds to wait for activation_command to complete before killing it (default 180s). # must be a positive number. activation_command_timeout: 180.0 + +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: +- "." + +# list of additional workspace folder paths for cross-package reference support. +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tool. +# Example: +# additional_workspace_folders: +# - "../sibling-package" +# - "../shared-lib" +ls_additional_workspace_folders: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 831b4824..3b1a34c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` + - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified + explicitly. In monorepos, this allows the set of indexed folders to be restricted to a subset of + the repository. #1627 + - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` + and support the option across all language servers (previously limited to TypeScript). * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index fa710498..b95c6f90 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -51,7 +51,7 @@ The file allows you to configure ... * the encoding used in source files * ignore rules * write access - * [additional workspace folders](additional-workspace-folders) for cross-package reference support in monorepos + * the list of workspace folders to be processed by language servers (when using the LSP backend) * an initial prompt that shall be passed to the LLM whenever the project is activated * a shell command to run upon project activation (prior to language backend initialisation) * the set of tools and modes to use for the project @@ -70,33 +70,6 @@ You can specify local overrides for the settings in a `project.local.yml` file i (which, by default, is ignored by git). Any keys defined therein will override the respective key in `project.yml`. -(additional-workspace-folders)= -#### Additional Workspace Folders (Cross-Package References) - -In monorepos or multi-package setups, Serena's language server normally only sees symbols within the -project root. To enable cross-package references (e.g. `find_referencing_symbols` discovering usages -in sibling packages), configure `additional_workspace_folders` in your `project.yml`: - -```yaml -additional_workspace_folders: - - ../shared-lib - - ../api-client - - /absolute/path/to/another-package -``` - -Paths can be absolute or relative to the project root. Each folder is registered as an LSP workspace -folder, and the language server will discover symbols and references across all listed packages. - -**Currently supported for:** TypeScript. Other language servers will raise an error if this setting -is used with them. Support for additional languages can be added by implementing the -`_find_representative_source_file()` method in the respective language server class. - -:::{note} -Each additional workspace folder adds startup time, as the language server needs to index the -additional projects. For large monorepos, consider listing only the packages you actively need -cross-references for. -::: - (indexing)= ### Indexing diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index c69faaaf..22a8872d 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1001,9 +1001,6 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | -TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package -reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. - #### Svelte Serena uses `svelte-language-server` for the `svelte` language key. Use `svelte` for Svelte projects instead of also listing `typescript`, unless you intentionally want multiple language servers active for the same files. diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 390eb2b2..6cb2a721 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -303,7 +303,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str languages: list[Language] ignored_paths: list[str] = field(default_factory=list) - additional_workspace_folders: list[str] = field(default_factory=list) + ls_workspace_folders: list[str] = field(default_factory=lambda: ["."]) + ls_additional_workspace_folders: list[str] = field(default_factory=list) read_only: bool = False ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" @@ -318,6 +319,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): SERENA_PROJECT_FILE = "project.yml" SERENA_LOCAL_PROJECT_FILE = "project.local.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} + RENAMED_FIELDS = {"additional_workspace_folders": "ls_additional_workspace_folders"} YAML_COMMENT_NORMALISATION = YamlCommentNormalisation.LEADING """ the comment normalisation strategy to use when loading/saving project configuration files. @@ -443,9 +445,22 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): and `was_complete` will always be True. """ data = load_yaml(yml_path, comment_normalisation=comment_normalisation) + was_complete = True + + # backward compatibility + # NOTE: This must also work for project.local.yml files, which may be highly incomplete + # * handle single "language" field + if "languages" not in data and "language" in data: + data["languages"] = [data["language"]] + del data["language"] + # * handle renamed fields + for old_key, new_key in cls.RENAMED_FIELDS.items(): + if old_key in data and new_key not in data: + data[new_key] = data[old_key] + del data[old_key] + was_complete = False # apply defaults - was_complete = True if apply_defaults: for field_info in dataclasses.fields(cls): key = field_info.name @@ -458,13 +473,6 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): default_value = get_dataclass_default(cls, key) data.setdefault(key, default_value) - # backward compatibility - # NOTE: This must also work for project.local.yml files, which may be highly incomplete - # * handle single "language" field - if "languages" not in data and "language" in data: - data["languages"] = [data["language"]] - del data["language"] - # Note: Checks for validity of fields must not happen here but in _from_dict. # Here, the data may be incomplete, because this function is also used for # loading project.local.yml files. @@ -480,6 +488,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): the ProjectConfig dataclass :param local_override_keys: the list of keys that have been overridden from project.local.yml """ + # map languages to list of enum items, checking for errors lang_name_mapping = {"javascript": "typescript"} languages: list[Language] = [] for language_str in data["languages"]: @@ -526,7 +535,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): fixed_tools = data["fixed_tools"] or [] excluded_tools = data["excluded_tools"] or [] included_optional_tools = data["included_optional_tools"] or [] - additional_workspace_folders = data.get("additional_workspace_folders") or [] + additional_workspace_folders = data.get("ls_additional_workspace_folders") or [] if "base_modes" in data and data["base_modes"] is not None: log.warning("The base_modes setting in project.yml is deprecated and will be ignored.") @@ -535,7 +544,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name=data["project_name"], languages=languages, ignored_paths=ignored_paths, - additional_workspace_folders=additional_workspace_folders, + ls_workspace_folders=data["ls_workspace_folders"], + ls_additional_workspace_folders=additional_workspace_folders, excluded_tools=excluded_tools, fixed_tools=fixed_tools, included_optional_tools=included_optional_tools, diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 11d6feab..3b44a9af 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -5,7 +5,7 @@ from collections.abc import Iterator from sensai.util.logging import LogTime -from serena.config.serena_config import SerenaPaths +from serena.config.serena_config import ProjectConfig, SerenaPaths from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings @@ -22,25 +22,27 @@ class LanguageServerFactory: def __init__( self, project_root: str, + project_config: ProjectConfig, project_data_path: str, encoding: str, ignored_patterns: list[str], ls_timeout: float | None = None, ls_specific_settings: dict | None = None, - additional_workspace_folders: list[str] | None = None, trace_lsp_communication: bool = False, ): self.project_root = project_root + self.project_config = project_config self.project_data_path = project_data_path self.encoding = encoding self.ignored_patterns = ignored_patterns self.ls_timeout = ls_timeout self.ls_specific_settings = ls_specific_settings - self.additional_workspace_folders = additional_workspace_folders or [] self.trace_lsp_communication = trace_lsp_communication def create_language_server(self, language: Language) -> SolidLanguageServer: ls_config = LanguageServerConfig( + workspace_folders=self.project_config.ls_workspace_folders, + additional_workspace_folders=self.project_config.ls_additional_workspace_folders, code_language=language, ignored_paths=self.ignored_patterns, trace_lsp_communication=self.trace_lsp_communication, @@ -56,7 +58,6 @@ class LanguageServerFactory: solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_path=self.project_data_path, ls_specific_settings=self.ls_specific_settings or {}, - additional_workspace_folders=self.additional_workspace_folders, ), ) diff --git a/src/serena/project.py b/src/serena/project.py index 8152a52a..87a54adb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -466,12 +466,12 @@ class Project(ToStringMixin): ) factory = LanguageServerFactory( project_root=self.project_root, + project_config=self.project_config, project_data_path=self._serena_data_folder, encoding=self.project_config.encoding, ignored_patterns=self._ignored_patterns, ls_timeout=ls_timeout, ls_specific_settings=ls_specific_settings, - additional_workspace_folders=self.project_config.additional_workspace_folders, trace_lsp_communication=self.serena_config.trace_lsp_communication, ) self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 5d0b7441..aa4247d7 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -1,23 +1,25 @@ -# the name by which the project can be referenced within Serena +# the name by which the project can be referenced within Serena/when chatting with the LLM. project_name: "project_name" - -# list of languages for which language servers are started; choose from: -# al angular ansible bash clojure -# cpp cpp_ccls crystal csharp csharp_omnisharp -# dart elixir elm erlang fortran -# fsharp go groovy haskell haxe -# hlsl html java json julia -# kotlin lean4 lua luau markdown +# list of languages for which language servers are started (LSP backend only); choose from: +# ada al angular ansible bash +# bsl clojure cpp cpp_ccls crystal +# csharp csharp_omnisharp cue dart elixir +# elm erlang fortran fsharp gdscript +# go groovy haskell haxe hlsl +# html java json julia kotlin +# latex lean4 lua luau markdown # matlab msl nix ocaml pascal -# perl php php_phpactor powershell python -# python_jedi python_ty r rego ruby -# ruby_solargraph rust scala scss solidity -# svelte swift systemverilog terraform toml -# typescript typescript_vts vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# perl php php_phpactor php_phpantom powershell +# python python_jedi python_pyrefly python_ty r +# rego ruby ruby_solargraph rust scala +# scss solidity svelte swift systemverilog +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated; generated with scripts/print_language_list.py; +# For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py) +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) # Note: # - For C, use cpp # - For JavaScript, use typescript @@ -71,16 +73,27 @@ ignore_all_files_in_gitignore: true # See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings ls_specific_settings: {} -# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# list of workspace folder paths (LSP backend only). +# These folders will be used to build up Serena's symbol index. +# Paths must be within the project root and should thus be relative to the project root. +# Furthermore, the paths should not be filtered by ignore settings. +# Default setting: The entire project root folder (".") is considered. +# In (large) monorepos, this can be used to index only subfolders of the project root, e.g. +# ls_workspace_folders: +# - "./subproject1" +# - "./subproject2" +ls_workspace_folders: ["."] + +# list of additional workspace folder paths for cross-package reference support. # Paths can be absolute or relative to the project root. # Each folder is registered as an LSP workspace folder, enabling language servers to discover -# symbols and references across package boundaries. -# Currently supported for: TypeScript. +# symbols and references across package boundaries, but these folders are not indexed by Serena, +# i.e. the respective symbols will not be found using Serena's symbol search tools. # Example: # additional_workspace_folders: # - ../sibling-package # - ../shared-lib -additional_workspace_folders: [] +ls_additional_workspace_folders: [] # list of additional paths to ignore in this project. # Same syntax as gitignore, so you can use * and **. diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py index b8d81c0e..4c3a8912 100644 --- a/src/solidlsp/initialize_params.py +++ b/src/solidlsp/initialize_params.py @@ -50,9 +50,20 @@ class DefaultInitializeParamsBuilder(InitializeParamsBuilder): def _apply_updates(self): root_abs_path = self._ls.repository_root_path + self._set("processId", os.getpid()) self._set("rootPath", root_abs_path) self._set("rootUri", pathlib.Path(root_abs_path).as_uri()) self._set("clientInfo", {"name": "Serena"}) + if self._set_workspace_folders: - self._set("workspaceFolders", [self._create_workspace_folder_entry(root_abs_path)]) + abs_workspace_paths = self._ls.config.get_absolute_workspace_folders(root_abs_path) + log.info("Workspace folders: %s", abs_workspace_paths) + workspace_folders = [self._create_workspace_folder_entry(abs_path) for abs_path in abs_workspace_paths] + additional_abs_workspace_paths = [ + p for p in self._ls.config.get_absolute_additional_workspace_folders(root_abs_path) if p not in abs_workspace_paths + ] + if additional_abs_workspace_paths: + log.info("Additional workspace folders (not indexed by SolidLSP): %s", additional_abs_workspace_paths) + workspace_folders.extend([self._create_workspace_folder_entry(abs_path) for abs_path in additional_abs_workspace_paths]) + self._set("workspaceFolders", workspace_folders) diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index 03d7414e..5edd1dca 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -166,8 +166,8 @@ class AngularTypeScriptServer(TypeScriptLanguageServer): ) @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() # Load @angular/language-service as a tsserver plugin via typescript-language-server's # initializationOptions.plugins API (the same API Vue uses for @vue/typescript-plugin). params["initializationOptions"] = { diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index c04730d8..8b65c822 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -120,8 +120,8 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): return "typescript" @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() params["initializationOptions"] = { "plugins": [ { diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 892843f5..0cab8c87 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -4,10 +4,9 @@ Provides TypeScript specific instantiation of the LanguageServer class. Contains import logging import os -import pathlib import shutil import threading -from typing import Any, cast +from typing import Any from overrides import override from sensai.util.logging import LogTime @@ -16,7 +15,6 @@ from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command @@ -224,11 +222,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): return "javascriptreact" return self.language_id - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - """ - Returns the initialize params for the TypeScript Language Server. - """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { "locale": "en", "capabilities": { @@ -258,12 +252,8 @@ class TypeScriptLanguageServer(SolidLanguageServer): "workDoneProgress": True, # Enables $/progress notifications for project loading }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": self._build_workspace_folders_param(repository_absolute_path), } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -366,7 +356,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): log.info("Starting TypeScript server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request from LSP client to LSP server and awaiting response", diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index f5112c0f..a29ab7f5 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -121,8 +121,8 @@ class VueTypeScriptServer(TypeScriptLanguageServer): ) @override - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - params = super()._get_initialize_params(repository_absolute_path) + def _create_base_initialize_params(self) -> dict: + params = super()._create_base_initialize_params() params["initializationOptions"] = { "plugins": [ diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ab130be5..11443129 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -11,6 +11,7 @@ from collections import defaultdict from collections.abc import Callable, Hashable, Iterator, Sequence from contextlib import contextmanager from copy import copy +from dataclasses import dataclass from pathlib import Path, PurePath from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast @@ -653,7 +654,7 @@ class SolidLanguageServer(ABC): Do not instantiate this class directly. Use `LanguageServer.create` method instead. - :param config: the global SolidLSP configuration. + :param config: the language server's configuration. :param repository_root_path: the root path of the repository. :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) the command used to start the actual language server. @@ -663,12 +664,13 @@ class SolidLanguageServer(ABC): notification by default. If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` to provide the appropriate identifier for each type of file. + :param solidlsp_settings: the global SolidLSP settings :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming from this specific language server. This should be incremented by subclasses calling this constructor whenever the format of the raw document symbols changes (typically because the language server improves/fixes its output). """ - self._solidlsp_config = config + self.config = config self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) @@ -746,28 +748,13 @@ class SolidLanguageServer(ABC): self._has_waited_for_cross_file_references = False - # resolving additional workspace folders - self._additional_workspace_abs_paths: list[str] = [] - _seen: set[str] = set() - for additional_workspace_path in self._solidlsp_settings.additional_workspace_folders: - if not additional_workspace_path or additional_workspace_path == ".": - continue - if not os.path.isabs(additional_workspace_path): - additional_workspace_abs_path = os.path.realpath(os.path.join(self.repository_root_path, additional_workspace_path)) - else: - additional_workspace_abs_path = str(Path(additional_workspace_path).resolve()) - if not os.path.isdir(additional_workspace_abs_path): - log.error( - "additional_workspace_folders: skipping non-existent directory %s (resolved to %s)", - additional_workspace_abs_path, - additional_workspace_abs_path, - ) - continue - if additional_workspace_abs_path in _seen: - log.info("additional_workspace_folders: skipping duplicate %s", additional_workspace_path) - continue - _seen.add(additional_workspace_abs_path) - self._additional_workspace_abs_paths.append(additional_workspace_abs_path) + self._abs_workspace_folders_indexed = self.config.get_absolute_workspace_folders(self.repository_root_path) + self._abs_workspace_folders_additional = self.config.get_absolute_additional_workspace_folders(self.repository_root_path) + """ + additional workspace folders, which are passed to the language server in the initialization request + but which are not indexed by SolidLSP, i.e. not traversed for full symbol tree + """ + self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ @@ -806,7 +793,7 @@ class SolidLanguageServer(ABC): language=self.language, determine_log_level=self._determine_log_level, logger=logging_fn, - start_independent_lsp_process=self._solidlsp_config.start_independent_lsp_process, + start_independent_lsp_process=self.config.start_independent_lsp_process, ) # --- diagnostics-related functions --- @@ -1208,6 +1195,48 @@ class SolidLanguageServer(ABC): """Check if a relative path traverses outside the workspace root via '..' components.""" return ".." in PurePath(relative_file_path).parts + @dataclass + class PathWorkspaceStatus: + """ + Represents the status of a path with respect to the language server's workspace folders. + """ + + ls: "SolidLanguageServer" + resolve_abs_path: Path + is_in_workspace_folder: bool + workspace_root: str | None = None + """ + the workspace root the path is within/relative to, if any + """ + + @classmethod + def from_abs_resolved_path(cls, path: Path, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param path: an absolute, resolved Path instance + :param ls: the language server instance + """ + for workspace in ls._abs_workspace_folders_all: + if path.is_relative_to(workspace): + return SolidLanguageServer.PathWorkspaceStatus( + ls=ls, is_in_workspace_folder=True, workspace_root=workspace, resolve_abs_path=path + ) + return SolidLanguageServer.PathWorkspaceStatus(ls=ls, is_in_workspace_folder=False, resolve_abs_path=path) + + @classmethod + def from_relative_path(cls, relative_path: str, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": + """ + :param relative_path: a relative path from the repository root + :param ls: the language server instance + """ + return cls.from_abs_resolved_path(pathlib.Path(ls.repository_root_path, relative_path).resolve(), ls) + + def check_within_workspace_or_raise(self): + if not self.is_in_workspace_folder: + raise ValueError( + f"Path {self.resolve_abs_path} is outside of configured workspaces. " + f"Configured workspaces: {self.ls._abs_workspace_folders_all}." + ) + def _resolve_file_uri(self, relative_file_path: str) -> str: """Construct a canonical file URI from a relative path. @@ -1217,36 +1246,9 @@ class SolidLanguageServer(ABC): p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)) if self._path_contains_dots(relative_file_path): p = p.resolve() - is_outside_of_configured_workspaces = True - configured_workspaces = [*self._additional_workspace_abs_paths, self.repository_root_path] - for workspace in configured_workspaces: - if p.is_relative_to(workspace): - is_outside_of_configured_workspaces = False - break - if is_outside_of_configured_workspaces: - raise ValueError( - f"Path {relative_file_path} contains '..' segments and is outside of configured workspaces. " - f"Configured workspaces: {configured_workspaces}. Resolved path: {p}." - ) + self.PathWorkspaceStatus.from_abs_resolved_path(p, self).check_within_workspace_or_raise() return p.as_uri() - def _build_workspace_folders_param(self, repository_absolute_path: str) -> list[dict[str, str]]: - """Build the ``workspaceFolders`` list for LSP initialization. - - Returns a list containing the primary workspace folder followed by any - additional workspace folders configured in settings. - """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - folders: list[dict[str, str]] = [ - {"uri": root_uri, "name": os.path.basename(repository_absolute_path)}, - ] - for abs_folder in self._additional_workspace_abs_paths: - folder_uri = pathlib.Path(abs_folder).as_uri() - folders.append({"uri": folder_uri, "name": os.path.basename(abs_folder)}) - if len(folders) > 1: - log.info("LSP multi-root workspace: %d folders", len(folders)) - return folders - def _activate_additional_workspaces(self) -> None: """Open a representative file from each additional workspace folder to trigger project loading in the language server. @@ -1260,7 +1262,7 @@ class SolidLanguageServer(ABC): enable this feature; the default implementation raises ``NotImplementedError``. """ opened_count = 0 - for additional_workspace in self._additional_workspace_abs_paths: + for additional_workspace in self._abs_workspace_folders_additional: source_file = self._find_representative_source_file(additional_workspace) if source_file is None: log.warning("No source file found in additional workspace folder: %s", additional_workspace) @@ -2179,24 +2181,17 @@ class SolidLanguageServer(ABC): If a directory is passed, all files within this directory will be considered. :return: A list of root symbols representing the top-level packages/modules in the project. """ - if within_relative_path is not None: - within_abs_path = os.path.join(self.repository_root_path, within_relative_path) - if not os.path.exists(within_abs_path): - raise FileNotFoundError(f"File or directory not found: {within_abs_path}") - if os.path.isfile(within_abs_path): - if self.is_ignored_path(within_relative_path): - log.error("You passed a file explicitly, but it is ignored. This is probably an error. File: %s", within_relative_path) - return [] - else: - root_nodes = self.request_document_symbols(within_relative_path).root_symbols - return root_nodes # Helper function to recursively process directories - def process_directory(rel_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_dir_path = self.repository_root_path if rel_dir_path == "." else os.path.join(self.repository_root_path, rel_dir_path) + def process_directory(abs_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: abs_dir_path = os.path.realpath(abs_dir_path) - if self.is_ignored_path(str(Path(abs_dir_path).relative_to(self.repository_root_path))): + rel_dir_path: str | None + try: + rel_dir_path = str(Path(abs_dir_path).relative_to(self.repository_root_path)) + except ValueError: # not relative to repository root + rel_dir_path = None + if rel_dir_path and self.is_ignored_path(rel_dir_path): log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) return [] @@ -2243,7 +2238,7 @@ class SolidLanguageServer(ABC): continue if os.path.isdir(contained_dir_or_file_abs_path): - child_symbols = process_directory(contained_dir_or_file_rel_path) + child_symbols = process_directory(contained_dir_or_file_abs_path) package_symbol["children"].extend(child_symbols) for child in child_symbols: child["parent"] = package_symbol @@ -2293,9 +2288,23 @@ class SolidLanguageServer(ABC): return result - # Start from the root or the specified directory - start_rel_path = within_relative_path or "." - return process_directory(start_rel_path) + if within_relative_path: + within_abs_path = os.path.join(self.repository_root_path, within_relative_path) + if not os.path.exists(within_abs_path): + raise FileNotFoundError(f"File or directory not found: {within_abs_path}") + if self.is_ignored_path(within_relative_path): + raise ValueError(f"Explicitly requested symbols in '{within_relative_path}' while the path is ignored") + if os.path.isfile(within_abs_path): + root_nodes = self.request_document_symbols(within_relative_path).root_symbols + return root_nodes + else: + self.PathWorkspaceStatus.from_abs_resolved_path(Path(within_abs_path).resolve(), self).check_within_workspace_or_raise() + return process_directory(within_abs_path) + else: + full_result = [] + for root in self.config.get_absolute_workspace_folders(self.repository_root_path): + full_result.extend(process_directory(root)) + return full_result @staticmethod def _get_range_from_file_content(file_content: str) -> ls_types.Range: diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 6be2fc80..c666a2df 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -2,15 +2,20 @@ Configuration objects for language servers """ +import logging +import os import re from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum +from pathlib import Path from typing import TYPE_CHECKING, Self if TYPE_CHECKING: from solidlsp import SolidLanguageServer +log = logging.getLogger(__name__) + class FilenameMatcher: def __init__(self, *file_extensions: str, case_sensitive: bool = True) -> None: @@ -826,13 +831,25 @@ class Language(str, Enum): raise ValueError(f"Unhandled language server class: {ls_class}") -@dataclass +@dataclass(frozen=True) class LanguageServerConfig: """ Configuration parameters """ code_language: Language + workspace_folders: list[str] = field(default_factory=lambda: ["."]) + """ + list of workspace folders to be used by the language server and to be fully indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders must be descendants of the project root. + """ + additional_workspace_folders: list[str] = field(default_factory=list) + """ + list of additional workspace folders to be passed to the language server, but which are not to be indexed by SolidLSP. + Paths can either be absolute or relative to the project root. + These folders can potentially be outside of the project root, e.g. for cross-package reference support. + """ trace_lsp_communication: bool = False start_independent_lsp_process: bool = True ignored_paths: list[str] = field(default_factory=list) @@ -845,3 +862,38 @@ class LanguageServerConfig: import inspect return cls(**{k: v for k, v in env.items() if k in inspect.signature(cls).parameters}) + + @staticmethod + def _absolute_workspace_folders(folders: list[str], project_root: str) -> list[str]: + abs_workspace_folders = [] + for path in folders: + if os.path.isabs(path): + abs_path = str(Path(path).resolve()) + else: + abs_path = os.path.realpath(os.path.join(project_root, path)) + if not os.path.exists(abs_path): + log.error("Workspace folder does not exist: %s; skipping", abs_path) + continue + if abs_path in abs_workspace_folders: + log.warning("Duplicate workspace folder: %s; skipping", abs_path) + continue + abs_workspace_folders.append(abs_path) + return abs_workspace_folders + + def get_absolute_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute workspace folder paths + """ + return self._absolute_workspace_folders(self.workspace_folders, project_root) + + def get_absolute_additional_workspace_folders(self, project_root: str) -> list[str]: + """ + Get the absolute paths of the additional workspace folders, resolving relative paths against the project root. + + :param project_root: The root path of the project + :return: List of absolute additional workspace folder paths + """ + return self._absolute_workspace_folders(self.additional_workspace_folders, project_root) diff --git a/test/conftest.py b/test/conftest.py index a8cf3394..fc046273 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -66,6 +66,7 @@ def _create_ls( ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, additional_workspace_folders: list[str] | None = None, solidlsp_dir: Path | None = None, ) -> SolidLanguageServer: @@ -79,6 +80,8 @@ def _create_ls( code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication, + workspace_folders=workspace_folders or ["."], + additional_workspace_folders=additional_workspace_folders or [], ) effective_solidlsp_dir = solidlsp_dir if solidlsp_dir is not None else SerenaPaths().serena_user_home_dir project_data_path = os.path.join(repo_path, SERENA_MANAGED_DIR_NAME) @@ -89,7 +92,6 @@ def _create_ls( solidlsp_dir=effective_solidlsp_dir, project_data_path=project_data_path, ls_specific_settings=ls_specific_settings or {}, - additional_workspace_folders=additional_workspace_folders or [], ), ) @@ -101,11 +103,19 @@ def start_ls_context( ignored_paths: list[str] | None = None, trace_lsp_communication: bool = False, ls_specific_settings: dict[Language, dict[str, Any]] | None = None, + workspace_folders: list[str] | None = None, additional_workspace_folders: list[str] | None = None, solidlsp_dir: Path | None = None, ) -> Iterator[SolidLanguageServer]: ls = _create_ls( - language, repo_path, ignored_paths, trace_lsp_communication, ls_specific_settings, additional_workspace_folders, solidlsp_dir + language, + repo_path, + ignored_paths, + trace_lsp_communication, + ls_specific_settings, + workspace_folders, + additional_workspace_folders, + solidlsp_dir, ) log.info(f"Starting language server for {language} {repo_path}") with ls.start_server_context(): diff --git a/test/solidlsp/test_ls_common.py b/test/solidlsp/test_ls_common.py index cfa1d11d..fab8f215 100644 --- a/test/solidlsp/test_ls_common.py +++ b/test/solidlsp/test_ls_common.py @@ -1,9 +1,12 @@ import os +from collections.abc import Sequence import pytest from solidlsp import SolidLanguageServer -from test.conftest import PYTHON_LANGUAGE_BACKENDS +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils +from test.conftest import PYTHON_LANGUAGE_BACKENDS, start_ls_context class TestLanguageServerCommonFunctionality: @@ -41,3 +44,35 @@ class TestLanguageServerCommonFunctionality: finally: os.remove(file_path) + + def test_workspace_folders_affect_full_symbol_tree(self): + """ + Tests that workspace folder configurations are respected when requesting the full symbol tree. + """ + + def check(langsrv: SolidLanguageServer, present: Sequence[str] = (), absent: Sequence[str] = ()) -> None: + symbols = langsrv.request_full_symbol_tree() + for name in present: + assert SymbolUtils.symbol_tree_contains_name(symbols, name) + for name in absent: + assert not SymbolUtils.symbol_tree_contains_name(symbols, name) + + symbols_in_subfolder_test_repo = ["OuterClass", "UserService"] + symbols_in_subfolder_scripts = ["parse_args", "create_sample_users"] + all_symbols = symbols_in_subfolder_test_repo + symbols_in_subfolder_scripts + + with start_ls_context(language=Language.PYTHON, workspace_folders=["."]) as ls: + ls.request_full_symbol_tree() + check(ls, present=all_symbols) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./test_repo"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_test_repo, absent=symbols_in_subfolder_scripts) + + with start_ls_context(language=Language.PYTHON, workspace_folders=["./scripts"]) as ls: + ls.request_full_symbol_tree() + check(ls, present=symbols_in_subfolder_scripts, absent=symbols_in_subfolder_test_repo) + + # test that explicit requests for symbols in other workspace folders are rejected + with pytest.raises(ValueError, match="outside"): + ls.request_full_symbol_tree(within_relative_path="./test_repo") From 1c12156901624ead484cfdbb1f3eee3390e7193d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 2 Jul 2026 23:41:56 +0200 Subject: [PATCH 17/57] SolidLSP: Apply InitializeParamsBuilder across all remaining language servers Convert every remaining SolidLanguageServer subclass from the old _get_initialize_params method to the new _create_base_initialize_params override, so common LSP initialize keys (processId, rootPath, rootUri, clientInfo, workspaceFolders) are provided centrally by the InitializeParamsBuilder rather than by each server. - Drop the per-server processId/rootPath/rootUri/workspaceFolders and all clientInfo entries (including the VS Code spoofs in rust_analyzer and sourcekit); these are now set centrally. - Preserve nested capabilities.workspace.workspaceFolders flags and any server-specific initializationOptions.workspaceFolders (e.g. kotlin, eclipse_jdtls). - omnisharp keeps loading its JSON template but no longer sets the centrally-managed keys. - Update _start_server call sites to self._create_initialize_params() and drop now-unnecessary cast()/type: ignore wrappers. angular, svelte and vue are intentionally left as-is: they already override _create_base_initialize_params for their TypeScript companion server, and their remaining _get_initialize_params drives a separate language server process. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../language_servers/ada_language_server.py | 16 +--- .../language_servers/al_language_server.py | 16 +--- .../ansible_language_server.py | 19 +---- .../language_servers/bash_language_server.py | 19 +---- .../language_servers/bsl_language_server.py | 20 ++--- .../language_servers/ccls_language_server.py | 22 +---- .../clangd_language_server.py | 21 +---- src/solidlsp/language_servers/clojure_lsp.py | 14 +-- .../crystal_language_server.py | 20 +---- .../csharp_language_server.py | 85 +++++++++---------- .../language_servers/cue_language_server.py | 16 +--- .../language_servers/dart_language_server.py | 20 +---- .../language_servers/eclipse_jdtls.py | 21 ++--- .../elixir_tools/elixir_tools.py | 17 +--- .../language_servers/elm_language_server.py | 20 +---- .../fortran_language_server.py | 20 +---- .../fsharp_language_server.py | 14 +-- .../language_servers/godot_language_server.py | 14 +-- src/solidlsp/language_servers/gopls.py | 20 +---- .../groovy_language_server.py | 23 +---- .../haskell_language_server.py | 20 +---- .../language_servers/haxe_language_server.py | 21 ++--- .../language_servers/hlsl_language_server.py | 16 +--- src/solidlsp/language_servers/intelephense.py | 19 +---- src/solidlsp/language_servers/jedi_server.py | 22 +---- .../language_servers/json_language_server.py | 19 +---- src/solidlsp/language_servers/julia_server.py | 19 +---- .../kotlin_language_server.py | 24 +----- .../language_servers/lean4_language_server.py | 21 +---- src/solidlsp/language_servers/lua_ls.py | 20 +---- src/solidlsp/language_servers/luau_lsp.py | 20 +---- src/solidlsp/language_servers/marksman.py | 23 ++--- .../matlab_language_server.py | 21 ++--- .../language_servers/msl_language_server.py | 14 +-- src/solidlsp/language_servers/nixd_ls.py | 18 +--- .../language_servers/ocaml_lsp_server.py | 20 +---- src/solidlsp/language_servers/omnisharp.py | 23 ++--- .../language_servers/pascal_server.py | 20 +---- .../language_servers/perl_language_server.py | 21 +---- src/solidlsp/language_servers/phpactor.py | 18 +--- src/solidlsp/language_servers/phpantom.py | 21 +---- .../powershell_language_server.py | 19 +---- .../language_servers/pyrefly_server.py | 17 ++-- .../language_servers/r_language_server.py | 20 +---- src/solidlsp/language_servers/regal_server.py | 22 +---- src/solidlsp/language_servers/ruby_lsp.py | 12 +-- .../language_servers/rust_analyzer.py | 20 +---- .../language_servers/scala_language_server.py | 13 +-- src/solidlsp/language_servers/solargraph.py | 25 ++---- .../solidity_language_server.py | 22 ++--- .../some_sass_language_server.py | 19 +---- .../language_servers/sourcekit_lsp.py | 21 +---- .../language_servers/systemverilog_server.py | 16 +--- src/solidlsp/language_servers/taplo_server.py | 20 +---- src/solidlsp/language_servers/terraform_ls.py | 21 +---- .../texlab_language_server.py | 21 +---- src/solidlsp/language_servers/ty_server.py | 17 +--- .../vscode_html_language_server.py | 19 +---- .../language_servers/vts_language_server.py | 19 +---- .../language_servers/yaml_language_server.py | 19 +---- src/solidlsp/language_servers/zls.py | 18 +--- .../solidlsp/csharp/test_omnisharp_startup.py | 35 -------- 62 files changed, 252 insertions(+), 1020 deletions(-) delete mode 100644 test/solidlsp/csharp/test_omnisharp_startup.py diff --git a/src/solidlsp/language_servers/ada_language_server.py b/src/solidlsp/language_servers/ada_language_server.py index bf015bd4..30040ec2 100644 --- a/src/solidlsp/language_servers/ada_language_server.py +++ b/src/solidlsp/language_servers/ada_language_server.py @@ -9,15 +9,12 @@ pragmas/aspects in source rather than file extension, so a single import logging import os -import pathlib import threading -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -155,15 +152,9 @@ class AdaLanguageServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialization params for the Ada Language Server.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() - result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -188,9 +179,8 @@ class AdaLanguageServer(SolidLanguageServer): }, "initializationOptions": {}, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start the Ada Language Server process and complete the LSP handshake.""" @@ -213,7 +203,7 @@ class AdaLanguageServer(SolidLanguageServer): log.info("Starting ada_language_server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/al_language_server.py b/src/solidlsp/language_servers/al_language_server.py index f954dbc7..f67ee2ba 100644 --- a/src/solidlsp/language_servers/al_language_server.py +++ b/src/solidlsp/language_servers/al_language_server.py @@ -7,7 +7,6 @@ You can pass the following entries in ``ls_specific_settings["al"]``: import logging import os -import pathlib import platform import re import stat @@ -448,20 +447,12 @@ class ALLanguageServer(SolidLanguageServer): return None - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> dict: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the AL Language Server. """ - # Ensure we have an absolute path for URI generation - repository_path = pathlib.Path(repository_absolute_path).resolve() - root_uri = repository_path.as_uri() - # AL requires extensive capabilities based on VS Code trace initialize_params = { - "processId": os.getpid(), - "rootPath": str(repository_path), - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -512,7 +503,6 @@ class ALLanguageServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [{"uri": root_uri, "name": repository_path.name}], } return initialize_params @@ -556,12 +546,12 @@ class ALLanguageServer(SolidLanguageServer): self.server.start() # Send initialize request - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to AL LSP server and awaiting response") # Send initialize and wait for response - resp = self.server.send_request("initialize", initialize_params) + resp = self.server.send_request("initialize", dict(initialize_params)) if resp is None: raise RuntimeError("AL Language Server initialization failed - no response") diff --git a/src/solidlsp/language_servers/ansible_language_server.py b/src/solidlsp/language_servers/ansible_language_server.py index 3fb1a3b6..39edcdb1 100644 --- a/src/solidlsp/language_servers/ansible_language_server.py +++ b/src/solidlsp/language_servers/ansible_language_server.py @@ -6,7 +6,6 @@ Contains various configurations and settings specific to Ansible YAML files (pla import fnmatch import logging import os -import pathlib import shutil from typing import Any, ClassVar @@ -15,7 +14,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -217,14 +215,12 @@ class AnsibleLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the Ansible Language Server. Reads shortcut keys and the ``ansible_settings`` dict from ``_custom_settings`` to build ``initializationOptions``. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # default ansible settings, populated from shortcut keys ansible_settings: dict[str, Any] = { "ansible": { @@ -282,18 +278,9 @@ class AnsibleLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": {"ansible": ansible_settings}, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Starts the Ansible Language Server, waits for the server to be ready.""" @@ -325,7 +312,7 @@ class AnsibleLanguageServer(SolidLanguageServer): log.info("Starting Ansible server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/bash_language_server.py b/src/solidlsp/language_servers/bash_language_server.py index 0338a5ef..5d52bbbc 100644 --- a/src/solidlsp/language_servers/bash_language_server.py +++ b/src/solidlsp/language_servers/bash_language_server.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to Bash scripting. import logging import os -import pathlib import shutil import threading @@ -19,7 +18,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -208,12 +206,10 @@ class BashLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "start"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Bash Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -237,17 +233,8 @@ class BashLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -283,7 +270,7 @@ class BashLanguageServer(SolidLanguageServer): log.info("Starting Bash server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/bsl_language_server.py b/src/solidlsp/language_servers/bsl_language_server.py index 98aaa0e1..b3fc8f89 100644 --- a/src/solidlsp/language_servers/bsl_language_server.py +++ b/src/solidlsp/language_servers/bsl_language_server.py @@ -14,7 +14,6 @@ You can configure the following options in ls_specific_settings (in serena_confi import logging import os -import pathlib import re import shutil import subprocess @@ -27,7 +26,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -166,10 +164,8 @@ class BSLLanguageServer(SolidLanguageServer): _verify_java_available() return ["java", "-jar", core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() - return { # type: ignore[return-value] + def _create_base_initialize_params(self) -> dict: + return { "locale": "en", "capabilities": { "textDocument": { @@ -179,9 +175,9 @@ class BSLLanguageServer(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { @@ -190,12 +186,6 @@ class BSLLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - {"uri": root_uri, "name": os.path.basename(repository_absolute_path)}, - ], } def _start_server(self) -> None: @@ -216,7 +206,7 @@ class BSLLanguageServer(SolidLanguageServer): log.info("Starting BSL language server process") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("BSL LSP initialize response: %s", init_response) diff --git a/src/solidlsp/language_servers/ccls_language_server.py b/src/solidlsp/language_servers/ccls_language_server.py index a27d80d6..1c7c1fa6 100644 --- a/src/solidlsp/language_servers/ccls_language_server.py +++ b/src/solidlsp/language_servers/ccls_language_server.py @@ -29,10 +29,8 @@ https://github.com/MaskRay/ccls """ import logging -import os -import pathlib import threading -from typing import Any, cast +from typing import Any from overrides import override @@ -43,7 +41,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -105,12 +102,10 @@ class CCLS(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the ccls Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -123,18 +118,9 @@ class CCLS(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], # ccls supports initializationOptions but none are required for basic functionality } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -154,7 +140,7 @@ class CCLS(SolidLanguageServer): log.info("Starting ccls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to ccls and awaiting response") self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/clangd_language_server.py b/src/solidlsp/language_servers/clangd_language_server.py index b5c5d40a..4db49125 100644 --- a/src/solidlsp/language_servers/clangd_language_server.py +++ b/src/solidlsp/language_servers/clangd_language_server.py @@ -2,10 +2,9 @@ import hashlib import json import logging import os -import pathlib import threading from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override @@ -13,7 +12,6 @@ from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection, is_unreal_engine_project @@ -315,12 +313,10 @@ class ClangdLanguageServer(SolidLanguageServer): # which is required for finding cross-file references return [core_path, "--background-index"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the clangd Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -336,18 +332,9 @@ class ClangdLanguageServer(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -403,7 +390,7 @@ class ClangdLanguageServer(SolidLanguageServer): log.info("Starting Clangd server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/clojure_lsp.py b/src/solidlsp/language_servers/clojure_lsp.py index 1835ce99..2a8554d1 100644 --- a/src/solidlsp/language_servers/clojure_lsp.py +++ b/src/solidlsp/language_servers/clojure_lsp.py @@ -9,13 +9,11 @@ import re import shutil import subprocess import threading -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -308,10 +306,8 @@ class ClojureLSP(SolidLanguageServer): log.info(f"clojure-lsp source-paths scanned from project descriptors: {scanned}") return scanned - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the init params for clojure-lsp.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() source_paths = self._resolve_source_paths() initialization_options: dict = {"dependency-scheme": "jar", "text-document-sync-kind": "incremental"} @@ -319,9 +315,6 @@ class ClojureLSP(SolidLanguageServer): initialization_options["source-paths"] = source_paths result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -344,9 +337,8 @@ class ClojureLSP(SolidLanguageServer): }, "initializationOptions": initialization_options, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: def register_capability_handler(params: dict) -> None: @@ -389,7 +381,7 @@ class ClojureLSP(SolidLanguageServer): log.info("Starting clojure-lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/crystal_language_server.py b/src/solidlsp/language_servers/crystal_language_server.py index 75ce704f..2e04c995 100644 --- a/src/solidlsp/language_servers/crystal_language_server.py +++ b/src/solidlsp/language_servers/crystal_language_server.py @@ -3,14 +3,11 @@ Provides Crystal specific instantiation of the LanguageServer class using Crysta """ import logging -import os -import pathlib import shutil import time from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -83,12 +80,10 @@ class CrystalLanguageServer(SolidLanguageServer): log.info(f"Waiting {remaining_delay:.1f}s for Crystalline to compile the project") time.sleep(remaining_delay) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Return the initialize params for the Crystal language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -119,17 +114,8 @@ class CrystalLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start the Crystal language server process.""" @@ -150,7 +136,7 @@ class CrystalLanguageServer(SolidLanguageServer): log.info("Starting Crystal language server (Crystalline) process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index 296acad0..1281c5a0 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -26,7 +26,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Hover from solidlsp.ls_utils import FileUtils, PathUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -486,60 +486,51 @@ class CSharpLanguageServer(SolidLanguageServer): except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. """ - root_uri = PathUtils.path_to_uri(self.repository_root_path) - root_name = os.path.basename(self.repository_root_path) - return cast( - InitializeParams, - { - "workspaceFolders": [{"uri": root_uri, "name": root_name}], - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": root_uri, - "capabilities": { - "window": { - "workDoneProgress": True, - "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, - "showDocument": {"support": True}, + return { + "capabilities": { + "window": { + "workDoneProgress": True, + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + }, + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "workspace": { - "applyEdit": True, - "workspaceEdit": {"documentChanges": True}, - "didChangeConfiguration": {"dynamicRegistration": True}, - "didChangeWatchedFiles": {"dynamicRegistration": True}, - "symbol": { - "dynamicRegistration": True, - "symbolKind": {"valueSet": list(range(1, 27))}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + "workDoneProgress": True, + }, + "textDocument": { + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, }, - "executeCommand": {"dynamicRegistration": True}, - "configuration": True, - "workspaceFolders": True, - "workDoneProgress": True, }, - "textDocument": { - "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, - "signatureHelp": { - "dynamicRegistration": True, - "signatureInformation": { - "documentationFormat": ["markdown", "plaintext"], - "parameterInformation": {"labelOffsetSupport": True}, - }, - }, - "definition": {"dynamicRegistration": True}, - "references": {"dynamicRegistration": True}, - "documentSymbol": { - "dynamicRegistration": True, - "symbolKind": {"valueSet": list(range(1, 27))}, - "hierarchicalDocumentSymbolSupport": True, - }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, }, }, }, - ) + } def _start_server(self) -> None: indexing_complete = threading.Event() @@ -672,7 +663,7 @@ class CSharpLanguageServer(SolidLanguageServer): raise SolidLSPException(f"Failed to start C# language server: {e}") # Send initialization - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request to language server") try: diff --git a/src/solidlsp/language_servers/cue_language_server.py b/src/solidlsp/language_servers/cue_language_server.py index b9a90a33..444c5f26 100644 --- a/src/solidlsp/language_servers/cue_language_server.py +++ b/src/solidlsp/language_servers/cue_language_server.py @@ -7,15 +7,12 @@ from __future__ import annotations import logging import os -import pathlib import threading -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -184,15 +181,9 @@ class CueLanguageServer(SolidLanguageServer): # cue's LSP mode is activated via the `lsp` subcommand; it speaks LSP over stdio. return [core_path, "lsp"] - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the init params for ``cue lsp``.""" - repository_absolute_path = self.repository_root_path - root_uri = pathlib.Path(repository_absolute_path).as_uri() - result = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -221,9 +212,8 @@ class CueLanguageServer(SolidLanguageServer): }, "initializationOptions": {}, "trace": "off", - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: def register_capability_handler(params: dict) -> None: @@ -252,7 +242,7 @@ class CueLanguageServer(SolidLanguageServer): log.info("Starting cue lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/dart_language_server.py b/src/solidlsp/language_servers/dart_language_server.py index 800f69eb..59fd4327 100644 --- a/src/solidlsp/language_servers/dart_language_server.py +++ b/src/solidlsp/language_servers/dart_language_server.py @@ -1,8 +1,6 @@ import logging import os -import pathlib from collections.abc import Hashable -from typing import cast from overrides import override @@ -11,7 +9,6 @@ from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from ..ls_config import Language, LanguageServerConfig -from ..lsp_protocol_handler.lsp_types import InitializeParams from .common import RuntimeDependency, RuntimeDependencyCollection log = logging.getLogger(__name__) @@ -149,12 +146,10 @@ class DartLanguageServer(SolidLanguageServer): return f"{dart_executable_path} language-server --client-id multilspy.dart --client-version 1.2" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Dart Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "capabilities": { "textDocument": { @@ -171,18 +166,9 @@ class DartLanguageServer(SolidLanguageServer): "allowOpenUri": False, }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -212,7 +198,7 @@ class DartLanguageServer(SolidLanguageServer): log.info("Starting dart-language-server server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to dart-language-server") init_response = self.server.send_request("initialize", initialize_params) # type: ignore log.info(f"Received initialize response from dart-language-server: {init_response}") diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 4614d9fe..320f7ed1 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -25,7 +25,7 @@ from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import UnifiedSymbolInformation from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -904,15 +904,13 @@ class EclipseJDTLS(SolidLanguageServer): log.info(f"Using bundled JRE for JDTLS: {java_home}") return {"syntaxserver": "false", "JAVA_HOME": java_home} - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize parameters for the EclipseJDTLS server. """ # Look into https://github.com/eclipse/eclipse.jdt.ls/blob/master/org.eclipse.jdt.ls.core/src/org/eclipse/jdt/ls/core/internal/preferences/Preferences.java to understand all the options available - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - repo_uri = pathlib.Path(repository_absolute_path).as_uri() + repo_uri = pathlib.Path(self.repository_root_path).as_uri() # Load user's Maven and Gradle configuration paths from ls_specific_settings["java"] @@ -996,8 +994,6 @@ class EclipseJDTLS(SolidLanguageServer): initialize_params = { "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": pathlib.Path(repository_absolute_path).as_uri(), "capabilities": { "workspace": { "applyEdit": True, @@ -1290,13 +1286,6 @@ class EclipseJDTLS(SolidLanguageServer): }, }, "trace": "verbose", - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": repo_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } initialize_params["initializationOptions"]["workspaceFolders"] = [repo_uri] @@ -1324,7 +1313,7 @@ class EclipseJDTLS(SolidLanguageServer): if self.runtime_dependency_paths.gradle_path is not None: gradle_settings["home"] = self.runtime_dependency_paths.gradle_path gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -1377,7 +1366,7 @@ class EclipseJDTLS(SolidLanguageServer): log.info("Starting EclipseJDTLS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 6005df89..d80e43e3 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -5,14 +5,13 @@ import stat import subprocess import threading from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -256,19 +255,12 @@ class ElixirTools(SolidLanguageServer): return None - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Expert Language Server. """ - # Ensure the path is absolute - abs_path = os.path.abspath(repository_absolute_path) - root_uri = pathlib.Path(abs_path).as_uri() initialize_params = { - "processId": os.getpid(), "locale": "en", - "rootPath": abs_path, - "rootUri": root_uri, "initializationOptions": { "mix_env": "dev", "mix_target": "host", @@ -319,10 +311,9 @@ class ElixirTools(SolidLanguageServer): "workDoneProgress": True, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start Expert server process""" @@ -381,7 +372,7 @@ class ElixirTools(SolidLanguageServer): log.debug("Starting Expert server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to Expert") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/elm_language_server.py b/src/solidlsp/language_servers/elm_language_server.py index e5823811..bd488500 100644 --- a/src/solidlsp/language_servers/elm_language_server.py +++ b/src/solidlsp/language_servers/elm_language_server.py @@ -4,7 +4,6 @@ Provides Elm specific instantiation of the LanguageServer class. Contains variou import logging import os -import pathlib import shutil import threading @@ -14,7 +13,6 @@ from sensai.util.logging import LogTime from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -131,13 +129,10 @@ class ElmLanguageServer(SolidLanguageServer): ) return [elm_ls_executable_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Elm Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { "locale": "en", "capabilities": { @@ -169,17 +164,8 @@ class ElmLanguageServer(SolidLanguageServer): "skipInstallPackageConfirmation": True, "onlyUpdateDiagnosticsOnSave": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -204,7 +190,7 @@ class ElmLanguageServer(SolidLanguageServer): log.info("Starting Elm server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/fortran_language_server.py b/src/solidlsp/language_servers/fortran_language_server.py index 39498971..134460ab 100644 --- a/src/solidlsp/language_servers/fortran_language_server.py +++ b/src/solidlsp/language_servers/fortran_language_server.py @@ -3,8 +3,6 @@ Fortran Language Server implementation using fortls. """ import logging -import os -import pathlib import re from overrides import override @@ -19,7 +17,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -190,10 +187,8 @@ class FortranLanguageServer(SolidLanguageServer): version_setting_key="fortls_version", ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Initialize params for Fortran Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -231,17 +226,8 @@ class FortranLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Fortran Language Server process.""" @@ -264,7 +250,7 @@ class FortranLanguageServer(SolidLanguageServer): log.info("Starting Fortran Language Server (fortls) process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Fortran Language Server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index a8cd6847..4b4fc820 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -4,7 +4,6 @@ Provides F# specific instantiation of the LanguageServer class. import logging import os -import pathlib import shutil import threading from pathlib import Path @@ -16,7 +15,6 @@ from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependenc from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -129,17 +127,11 @@ class FSharpLanguageServer(SolidLanguageServer): # FsAutoComplete uses --lsp flag for LSP mode return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the F# Language Server. """ - root_uri = pathlib.Path(self.repository_root_path).as_uri() - initialize_params = { - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": root_uri, - "workspaceFolders": [{"name": "workspace", "uri": root_uri}], "capabilities": { "workspace": { "applyEdit": True, @@ -269,7 +261,7 @@ class FSharpLanguageServer(SolidLanguageServer): "trace": "off", } - return initialize_params # type: ignore + return initialize_params def _get_dotnet_root(self) -> str: """ @@ -362,7 +354,7 @@ class FSharpLanguageServer(SolidLanguageServer): raise SolidLSPException(f"Failed to start F# language server: {e}") # Send initialization - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request to F# language server") try: diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index 453f0f92..d0ea8c3f 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -8,13 +8,11 @@ The editor must be open with its built-in language server enabled (default). import logging import os -import pathlib from collections.abc import Callable from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict from solidlsp.settings import SolidLSPSettings @@ -93,14 +91,8 @@ class GodotLanguageServer(SolidLanguageServer): request_timeout=request_timeout, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: params = { - "processId": os.getpid(), - "rootUri": root_uri, - "rootPath": repository_absolute_path, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -125,7 +117,7 @@ class GodotLanguageServer(SolidLanguageServer): }, }, } - return params # type: ignore + return params def _start_server(self) -> None: def do_nothing(params: dict) -> None: @@ -143,7 +135,7 @@ class GodotLanguageServer(SolidLanguageServer): log.info("Connecting to Godot LSP at %s:%d", self._conn_info.host, self._conn_info.port) self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending LSP initialize request to Godot") self.server.send.initialize(initialize_params) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/gopls.py b/src/solidlsp/language_servers/gopls.py index 3c098178..44164927 100644 --- a/src/solidlsp/language_servers/gopls.py +++ b/src/solidlsp/language_servers/gopls.py @@ -2,16 +2,14 @@ import hashlib import json import logging import os -import pathlib import subprocess from collections.abc import Hashable -from typing import Any, cast +from typing import Any from overrides import override from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -103,11 +101,10 @@ class Gopls(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="gopls", cwd=repository_root_path), "go", solidlsp_settings) self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Go Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", "capabilities": { @@ -122,15 +119,6 @@ class Gopls(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } # Apply gopls-specific settings via initializationOptions @@ -149,7 +137,7 @@ class Gopls(SolidLanguageServer): log.debug("Applying gopls settings via initializationOptions: keys=%s", list(gopls_settings.keys())) initialize_params["initializationOptions"] = gopls_settings - return cast(InitializeParams, initialize_params) + return initialize_params def _validate_gopls_settings_dict(self, gopls_settings: object) -> dict: if not isinstance(gopls_settings, dict): @@ -233,7 +221,7 @@ class Gopls(SolidLanguageServer): log.info("Starting gopls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/groovy_language_server.py b/src/solidlsp/language_servers/groovy_language_server.py index 2370e128..f6530733 100644 --- a/src/solidlsp/language_servers/groovy_language_server.py +++ b/src/solidlsp/language_servers/groovy_language_server.py @@ -5,13 +5,11 @@ Provides Groovy specific instantiation of the LanguageServer class. Contains var import dataclasses import logging import os -import pathlib import shlex from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -244,19 +242,11 @@ class GroovyLanguageServer(SolidLanguageServer): " Ensure the JAR file is available at the configured path\n" ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Groovy Language Server. """ - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Serena Groovy Client", "version": "1.0.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"dynamicRegistration": True, "didSave": True}, @@ -282,15 +272,8 @@ class GroovyLanguageServer(SolidLanguageServer): } }, }, - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -316,7 +299,7 @@ class GroovyLanguageServer(SolidLanguageServer): log.info("Starting Groovy server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py index be3ed0db..c732c4b2 100644 --- a/src/solidlsp/language_servers/haskell_language_server.py +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -4,7 +4,6 @@ Provides Haskell specific instantiation of the LanguageServer class. Contains va import logging import os -import pathlib import shutil import time from typing import Any @@ -13,7 +12,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -107,14 +105,11 @@ class HaskellLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Haskell Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", "capabilities": { "workspace": { @@ -299,17 +294,8 @@ class HaskellLanguageServer(SolidLanguageServer): } }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -364,7 +350,7 @@ class HaskellLanguageServer(SolidLanguageServer): log.info("Starting Haskell Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/haxe_language_server.py b/src/solidlsp/language_servers/haxe_language_server.py index 24ca6fe7..3d4a154c 100644 --- a/src/solidlsp/language_servers/haxe_language_server.py +++ b/src/solidlsp/language_servers/haxe_language_server.py @@ -22,7 +22,7 @@ from solidlsp.ls import ( from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Hover -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticSeverity from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -202,13 +202,11 @@ class HaxeLanguageServer(SolidLanguageServer): "dump", ] - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return initialize params for the Haxe Language Server. displayArguments are resolved from user-configured buildFile or auto-discovered .hxml files. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # 1. Check for user-configured .hxml path configured_build_file = self._custom_settings.get("buildFile") if configured_build_file: @@ -216,7 +214,7 @@ class HaxeLanguageServer(SolidLanguageServer): display_arguments = [configured_build_file] else: # 2. Auto-discover .hxml files recursively - display_arguments = self._discover_hxml_file(repository_absolute_path) + display_arguments = self._discover_hxml_file(self.repository_root_path) init_options: dict = {"displayArguments": display_arguments} rename_source_folders = self._custom_settings.get("renameSourceFolders") @@ -258,17 +256,8 @@ class HaxeLanguageServer(SolidLanguageServer): }, }, "initializationOptions": init_options, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params @staticmethod def _discover_hxml_file(repository_absolute_path: str) -> list[str]: @@ -377,7 +366,7 @@ class HaxeLanguageServer(SolidLanguageServer): log.info("Starting Haxe server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index 839e0381..41b945ef 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -5,9 +5,8 @@ Supports HLSL, GLSL, and WGSL shader file formats. import logging import os -import pathlib import shutil -from typing import Any, cast +from typing import Any from overrides import override @@ -17,7 +16,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -167,13 +165,8 @@ class HlslLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { @@ -207,9 +200,8 @@ class HlslLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params @override def _start_server(self) -> None: @@ -237,7 +229,7 @@ class HlslLanguageServer(SolidLanguageServer): log.info("Starting shader-language-server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/intelephense.py b/src/solidlsp/language_servers/intelephense.py index 47212695..6f68c33e 100644 --- a/src/solidlsp/language_servers/intelephense.py +++ b/src/solidlsp/language_servers/intelephense.py @@ -4,7 +4,6 @@ Provides PHP specific instantiation of the LanguageServer class using Intelephen import logging import os -import pathlib import shutil from time import sleep @@ -13,7 +12,7 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.settings import SolidLSPSettings from ..lsp_protocol_handler import lsp_types @@ -110,11 +109,10 @@ class Intelephense(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the Intelephense Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -135,15 +133,6 @@ class Intelephense(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } initialization_options = {} # Add license key if provided via environment variable @@ -159,7 +148,7 @@ class Intelephense(SolidLanguageServer): initialization_options["intelephense.files.maxSize"] = max_file_size initialize_params["initializationOptions"] = initialization_options - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Intelephense server process""" @@ -180,7 +169,7 @@ class Intelephense(SolidLanguageServer): log.info("Starting Intelephense server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/jedi_server.py b/src/solidlsp/language_servers/jedi_server.py index 49ca91a0..7ad3b227 100644 --- a/src/solidlsp/language_servers/jedi_server.py +++ b/src/solidlsp/language_servers/jedi_server.py @@ -3,16 +3,12 @@ Provides Python specific instantiation of the LanguageServer class. Contains var """ import logging -import os -import pathlib import threading -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -40,18 +36,12 @@ class JediServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Jedi Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, # Note: this is not necessarily the minimal set of capabilities... "capabilities": { "workspace": { @@ -143,14 +133,8 @@ class JediServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -182,7 +166,7 @@ class JediServer(SolidLanguageServer): log.info("Starting jedi-language-server server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/json_language_server.py b/src/solidlsp/language_servers/json_language_server.py index f764418e..654f2cc4 100644 --- a/src/solidlsp/language_servers/json_language_server.py +++ b/src/solidlsp/language_servers/json_language_server.py @@ -5,14 +5,12 @@ Contains various configurations and settings specific to JSON files. import logging import os -import pathlib import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -101,12 +99,10 @@ class JsonLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the JSON Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -127,17 +123,8 @@ class JsonLanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -160,7 +147,7 @@ class JsonLanguageServer(SolidLanguageServer): log.info("Starting JSON server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/julia_server.py b/src/solidlsp/language_servers/julia_server.py index 7b7579b6..cde52982 100644 --- a/src/solidlsp/language_servers/julia_server.py +++ b/src/solidlsp/language_servers/julia_server.py @@ -1,6 +1,5 @@ import logging import os -import pathlib import platform import shutil import subprocess @@ -10,7 +9,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DiagnosticTag from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -136,15 +135,11 @@ class JuliaLanguageServer(SolidLanguageServer): # runs (standalone test invocations, CI cold start). return 30.0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Julia Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, + initialize_params: dict = { "capabilities": { # workspace.configuration MUST be true: LanguageServer.jl pulls all julia.lint.* # settings via workspace/configuration after initialize. Without it, the server @@ -164,12 +159,6 @@ class JuliaLanguageServer(SolidLanguageServer): "documentSymbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } return initialize_params @@ -202,7 +191,7 @@ class JuliaLanguageServer(SolidLanguageServer): log.info("Starting LanguageServer.jl server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Julia Language Server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/kotlin_language_server.py b/src/solidlsp/language_servers/kotlin_language_server.py index 3998dafa..45901ede 100644 --- a/src/solidlsp/language_servers/kotlin_language_server.py +++ b/src/solidlsp/language_servers/kotlin_language_server.py @@ -21,7 +21,6 @@ import os import pathlib import stat import threading -from typing import cast from overrides import override @@ -32,7 +31,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -185,20 +183,13 @@ class KotlinLanguageServer(SolidLanguageServer): env["JAVA_TOOL_OPTIONS"] = jvm_options return env - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Kotlin Language Server. """ - if not os.path.isabs(repository_absolute_path): - repository_absolute_path = os.path.abspath(repository_absolute_path) - - root_uri = pathlib.Path(repository_absolute_path).as_uri() + root_uri = pathlib.Path(self.repository_root_path).as_uri() initialize_params = { - "clientInfo": {"name": "Multilspy Kotlin Client", "version": "1.0.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "applyEdit": True, @@ -429,15 +420,8 @@ class KotlinLanguageServer(SolidLanguageServer): }, }, "trace": "off", - "processId": os.getpid(), - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -503,7 +487,7 @@ class KotlinLanguageServer(SolidLanguageServer): log.info("Starting Kotlin server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/lean4_language_server.py b/src/solidlsp/language_servers/lean4_language_server.py index 961a65b6..4f4af40a 100644 --- a/src/solidlsp/language_servers/lean4_language_server.py +++ b/src/solidlsp/language_servers/lean4_language_server.py @@ -4,17 +4,13 @@ Uses the built-in Lean 4 language server (lean --server). """ import logging -import os -import pathlib import shutil import subprocess -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -96,16 +92,11 @@ class Lean4LanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".lake", "build"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Lean 4 Language Server. """ - 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}, @@ -131,14 +122,8 @@ class Lean4LanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start the Lean 4 language server process.""" @@ -160,7 +145,7 @@ class Lean4LanguageServer(SolidLanguageServer): log.info("Starting Lean 4 language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/lua_ls.py b/src/solidlsp/language_servers/lua_ls.py index 3176e41f..7b31cc71 100644 --- a/src/solidlsp/language_servers/lua_ls.py +++ b/src/solidlsp/language_servers/lua_ls.py @@ -7,8 +7,6 @@ You can pass the following entries in ``ls_specific_settings["lua"]``: """ import logging -import os -import pathlib import platform import shutil from collections.abc import Hashable @@ -20,7 +18,6 @@ from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -220,12 +217,10 @@ class LuaLanguageServer(SolidLanguageServer): return original_name - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Lua Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -270,15 +265,6 @@ class LuaLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # Lua Language Server specific options "runtime": { @@ -304,7 +290,7 @@ class LuaLanguageServer(SolidLanguageServer): }, }, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Lua Language Server process""" @@ -325,7 +311,7 @@ class LuaLanguageServer(SolidLanguageServer): log.info("Starting Lua Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/luau_lsp.py b/src/solidlsp/language_servers/luau_lsp.py index f0cb06bf..a23daed7 100644 --- a/src/solidlsp/language_servers/luau_lsp.py +++ b/src/solidlsp/language_servers/luau_lsp.py @@ -20,8 +20,6 @@ See: https://github.com/JohnnyMorganz/luau-lsp """ import logging -import os -import pathlib import platform import shutil import threading @@ -32,7 +30,6 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -270,12 +267,10 @@ class LuauLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, None, "luau", solidlsp_settings) self.server_ready = threading.Event() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Luau Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -322,20 +317,11 @@ class LuauLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], # luau-lsp initialization options # These can be overridden via .luaurc in the project root "initializationOptions": {}, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start Luau Language Server process""" @@ -366,7 +352,7 @@ class LuauLanguageServer(SolidLanguageServer): log.info("Starting Luau Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/marksman.py b/src/solidlsp/language_servers/marksman.py index 72d7268a..98696f53 100644 --- a/src/solidlsp/language_servers/marksman.py +++ b/src/solidlsp/language_servers/marksman.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to Markdown. import logging import os -import pathlib from collections.abc import Hashable from overrides import override @@ -19,7 +18,6 @@ from solidlsp.ls import ( ) from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind, UnifiedSymbolInformation -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -195,17 +193,12 @@ class Marksman(SolidLanguageServer): return document_symbols - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Marksman Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params: InitializeParams = { - "processId": os.getpid(), + initialize_params: dict = { "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -215,9 +208,9 @@ class Marksman(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, - "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, # type: ignore[list-item] + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "codeAction": {"dynamicRegistration": True}, }, "workspace": { @@ -226,12 +219,6 @@ class Marksman(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } return initialize_params @@ -256,7 +243,7 @@ class Marksman(SolidLanguageServer): log.info("Starting marksman server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to marksman server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/matlab_language_server.py b/src/solidlsp/language_servers/matlab_language_server.py index 5a83a75f..d6694355 100644 --- a/src/solidlsp/language_servers/matlab_language_server.py +++ b/src/solidlsp/language_servers/matlab_language_server.py @@ -37,12 +37,12 @@ import pathlib import platform import shutil import threading -from typing import Any, cast +from typing import Any from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -361,10 +361,8 @@ class MatlabLanguageServer(SolidLanguageServer): "MATLAB_INSTALL_PATH": self.get_matlab_path(), } - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return the initialize params for the MATLAB Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -394,17 +392,8 @@ class MatlabLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start the MATLAB Language Server and wait for it to be ready.""" @@ -460,7 +449,7 @@ class MatlabLanguageServer(SolidLanguageServer): log.info("Starting MATLAB server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/msl_language_server.py b/src/solidlsp/language_servers/msl_language_server.py index e2596a05..e8c89c1c 100644 --- a/src/solidlsp/language_servers/msl_language_server.py +++ b/src/solidlsp/language_servers/msl_language_server.py @@ -8,7 +8,6 @@ and launched as a subprocess using the current Python interpreter. import logging import os -import pathlib import sys import threading @@ -16,7 +15,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -46,10 +44,8 @@ class MslLanguageServer(SolidLanguageServer): ) self.server_ready = threading.Event() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the mSL Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -69,12 +65,8 @@ class MslLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Starts the mSL Language Server.""" @@ -91,7 +83,7 @@ class MslLanguageServer(SolidLanguageServer): log.info("Starting mSL server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to mSL LSP server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/nixd_ls.py b/src/solidlsp/language_servers/nixd_ls.py index b697292c..eacd6364 100644 --- a/src/solidlsp/language_servers/nixd_ls.py +++ b/src/solidlsp/language_servers/nixd_ls.py @@ -6,8 +6,6 @@ Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging -import os -import pathlib import platform import shutil import subprocess @@ -18,7 +16,6 @@ from overrides import override from solidlsp import ls_types from solidlsp.ls import DocumentSymbols, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -250,12 +247,10 @@ class NixLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=nixd_path, cwd=repository_root_path), "nix", solidlsp_settings) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for nixd. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -318,15 +313,6 @@ class NixLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # nixd specific options "nixpkgs": {"expr": "import { }"}, @@ -360,7 +346,7 @@ class NixLanguageServer(SolidLanguageServer): log.info("Starting nixd server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/ocaml_lsp_server.py b/src/solidlsp/language_servers/ocaml_lsp_server.py index b2e9511a..3a26e50e 100644 --- a/src/solidlsp/language_servers/ocaml_lsp_server.py +++ b/src/solidlsp/language_servers/ocaml_lsp_server.py @@ -5,7 +5,6 @@ Contains various configurations and settings specific to OCaml and Reason. import logging import os -import pathlib import platform import re import shutil @@ -18,7 +17,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings from solidlsp.util.subprocess_util import subprocess_kwargs @@ -321,19 +319,13 @@ class OcamlLanguageServer(SolidLanguageServer): """Define language-specific directories to ignore for OCaml projects.""" return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the OCaml Language Server. Supports both OCaml and Reason. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "workspaceFolders": True, @@ -376,14 +368,8 @@ class OcamlLanguageServer(SolidLanguageServer): }, }, "trace": "verbose", - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -417,7 +403,7 @@ class OcamlLanguageServer(SolidLanguageServer): log.info("Starting OCaml LSP server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/omnisharp.py b/src/solidlsp/language_servers/omnisharp.py index 58dda4ee..e64746e5 100644 --- a/src/solidlsp/language_servers/omnisharp.py +++ b/src/solidlsp/language_servers/omnisharp.py @@ -5,7 +5,6 @@ Provides C# specific instantiation of the LanguageServer class. Contains various import json import logging import os -import pathlib import threading from collections.abc import Iterable @@ -15,7 +14,6 @@ from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_utils import DotnetVersion, FileUtils, PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -127,8 +125,7 @@ class OmniSharp(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["bin", "obj"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Omnisharp Language Server. """ @@ -137,18 +134,10 @@ class OmniSharp(SolidLanguageServer): del d["_description"] - d["processId"] = os.getpid() - assert d["rootPath"] == "$rootPath" - d["rootPath"] = repository_absolute_path - - assert d["rootUri"] == "$rootUri" - d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["uri"] == "$uri" - d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri() - - assert d["workspaceFolders"][0]["name"] == "$name" - d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path) + # processId, rootPath, rootUri and workspaceFolders are set centrally by the InitializeParamsBuilder + del d["rootPath"] + del d["rootUri"] + del d["workspaceFolders"] return d @@ -402,7 +391,7 @@ class OmniSharp(SolidLanguageServer): log.info("Starting OmniSharp server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/pascal_server.py b/src/solidlsp/language_servers/pascal_server.py index 1f1488e7..e27831fd 100644 --- a/src/solidlsp/language_servers/pascal_server.py +++ b/src/solidlsp/language_servers/pascal_server.py @@ -49,7 +49,6 @@ import hashlib import json import logging import os -import pathlib import platform import shutil import tarfile @@ -64,7 +63,6 @@ import zipfile from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, quote_windows_path from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -777,8 +775,7 @@ class PascalLanguageServer(SolidLanguageServer): log.info(f"Using pasls at: {pasls_executable_path}") return quote_windows_path(pasls_executable_path) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Pascal Language Server. @@ -788,8 +785,6 @@ class PascalLanguageServer(SolidLanguageServer): We only pass target OS/CPU in initializationOptions if explicitly set. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # Build initializationOptions from environment variables # pasls reads these to configure CodeTools: # - PP: Path to FPC compiler executable @@ -881,18 +876,9 @@ class PascalLanguageServer(SolidLanguageServer): }, }, "initializationOptions": initialization_options, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -926,7 +912,7 @@ class PascalLanguageServer(SolidLanguageServer): log.info("Starting Pascal server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index c2e40b39..9b785ded 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -5,8 +5,6 @@ Note: Windows is not supported as Nix itself doesn't support Windows natively. """ import logging -import os -import pathlib import subprocess import time from typing import Any @@ -16,7 +14,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams, InitializeParams +from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -112,17 +110,12 @@ class PerlLanguageServer(SolidLanguageServer): ) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for Perl::LanguageServer. Based on the expected structure from Perl::LanguageServer::Methods::_rpcreq_initialize. """ - 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}, @@ -138,15 +131,9 @@ class PerlLanguageServer(SolidLanguageServer): }, }, "initializationOptions": {}, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Perl::LanguageServer process""" @@ -180,7 +167,7 @@ class PerlLanguageServer(SolidLanguageServer): log.info("Starting Perl::LanguageServer process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/phpactor.py b/src/solidlsp/language_servers/phpactor.py index 181cb3ee..306374d9 100644 --- a/src/solidlsp/language_servers/phpactor.py +++ b/src/solidlsp/language_servers/phpactor.py @@ -4,7 +4,6 @@ Provides PHP specific instantiation of the LanguageServer class using Phpactor. import logging import os -import pathlib import re import shutil import stat @@ -15,7 +14,6 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -122,15 +120,11 @@ class PhpactorServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the Phpactor Language Server. """ - 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}, @@ -145,19 +139,13 @@ class PhpactorServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "language_server_phpstan.enabled": False, "language_server_psalm.enabled": False, "language_server_php_cs_fixer.enabled": False, }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start Phpactor server process.""" @@ -178,7 +166,7 @@ class PhpactorServer(SolidLanguageServer): log.info("Starting Phpactor server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/phpantom.py b/src/solidlsp/language_servers/phpantom.py index 6fd42f0a..f0f11dd1 100644 --- a/src/solidlsp/language_servers/phpantom.py +++ b/src/solidlsp/language_servers/phpantom.py @@ -4,11 +4,9 @@ Provides PHP specific instantiation of the LanguageServer class using PHPantom. import logging import os -import pathlib import shutil import stat from time import sleep -from typing import cast from overrides import override @@ -22,7 +20,7 @@ from solidlsp.ls import ( from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler import lsp_types as protocol_lsp_types -from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, InitializeParams, LocationLink +from solidlsp.lsp_protocol_handler.lsp_types import Definition, DefinitionParams, LocationLink from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -178,12 +176,10 @@ class PHPantomServer(SolidLanguageServer): def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialization params for the PHPantom Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - # declaring client capabilities initialize_params = { "locale": "en", @@ -213,17 +209,8 @@ class PHPantomServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start PHPantom server process.""" @@ -244,7 +231,7 @@ class PHPantomServer(SolidLanguageServer): log.info("Starting PHPantom server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() # negotiating server capabilities log.info("Sending initialize request from LSP client to LSP server and awaiting response") diff --git a/src/solidlsp/language_servers/powershell_language_server.py b/src/solidlsp/language_servers/powershell_language_server.py index 1879ac02..99bac570 100644 --- a/src/solidlsp/language_servers/powershell_language_server.py +++ b/src/solidlsp/language_servers/powershell_language_server.py @@ -11,7 +11,6 @@ You can pass the following entries in ``ls_specific_settings["powershell"]``: import logging import os -import pathlib import platform import shutil import subprocess @@ -27,7 +26,6 @@ from solidlsp.ls import LSPConstants, RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_types import SymbolKind from solidlsp.ls_utils import FileUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -277,12 +275,10 @@ class PowerShellLanguageServer(SolidLanguageServer): return original_name - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the PowerShell Editor Services. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -326,17 +322,8 @@ class PowerShellLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """ @@ -375,7 +362,7 @@ class PowerShellLanguageServer(SolidLanguageServer): log.info("Starting PowerShell Editor Services process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/pyrefly_server.py b/src/solidlsp/language_servers/pyrefly_server.py index 3c5f26e2..b657895b 100644 --- a/src/solidlsp/language_servers/pyrefly_server.py +++ b/src/solidlsp/language_servers/pyrefly_server.py @@ -15,7 +15,7 @@ import pathlib import sys import threading import time -from typing import Any, cast +from typing import Any from typing_extensions import override @@ -23,7 +23,7 @@ from solidlsp import ls_types from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, LSPErrorCodes +from solidlsp.lsp_protocol_handler.lsp_types import LSPErrorCodes from solidlsp.lsp_protocol_handler.server import LSPError, PayloadLike from solidlsp.settings import SolidLSPSettings @@ -171,17 +171,11 @@ class PyreflyLanguageServer(SolidLanguageServer): def _get_language_id_for_file(self, relative_file_path: str) -> str: return "python" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the pyrefly language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "window": { "workDoneProgress": True, @@ -216,9 +210,8 @@ class PyreflyLanguageServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -280,7 +273,7 @@ class PyreflyLanguageServer(SolidLanguageServer): self.server.start() # retry requests that pyrefly cancels while re-indexing the workspace in the background self._install_mutation_retry() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to pyrefly server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/r_language_server.py b/src/solidlsp/language_servers/r_language_server.py index 087eccca..8caeeed1 100644 --- a/src/solidlsp/language_servers/r_language_server.py +++ b/src/solidlsp/language_servers/r_language_server.py @@ -1,6 +1,4 @@ import logging -import os -import pathlib import subprocess from typing import Any @@ -8,7 +6,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -68,10 +65,8 @@ class RLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=r_cmd, cwd=repository_root_path), "r", solidlsp_settings) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Initialize params for R Language Server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -107,17 +102,8 @@ class RLanguageServer(SolidLanguageServer): }, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """Start R Language Server process.""" @@ -140,7 +126,7 @@ class RLanguageServer(SolidLanguageServer): log.info("Starting R Language Server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request to R Language Server", ) diff --git a/src/solidlsp/language_servers/regal_server.py b/src/solidlsp/language_servers/regal_server.py index 25870a04..c19f556e 100644 --- a/src/solidlsp/language_servers/regal_server.py +++ b/src/solidlsp/language_servers/regal_server.py @@ -1,16 +1,12 @@ """Regal Language Server implementation for Rego policy files.""" import logging -import os import shutil -from typing import cast from overrides import override 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 @@ -54,20 +50,14 @@ class RegalLanguageServer(SolidLanguageServer): solidlsp_settings, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Regal Language Server. - :param repository_absolute_path: Absolute path to the repository :return: LSP initialization parameters """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) initialize_params = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -89,14 +79,8 @@ class RegalLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """Start Regal language server process and wait for initialization.""" @@ -117,7 +101,7 @@ class RegalLanguageServer(SolidLanguageServer): log.info("Starting Regal language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info( "Sending initialize request from LSP client to LSP server and awaiting response", diff --git a/src/solidlsp/language_servers/ruby_lsp.py b/src/solidlsp/language_servers/ruby_lsp.py index fd72b289..0e66b16b 100644 --- a/src/solidlsp/language_servers/ruby_lsp.py +++ b/src/solidlsp/language_servers/ruby_lsp.py @@ -11,7 +11,6 @@ You can pass the following entries in ``ls_specific_settings["ruby"]``: import json import logging import os -import pathlib import shutil import subprocess import threading @@ -20,7 +19,7 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams, InitializeResult +from solidlsp.lsp_protocol_handler.lsp_types import InitializeResult from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -329,16 +328,13 @@ class RubyLsp(SolidLanguageServer): return base_patterns - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns ruby-lsp specific initialization parameters. """ exclude_patterns = self._get_ruby_exclude_patterns(self.repository_root_path) initialize_params = { - "processId": os.getpid(), - "rootPath": self.repository_root_path, - "rootUri": pathlib.Path(self.repository_root_path).as_uri(), "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, @@ -371,7 +367,7 @@ class RubyLsp(SolidLanguageServer): }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -437,7 +433,7 @@ class RubyLsp(SolidLanguageServer): log.info("Starting ruby-lsp server process") self.server.start() - initialize_params = self._get_initialize_params() + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 2e222b5a..65f29665 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -9,13 +9,11 @@ import platform import shutil import subprocess import threading -from typing import cast from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -210,14 +208,11 @@ class RustAnalyzer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in ["target"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Rust Analyzer Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "clientInfo": {"name": "Visual Studio Code - Insiders", "version": "1.82.0-insider"}, "locale": "en", "capabilities": { "workspace": { @@ -672,17 +667,8 @@ class RustAnalyzer(SolidLanguageServer): "workspace": {"symbol": {"search": {"kind": "only_types", "limit": 128, "scope": "workspace"}}}, }, "trace": "verbose", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -728,7 +714,7 @@ class RustAnalyzer(SolidLanguageServer): log.info("Starting RustAnalyzer server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index 563806cd..f360d1ce 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -4,7 +4,6 @@ Provides Scala specific instantiation of the LanguageServer class. Contains vari import logging import os -import pathlib import shutil import subprocess from enum import Enum @@ -14,7 +13,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -259,17 +257,12 @@ class ScalaLanguageServer(SolidLanguageServer): log.info("Bootstrapping metals finished.") return [metals_executable] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Scala Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "initializationOptions": { "compilerOptions": { "completionCommand": None, @@ -304,7 +297,7 @@ class ScalaLanguageServer(SolidLanguageServer): }, "capabilities": {"textDocument": {"documentSymbol": {"hierarchicalDocumentSymbolSupport": True}}}, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -315,7 +308,7 @@ class ScalaLanguageServer(SolidLanguageServer): log.info("Sending initialize request from LSP client to LSP server and awaiting response") - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() self.server.send.initialize(initialize_params) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/solargraph.py b/src/solidlsp/language_servers/solargraph.py index 9379bc6d..b0ae3b00 100644 --- a/src/solidlsp/language_servers/solargraph.py +++ b/src/solidlsp/language_servers/solargraph.py @@ -6,7 +6,6 @@ Contains various configurations and settings specific to Ruby. import json import logging import os -import pathlib import re import shutil import subprocess @@ -16,7 +15,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -267,18 +265,13 @@ class Solargraph(SolidLanguageServer): return base_patterns - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Solargraph Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - exclude_patterns = Solargraph._get_ruby_exclude_patterns(repository_absolute_path) + exclude_patterns = Solargraph._get_ruby_exclude_patterns(self.repository_root_path) - initialize_params: InitializeParams = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, + initialize_params = { "initializationOptions": { "exclude": exclude_patterns, }, @@ -289,17 +282,11 @@ class Solargraph(SolidLanguageServer): "textDocument": { "documentSymbol": { "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, }, }, - "trace": "verbose", # type: ignore[typeddict-item] - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], + "trace": "verbose", } return initialize_params @@ -341,7 +328,7 @@ class Solargraph(SolidLanguageServer): log.info("Starting solargraph server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") log.info(f"Sending init params: {json.dumps(initialize_params, indent=4)}") diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 7ee35953..515bd5ce 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -19,7 +19,6 @@ from solidlsp import ls_types from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, LSPConstants, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -178,21 +177,10 @@ class SolidityLanguageServer(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in {"artifacts", "cache", "typechain-types"} - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Return LSP InitializeParams for the Solidity language server.""" - root_uri = pathlib.Path(repository_absolute_path).as_uri() - return { # type: ignore + return { "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "capabilities": { "textDocument": { "synchronization": { @@ -208,11 +196,11 @@ class SolidityLanguageServer(SolidLanguageServer): "documentSymbol": { "dynamicRegistration": True, "hierarchicalDocumentSymbolSupport": True, - "symbolKind": {"valueSet": list(range(1, 27))}, # type: ignore[arg-type] + "symbolKind": {"valueSet": list(range(1, 27))}, }, "hover": { "dynamicRegistration": True, - "contentFormat": ["markdown", "plaintext"], # type: ignore[list-item] + "contentFormat": ["markdown", "plaintext"], }, "publishDiagnostics": {"relatedInformation": True}, }, @@ -263,7 +251,7 @@ class SolidityLanguageServer(SolidLanguageServer): log.info("Starting Solidity language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.debug("Sending initialize request to Solidity language server") init_response = self.server.send.initialize(initialize_params) log.debug(f"Received initialize response from Solidity server: {init_response}") diff --git a/src/solidlsp/language_servers/some_sass_language_server.py b/src/solidlsp/language_servers/some_sass_language_server.py index 770b80ae..abce6596 100644 --- a/src/solidlsp/language_servers/some_sass_language_server.py +++ b/src/solidlsp/language_servers/some_sass_language_server.py @@ -25,7 +25,6 @@ from __future__ import annotations import logging import os -import pathlib import shutil import threading @@ -34,7 +33,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -157,9 +155,7 @@ class SomeSassLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params: dict = { "locale": "en", "capabilities": { @@ -183,17 +179,8 @@ class SomeSassLanguageServer(SolidLanguageServer): }, }, "initializationOptions": SOMESASS_INIT_OPTIONS, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params @staticmethod def _handle_workspace_configuration(params: dict) -> list[dict]: @@ -231,7 +218,7 @@ class SomeSassLanguageServer(SolidLanguageServer): log.info("Starting some-sass-language-server") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("Some Sass LS initialize response: %s", init_response) assert "completionProvider" in init_response["capabilities"], "Some Sass LSP did not advertise completionProvider" diff --git a/src/solidlsp/language_servers/sourcekit_lsp.py b/src/solidlsp/language_servers/sourcekit_lsp.py index 8d30daeb..e77ba74e 100644 --- a/src/solidlsp/language_servers/sourcekit_lsp.py +++ b/src/solidlsp/language_servers/sourcekit_lsp.py @@ -1,6 +1,5 @@ import logging import os -import pathlib import subprocess import time from collections.abc import Hashable @@ -11,7 +10,6 @@ from solidlsp import ls_types from solidlsp.ls import RawDocumentSymbol, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_types import SymbolKind -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -75,13 +73,10 @@ class SourceKitLSP(SolidLanguageServer): return original_name.split("(", 1)[0].strip() - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Swift Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() - initialize_params = { "capabilities": { "general": { @@ -295,7 +290,6 @@ class SourceKitLSP(SolidLanguageServer): "workspaceFolders": True, }, }, - "clientInfo": {"name": "Visual Studio Code", "version": "1.102.2"}, "initializationOptions": { "backgroundIndexing": True, "backgroundPreparationMode": "enabled", @@ -305,18 +299,9 @@ class SourceKitLSP(SolidLanguageServer): "workspace/peekDocuments": True, }, "locale": "en", - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start sourcekit-lsp server process""" @@ -337,7 +322,7 @@ class SourceKitLSP(SolidLanguageServer): log.info("Starting sourcekit-lsp server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/systemverilog_server.py b/src/solidlsp/language_servers/systemverilog_server.py index bd1782c6..dd7f7b0c 100644 --- a/src/solidlsp/language_servers/systemverilog_server.py +++ b/src/solidlsp/language_servers/systemverilog_server.py @@ -4,14 +4,12 @@ SystemVerilog language server using verible-verilog-ls. import logging import os -import pathlib import shutil import subprocess -from typing import Any, cast +from typing import Any from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings from .common import RuntimeDependency, RuntimeDependencyCollection @@ -150,13 +148,8 @@ class SystemVerilogLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, "locale": "en", "capabilities": { "textDocument": { @@ -186,9 +179,8 @@ class SystemVerilogLanguageServer(SolidLanguageServer): "didChangeConfiguration": {"dynamicRegistration": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: def do_nothing(params: Any) -> None: @@ -205,7 +197,7 @@ class SystemVerilogLanguageServer(SolidLanguageServer): log.info("Starting verible-verilog-ls process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/taplo_server.py b/src/solidlsp/language_servers/taplo_server.py index ee9be952..69511d7b 100644 --- a/src/solidlsp/language_servers/taplo_server.py +++ b/src/solidlsp/language_servers/taplo_server.py @@ -18,8 +18,7 @@ from overrides import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import FileUtils, PathUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import FileUtils from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -208,12 +207,10 @@ class TaploServer(SolidLanguageServer): 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: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Taplo Language Server. """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) initialize_params = { "locale": "en", "capabilities": { @@ -236,17 +233,8 @@ class TaploServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -269,7 +257,7 @@ class TaploServer(SolidLanguageServer): log.info("Starting Taplo server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request to Taplo server") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index c654fd95..a298fee1 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -1,14 +1,12 @@ import logging import os import shutil -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig -from solidlsp.ls_utils import PathUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -219,17 +217,12 @@ class TerraformLS(SolidLanguageServer): ) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Terraform Language Server. """ - root_uri = PathUtils.path_to_uri(repository_absolute_path) result = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -243,14 +236,8 @@ class TerraformLS(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start terraform-ls server process""" @@ -271,7 +258,7 @@ class TerraformLS(SolidLanguageServer): log.info("Starting terraform-ls server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/texlab_language_server.py b/src/solidlsp/language_servers/texlab_language_server.py index ea95f6e6..91f0e09b 100644 --- a/src/solidlsp/language_servers/texlab_language_server.py +++ b/src/solidlsp/language_servers/texlab_language_server.py @@ -5,14 +5,12 @@ texlab is downloaded as a prebuilt binary from the latex-lsp/texlab GitHub relea import logging import os -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.ls_utils import PathUtils, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.ls_utils import PlatformUtils from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -134,15 +132,10 @@ class TexlabLanguageServer(SolidLanguageServer): solidlsp_settings, ) - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """Returns the initialize params for the texlab Language Server.""" - root_uri = PathUtils.path_to_uri(repository_absolute_path) result = { - "processId": os.getpid(), "locale": "en", - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -159,14 +152,8 @@ class TexlabLanguageServer(SolidLanguageServer): }, "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, }, - "workspaceFolders": [ - { - "name": os.path.basename(repository_absolute_path), - "uri": root_uri, - } - ], } - return cast(InitializeParams, result) + return result def _start_server(self) -> None: """Start the texlab server process.""" @@ -187,7 +174,7 @@ class TexlabLanguageServer(SolidLanguageServer): log.info("Starting texlab server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to texlab and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/ty_server.py b/src/solidlsp/language_servers/ty_server.py index 1e0832f0..4ce67355 100644 --- a/src/solidlsp/language_servers/ty_server.py +++ b/src/solidlsp/language_servers/ty_server.py @@ -8,15 +8,11 @@ You can pass the following entries in ``ls_specific_settings["python_ty"]``: """ import logging -import os -import pathlib -from typing import cast from typing_extensions import override from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderUvx, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -61,17 +57,11 @@ class TyLanguageServer(SolidLanguageServer): def _get_language_id_for_file(self, relative_file_path: str) -> str: return "python" - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Ty language server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { - "processId": os.getpid(), - "clientInfo": {"name": "Serena", "version": "0.1.0"}, - "rootPath": repository_absolute_path, - "rootUri": root_uri, "capabilities": { "workspace": { "workspaceEdit": {"documentChanges": True}, @@ -101,9 +91,8 @@ class TyLanguageServer(SolidLanguageServer): "publishDiagnostics": {"relatedInformation": True}, }, }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(repository_absolute_path)}], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -130,7 +119,7 @@ class TyLanguageServer(SolidLanguageServer): # starting and initializing the server log.info("Starting ty language server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to ty server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/vscode_html_language_server.py b/src/solidlsp/language_servers/vscode_html_language_server.py index 7b32fc08..9b329bc3 100644 --- a/src/solidlsp/language_servers/vscode_html_language_server.py +++ b/src/solidlsp/language_servers/vscode_html_language_server.py @@ -20,7 +20,6 @@ from __future__ import annotations import logging import os -import pathlib import shutil import threading @@ -29,7 +28,6 @@ from overrides import override from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -112,9 +110,7 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params: dict = { "locale": "en", "capabilities": { @@ -142,17 +138,8 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): "handledSchemas": ["file"], "provideFormatter": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: def do_nothing(_params: dict) -> None: @@ -169,7 +156,7 @@ class VsCodeHtmlLanguageServer(SolidLanguageServer): log.info("Starting vscode-html-language-server") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("HTML LS initialize response: %s", init_response) # Sanity-check key capabilities; HTML LSP always provides documentSymbol + completion diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 5525809b..23f8fe76 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -6,17 +6,14 @@ which provides TypeScript language server functionality via VSCode's TypeScript import logging import os -import pathlib import shutil import threading -from typing import cast from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -141,14 +138,13 @@ class VtsLanguageServer(SolidLanguageServer): raise ValueError(f"ls_specific_settings.typescript_vts.initialization_options must be a dict, got {type(opts).__name__}") return opts - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the VTS Language Server. If ``initialization_options`` is set in ``ls_specific_settings["typescript_vts"]``, it is forwarded verbatim as LSP ``initializationOptions``. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", "capabilities": { @@ -172,22 +168,13 @@ class VtsLanguageServer(SolidLanguageServer): "configuration": True, # This might be needed for vtsls }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } if self._initialization_options: log.info("Forwarding user-provided initializationOptions to vtsls: %s", self._initialization_options) initialize_params["initializationOptions"] = self._initialization_options - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: """ @@ -244,7 +231,7 @@ class VtsLanguageServer(SolidLanguageServer): log.info("Starting VTS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/yaml_language_server.py b/src/solidlsp/language_servers/yaml_language_server.py index b52c760e..b229f15f 100644 --- a/src/solidlsp/language_servers/yaml_language_server.py +++ b/src/solidlsp/language_servers/yaml_language_server.py @@ -5,14 +5,12 @@ Contains various configurations and settings specific to YAML files. import logging import os -import pathlib import shutil from typing import Any from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection, build_npm_install_command from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -114,12 +112,10 @@ class YamlLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the YAML Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -142,15 +138,6 @@ class YamlLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "yaml": { "schemaStore": {"enable": True, "url": "https://www.schemastore.org/api/json/catalog.json"}, @@ -161,7 +148,7 @@ class YamlLanguageServer(SolidLanguageServer): } }, } - return initialize_params # type: ignore + return initialize_params def _start_server(self) -> None: """ @@ -184,7 +171,7 @@ class YamlLanguageServer(SolidLanguageServer): log.info("Starting YAML server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/src/solidlsp/language_servers/zls.py b/src/solidlsp/language_servers/zls.py index cb5652fc..c05ec371 100644 --- a/src/solidlsp/language_servers/zls.py +++ b/src/solidlsp/language_servers/zls.py @@ -13,7 +13,6 @@ from overrides import override from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -100,12 +99,10 @@ class ZigLanguageServer(SolidLanguageServer): super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd="zls", cwd=repository_root_path), "zig", solidlsp_settings) self.request_id = 0 - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Zig Language Server. """ - root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", "capabilities": { @@ -140,15 +137,6 @@ class ZigLanguageServer(SolidLanguageServer): "configuration": True, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { # ZLS specific options based on schema.json # Critical paths for ZLS to understand the project @@ -178,7 +166,7 @@ class ZigLanguageServer(SolidLanguageServer): "inlay_hints_hide_redundant_param_names_last_token": False, }, } - return initialize_params # type: ignore[return-value] + return initialize_params def _start_server(self) -> None: """Start ZLS server process""" @@ -199,7 +187,7 @@ class ZigLanguageServer(SolidLanguageServer): log.info("Starting ZLS server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/test/solidlsp/csharp/test_omnisharp_startup.py b/test/solidlsp/csharp/test_omnisharp_startup.py deleted file mode 100644 index 74b9df34..00000000 --- a/test/solidlsp/csharp/test_omnisharp_startup.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -import pytest - -from solidlsp.language_servers.omnisharp import OmniSharp - - -class _NoWaitEvent: - def is_set(self) -> bool: - return False - - def wait(self, timeout: float | None = None) -> bool: - raise AssertionError("OmniSharp startup must not wait for dynamic capability registration") - - -@pytest.mark.csharp -def test_omnisharp_startup_does_not_wait_for_capability_registration(tmp_path: Path) -> None: - omnisharp = OmniSharp.__new__(OmniSharp) - omnisharp.repository_root_path = str(tmp_path) - omnisharp.server = SimpleNamespace( - send=SimpleNamespace(initialize=lambda _params: {"capabilities": {}}), - notify=SimpleNamespace(initialized=lambda _params: None, workspace_did_change_configuration=lambda _params: None), - on_request=lambda *_args: None, - on_notification=lambda *_args: None, - start=lambda: None, - ) - omnisharp.server_ready = _NoWaitEvent() - omnisharp.definition_available = _NoWaitEvent() - omnisharp.references_available = _NoWaitEvent() - omnisharp.completions_available = _NoWaitEvent() - - omnisharp._start_server() From 05181b62e5fb3852a40bfcd72beaa65e1f9efef4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 00:03:32 +0200 Subject: [PATCH 18/57] Docs: Update add-language guide for _create_base_initialize_params pattern Document the new LSP initialization pattern in the "adding new language support" memory: override _create_base_initialize_params (server-specific keys only), let the InitializeParamsBuilder supply the common keys, and call _create_initialize_params() in _start_server. Includes notes on builder-resolved workspace folders, nested initializationOptions folder lists, and opting out via set_workspace_folders=False. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adding_new_language_support_guide.md | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 2ac65b31..6eeed48e 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -67,31 +67,45 @@ You should look at at least one existing implementation of each base class to un ### 1.2 LSP Initialization -Override initialization methods if needed: +Override `_create_base_initialize_params` to provide server-specific initialization +parameters. The common keys — `processId`, `rootPath`, `rootUri`, `clientInfo` and +`workspaceFolders` — are set centrally by the `InitializeParamsBuilder` (see +`src/solidlsp/initialize_params.py`), so your override MUST NOT set them. Just return +the server-specific settings (typically `capabilities` and `initializationOptions`): ```python -def _get_initialize_params(self) -> InitializeParams: - """Return language-specific initialization parameters.""" +def _create_base_initialize_params(self) -> dict: + """Return language-specific initialization parameters (server-specific keys only).""" return { - "processId": os.getpid(), - "rootUri": PathUtils.path_to_uri(self.repository_root_path), "capabilities": { # Language-specific capabilities - } + }, + # "initializationOptions": {...}, # if the server needs them } def _start_server(self): """Start the language server with custom handlers.""" # Set up notification handlers self.server.on_notification("window/logMessage", self._handle_log_message) - - # Start server and initialize + + # Start server and initialize. Do NOT call _create_base_initialize_params directly; + # _create_initialize_params() wraps it with the builder to add the common keys. self.server.start() - init_response = self.server.send.initialize(self._get_initialize_params()) - + init_response = self.server.send.initialize(self._create_initialize_params()) + self.server.notify.initialized({}) ``` +Notes: +- The builder resolves `workspaceFolders` from the language server config (indexed + folders + `ls_additional_workspace_folders`); don't build the folder list yourself. +- To send a folder list nested inside `initializationOptions` (some servers, e.g. + `EclipseJDTLS`/`KotlinLanguageServer`, need this), set it explicitly there — only the + *top-level* `workspaceFolders` is builder-managed. +- To suppress the top-level `workspaceFolders` entirely, override + `_create_initialize_params_builder` and construct `DefaultInitializeParamsBuilder` + with `set_workspace_folders=False`. + After `_start_server` returns, the language server should be fully operational. If the server requires that one waits for certain notifications or responses before being ready, implement that logic here. For an example, see `EclipseJDTLS._start_server`. From 81b1bc804d95d67ed14e27b8561f56e4bedb1594 Mon Sep 17 00:00:00 2001 From: bymebyu Date: Fri, 3 Jul 2026 18:38:53 +0900 Subject: [PATCH 19/57] TypeScript/VTS: event-based indexing wait and no automatic typing acquisition (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * TypeScript/VTS: event-based indexing wait, no automatic typing acquisition - Set disableAutomaticTypingAcquisition in initializationOptions (TS and VTS) so tsserver does not download type packages from the network during language server initialization. - Replace the fixed 2-second wait before cross-file reference requests with $/progress-based indexing tracking: a pre-open hook arms the tracking (expect_indexing) before didOpen, then the request waits until indexing progress drains (wait_for_indexing), bounded by a configurable 'indexing_timeout' (ls_specific_settings, default 30s). If tsserver does not start reporting progress within a 2-second grace period (the previous fixed wait), the request proceeds — so the fast path is never slower than before, while slow indexing is actually awaited instead of guessed at. --- CHANGELOG.md | 3 ++ docs/02-usage/050_configuration.md | 1 + .../typescript_language_server.py | 46 +++++++++++++++++-- .../language_servers/vts_language_server.py | 5 ++ src/solidlsp/ls.py | 11 +++++ 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 831b4824..0c34aae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ Status of the `main` branch. Changes prior to the next official version change w - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. + - TypeScript/VTS: disable automatic typing acquisition during initialization (no network + downloads at startup) and replace the fixed 2-second cross-file reference wait with + event-based `$/progress` indexing tracking (configurable `indexing_timeout`, default 30s) - C#: minor fixes in Omnisharp and Roslyn that prevented startup on some systems #1617 - `SvelteLanguageServer`: Fix diagnostics requests for TypeScript/JavaScript files incorrectly being processed by the Svelte LS instead of the TypeScript LS. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index c69faaaf..dd8aa138 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1000,6 +1000,7 @@ Supported settings: | `typescript_version` | `5.9.3` | Override the bundled `typescript` npm package version Serena installs when `ls_path` is not set. | | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | +| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. | TypeScript supports [additional workspace folders](additional-workspace-folders) for cross-package reference discovery. Configure `additional_workspace_folders` in `project.yml` to enable this feature. diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 892843f5..e056e5de 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -7,6 +7,7 @@ import os import pathlib import shutil import threading +import time from typing import Any, cast from overrides import override @@ -73,6 +74,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): You can pass the following entries in ls_specific_settings["typescript"]: - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + - indexing_timeout: float, timeout in seconds for project indexing (default: 30.0) """ @classmethod @@ -81,7 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. - INDEXING_PROGRESS_TIMEOUT = 15.0 if os.name == "nt" else 10.0 + INDEXING_PROGRESS_TIMEOUT = 30.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ @@ -231,6 +233,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params = { "locale": "en", + # Disable Automatic Type Acquisition (ATA): with ATA enabled, tsserver fetches + # @types/* packages from npm in the background during indexing, which makes startup + # slow, network-dependent, and nondeterministic (and can hang on offline/locked-down + # machines). Serena relies on the types already installed in the project instead. + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, @@ -393,13 +404,14 @@ class TypeScriptLanguageServer(SolidLanguageServer): # typescript-language-server may send $/progress for "Initializing JS/TS # language features…" after initialized. If no progress is sent, # _indexing_complete stays SET and wait() returns immediately. + indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) log.info("Waiting for TypeScript project indexing to complete (if async)...") - if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT): + if self.wait_for_indexing(timeout=indexing_timeout): log.info("TypeScript project indexing complete") else: log.warning( "TypeScript project indexing did not complete within %.0fs; proceeding anyway", - self.INDEXING_PROGRESS_TIMEOUT, + indexing_timeout, ) self._activate_additional_workspaces() @@ -456,8 +468,32 @@ class TypeScriptLanguageServer(SolidLanguageServer): return self._published_diagnostics_timeout @override - def _get_wait_time_for_cross_file_referencing(self) -> float: - return 2 + def _pre_open_for_cross_file_references(self) -> None: + if not self._has_waited_for_cross_file_references: + self.expect_indexing() + + #: how long to wait for tsserver to *start* reporting indexing progress after didOpen; + #: matches the fixed wait the base implementation previously used + _INDEXING_START_GRACE_S = 2.0 + + @override + def _wait_for_cross_file_references_if_needed(self) -> None: + if self._has_waited_for_cross_file_references: + return + # Give tsserver a short grace period to start reporting $/progress after the file + # was opened. If progress starts, wait until it drains (bounded by indexing_timeout); + # if it never starts, tsserver considers the project loaded and we proceed after the + # grace period — which is no worse than the previous fixed 2-second wait. + grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S + while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens: + time.sleep(0.05) + if self._active_progress_tokens: + timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) + if self.wait_for_indexing(timeout=timeout): + log.info("TypeScript cross-file indexing complete") + else: + log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout) + self._has_waited_for_cross_file_references = True @override def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: diff --git a/src/solidlsp/language_servers/vts_language_server.py b/src/solidlsp/language_servers/vts_language_server.py index 5525809b..6cd092bb 100644 --- a/src/solidlsp/language_servers/vts_language_server.py +++ b/src/solidlsp/language_servers/vts_language_server.py @@ -151,6 +151,11 @@ class VtsLanguageServer(SolidLanguageServer): root_uri = pathlib.Path(repository_absolute_path).as_uri() initialize_params: dict = { "locale": "en", + "initializationOptions": { + "preferences": { + "disableAutomaticTypingAcquisition": True, + }, + }, "capabilities": { "textDocument": { "synchronization": {"didSave": True, "dynamicRegistration": True}, diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index a769a3b3..af3e5f07 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1582,6 +1582,9 @@ class SolidLanguageServer(ABC): self._ensure_server_started() t0 = perf_counter() if _debug_enabled else None + # arm indexing tracking before didOpen so that the subsequent wait can + # observe the indexing progress triggered by opening the file + self.language_server._pre_open_for_cross_file_references() with self.language_server.open_file(self.relative_file_path): self.language_server._wait_for_cross_file_references_if_needed() try: @@ -1733,6 +1736,14 @@ class SolidLanguageServer(ABC): }, ) + def _pre_open_for_cross_file_references(self) -> None: + """Called just before open_file() for requests that may need cross-file indexing. + + Override to pre-arm async indexing tracking before sending didOpen (e.g. clear + a threading.Event so a subsequent wait for indexing blocks correctly). + The base implementation is a no-op. + """ + def _wait_for_cross_file_references_if_needed(self) -> None: if not self._has_waited_for_cross_file_references: # Some LS require waiting for a while before they can return accurate cross-file results. From 9157e256713438aa4a8ba95c18ac229863d879a3 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 12:25:18 +0200 Subject: [PATCH 20/57] Improve docs --- docs/02-usage/040_workflow.md | 12 ++++++------ docs/02-usage/050_configuration.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index b95c6f90..f704b262 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -13,7 +13,7 @@ setting up a project with Serena typically involves the following steps: 4. **Working on coding tasks**: Using Serena to help you with actual coding tasks in the project (project-creation-indexing)= -## Project Creation & Indexing +## Project Creation Project creation is the process of defining fundamental project settings that are relevant to Serena's operation. @@ -38,7 +38,7 @@ To explicitly create a project, use the following command while in the project d * You can immediately index the project after creation with `--index`. (project-config)= -#### Project Configuration +## Project Configuration After creation, you can adjust the project settings in the generated `.serena/project.yml` file within the project directory. @@ -61,17 +61,17 @@ For detailed information on the parameters and possible settings, see the [template file](https://github.com/oraios/serena/blob/main/src/serena/resources/project.template.yml). :::{note} -Many settings in project.yml *extend* or *override* settings in the global configuration file `serena_config.yml`. +Many settings in project.yml *extend* or *override* settings in [Serena's global configuration](global-config). So use the project configuration specifically for aspects that apply only to the particular project. ::: -**Local Overrides**. The project.yml file is intended to be versioned together with the project. +**Local Overrides**. The `project.yml` file is intended to be versioned together with the project. You can specify local overrides for the settings in a `project.local.yml` file in the same directory (which, by default, is ignored by git). Any keys defined therein will override the respective key in `project.yml`. (indexing)= -### Indexing +## Indexing :::{note} Indexing is not a relevant operation when using the JetBrains plugin, as indexing is handled by the IDE. @@ -83,7 +83,7 @@ that requires symbol information. While in the project directory, run this command: - project index + serena project index Indexing has to be called only once. During regular usage, Serena will automatically update the index whenever files change. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 3429de33..cd722158 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -7,7 +7,7 @@ You can disable tools, change Serena's fundamental instructions (what we denote as the `system_prompt`), adjust the output of tools that just provide a prompt, and even adjust tool descriptions. -Serena is configured in using a multi-layered approach: +Serena is configured using a multi-layered approach: * **global configuration** (`serena_config.yml`, see below) * **project configuration** (`project.yml`, see [Project Configuration](project-config)) From f00d3cfa19f421b787877a92a0b64efe256081f7 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 12:40:26 +0200 Subject: [PATCH 21/57] Add docstrings --- src/solidlsp/ls.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 65793bec..ddb238c5 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -279,6 +279,11 @@ class LanguageServerDependencyProvider(ABC): """ def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): + """ + :param custom_settings: the (user-provided) language server-specific settings + :param ls_resources_dir: the directory in which data for the language-server shall be stored + (this is already specific to the concrete language server, i.e. no further subdirectory is needed) + """ self._custom_settings = custom_settings self._ls_resources_dir = ls_resources_dir @@ -674,6 +679,9 @@ class SolidLanguageServer(ABC): self._solidlsp_settings = solidlsp_settings lang = self.get_language_enum_instance() self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) + """ + the (user-provided) language server-specific settings + """ self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") self._encoding = config.encoding From 352b3d3fed7d4bb3201ad5c0b0f5bb1794945f7e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 12:42:07 +0200 Subject: [PATCH 22/57] Allow to overrride entries of initializationOptions via LS-specific settings --- docs/02-usage/050_configuration.md | 30 ++++++++++++++++++++++++++++++ src/solidlsp/initialize_params.py | 13 +++++++++++++ src/solidlsp/ls.py | 7 +++++++ 3 files changed, 50 insertions(+) diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index cd722158..03f44761 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -250,6 +250,36 @@ Common examples include: `ansible`, `bash`, `bsl`, `clojure`, `cpp`, `cpp_ccls`, If `ls_path` is set, Serena's managed download or install is bypassed for that language server. In that case, any server-specific version or registry settings do not apply. +(override-init-options)= +#### Overriding Language Server Initialization Options + +When Serena starts a language server, it sends a set of `initializationOptions` as part of the +Language Server Protocol `initialize` request. These options are constructed internally and are +tailored to each language server. In some cases, you may want to override or extend these options, +e.g. to enable a feature or to adjust a behavior that is specific to your setup. + +Under the key `initializationOptions` within a language's `ls_specific_settings`, you can provide a +dictionary of options that is applied on top of the internally constructed `initializationOptions`. +The values are combined at the top level only: for each top-level key you define, your value +replaces the original value for that key exactly as given (there is no recursive/deep merge of +nested dictionaries). Internally constructed keys that you do not define are left unchanged. + +* If Serena constructs `initializationOptions` for the language server, each top-level key you + provide replaces the internally constructed value for that same key, while all other internally + constructed keys are retained. +* If Serena does not construct any `initializationOptions` for the language server, your custom + options are used as-is. + +Example: + +```yaml +ls_specific_settings: + : + initializationOptions: + someFeature: + enabled: true +``` + #### AL Serena uses the AL language server bundled in the Microsoft Dynamics 365 Business Central VS Code extension. diff --git a/src/solidlsp/initialize_params.py b/src/solidlsp/initialize_params.py index 4c3a8912..90c5b4db 100644 --- a/src/solidlsp/initialize_params.py +++ b/src/solidlsp/initialize_params.py @@ -67,3 +67,16 @@ class DefaultInitializeParamsBuilder(InitializeParamsBuilder): log.info("Additional workspace folders (not indexed by SolidLSP): %s", additional_abs_workspace_paths) workspace_folders.extend([self._create_workspace_folder_entry(abs_path) for abs_path in additional_abs_workspace_paths]) self._set("workspaceFolders", workspace_folders) + + init_options_key = "initializationOptions" + custom_init_options = self._ls.custom_settings.get(init_options_key, None) + if custom_init_options: + if not isinstance(custom_init_options, dict): + log.error("Custom initialization options should be a dictionary, but got %s. Ignoring.", type(custom_init_options).__name__) + else: + log.info("Applying custom initialization options: %s", custom_init_options) + if init_options_key in self._params: + self._params[init_options_key].update(custom_init_options) + else: + self._params[init_options_key] = custom_init_options + log.info("Final initialization options: %s", self._params[init_options_key]) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index ddb238c5..189ed397 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -764,6 +764,13 @@ class SolidLanguageServer(ABC): """ self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional + @property + def custom_settings(self) -> SolidLSPSettings.CustomLSSettings: + """ + The (user-provided) language server-specific settings. + """ + return self._custom_settings + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: """ Creates the dependency provider for this language server. From fd91dd10f67fdf5a644da7b7b6c2c5a5a1562963 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 14:18:07 +0200 Subject: [PATCH 23/57] Clean up language server settings classes, improving docstrings --- src/solidlsp/ls.py | 5 ++--- src/solidlsp/ls_config.py | 5 ++++- src/solidlsp/settings.py | 13 ++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 189ed397..5ebdd6e0 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -638,9 +638,8 @@ class SolidLanguageServer(ABC): repository_root_path = os.path.abspath(repository_root_path) ls_class = config.code_language.get_ls_class() - # For now, we assume that all language server implementations have the same signature of the constructor - # (which, unfortunately, differs from the signature of the base class). - # If this assumption is ever violated, we need branching logic here. + # All language server implementations are required to use the same signature of the constructor + # (which differs from the signature of the base class constructor). ls = ls_class(config, repository_root_path, solidlsp_settings) ls.set_request_timeout(timeout) return ls diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c666a2df..4ac6c844 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -834,10 +834,13 @@ class Language(str, Enum): @dataclass(frozen=True) class LanguageServerConfig: """ - Configuration parameters + Configuration parameters for a language server instance """ code_language: Language + """ + defines the language server to use + """ workspace_folders: list[str] = field(default_factory=lambda: ["."]) """ list of workspace folders to be used by the language server and to be fully indexed by SolidLSP. diff --git a/src/solidlsp/settings.py b/src/solidlsp/settings.py index 8d033d80..373f5d2f 100644 --- a/src/solidlsp/settings.py +++ b/src/solidlsp/settings.py @@ -18,6 +18,12 @@ log = logging.getLogger(__name__) @dataclass class SolidLSPSettings: + """ + Configures SolidLSP-specific data storage as well as global settings. + + Note: Server instance-specific settings belong in LanguageServerConfig, not here. + """ + solidlsp_dir: str = str(pathlib.Path.home() / ".solidlsp") """ Path to the directory in which to store global Solid-LSP data (which is not project-specific) @@ -34,13 +40,6 @@ class SolidLSPSettings: Have a look at the docstring of the constructors of the corresponding LS implementations within solidlsp to see which options are available. No documentation on options means no options are available. """ - additional_workspace_folders: list[str] = field(default_factory=list) - """ - Additional workspace folder paths for cross-package reference support. - Paths can be absolute or relative to the project root. Each folder is added as a workspace - folder in the LSP initialization, enabling language servers to discover symbols and references - across package boundaries (e.g. in monorepos). - """ def __post_init__(self) -> None: os.makedirs(str(self.solidlsp_dir), exist_ok=True) From cb7ba3f90513def8f8e0968fef44ac7334a2513e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 14:32:58 +0200 Subject: [PATCH 24/57] SolidLSP: Apply InitializeParamsBuilder to Angular/Svelte/Vue main servers The AngularLanguageServer, SvelteLanguageServer and VueLanguageServer classes (the primary ngserver/svelte-language-server/vue-language-server processes, all direct SolidLanguageServer subclasses) still hand-rolled their initialize params via _get_initialize_params, bypassing the builder. Only their companion *TypeScript* server classes (AngularTypeScriptServer, etc.) had been migrated previously. Convert the three main classes to _create_base_initialize_params so their processId/rootPath/rootUri/clientInfo/workspaceFolders are now supplied centrally by the InitializeParamsBuilder, consistent with every other language server. Server-specific initializationOptions (ngProbeLocations, svelte plugin configuration, Vue hybridMode) are preserved. Also update the Angular companion-cleanup test to monkeypatch the renamed _create_base_initialize_params (it patched the now-removed _get_initialize_params). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../angular_language_server.py | 18 ++++-------------- .../language_servers/svelte_language_server.py | 18 +++--------------- .../language_servers/vue_language_server.py | 18 ++++-------------- .../angular/test_angular_error_cases.py | 6 +++--- 4 files changed, 14 insertions(+), 46 deletions(-) diff --git a/src/solidlsp/language_servers/angular_language_server.py b/src/solidlsp/language_servers/angular_language_server.py index 5edd1dca..e7bf231d 100644 --- a/src/solidlsp/language_servers/angular_language_server.py +++ b/src/solidlsp/language_servers/angular_language_server.py @@ -70,7 +70,7 @@ from solidlsp.language_servers.typescript_language_server import ( from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer from solidlsp.ls import LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -502,8 +502,7 @@ class AngularLanguageServer(SolidLanguageServer): else: log.debug("Found @angular/core at %s", found) - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: params: dict = { "locale": "en", "capabilities": { @@ -531,17 +530,8 @@ class AngularLanguageServer(SolidLanguageServer): "tsProbeLocations": [os.path.join(self._install_dir, "node_modules")], "forceStrictTemplates": False, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], } - return params # type: ignore[return-value] + return params @override def _start_server(self) -> None: @@ -580,7 +570,7 @@ class AngularLanguageServer(SolidLanguageServer): try: log.info("Starting Angular language server (ngserver)") self.server.start() - init_params = self._get_initialize_params(self.repository_root_path) + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) log.debug("Angular LS initialize response: %s", init_response) self.server.notify.initialized({}) diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index 8b65c822..3c7f377b 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -28,7 +28,6 @@ from solidlsp.ls import ( SolidLanguageServer, ) from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -440,7 +439,7 @@ class SvelteLanguageServer(SolidLanguageServer): self.server.notify.send_notification = send_notification_wrapped - def _get_initialize_params(self) -> InitializeParams: + def _create_base_initialize_params(self) -> dict: """ Returns the initialize params for the Svelte Language Server. @@ -451,8 +450,6 @@ class SvelteLanguageServer(SolidLanguageServer): The resulting dict is also stored as :attr:`_lsp_configuration` so ``workspace/configuration`` requests can be answered with real values. """ - root_uri = pathlib.Path(self.repo_path).as_uri() - # base configuration mirroring all plugin-sections from svelte-vscode initializationOptions lsp_config: dict[str, Any] = { "svelte": {}, @@ -514,17 +511,8 @@ class SvelteLanguageServer(SolidLanguageServer): "dontFilterIncompleteCompletions": True, "configuration": lsp_config, }, - "processId": os.getpid(), - "rootPath": self.repo_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(self.repo_path), - } - ], } - return cast(InitializeParams, initialize_params) + return initialize_params def _start_server(self) -> None: def window_log_message(msg: dict) -> None: @@ -562,7 +550,7 @@ class SvelteLanguageServer(SolidLanguageServer): self._wrap_notify_send_for_ts_js_mirror() self.server.start() - init_params = self._get_initialize_params() + init_params = self._create_initialize_params() init_response = self.server.send.initialize(init_params) assert "documentSymbolProvider" in init_response["capabilities"], "Svelte LSP did not advertise documentSymbolProvider" diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py index a29ab7f5..b4d79297 100644 --- a/src/solidlsp/language_servers/vue_language_server.py +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -27,7 +27,7 @@ from solidlsp.ls_exceptions import SolidLSPException from solidlsp.ls_types import Location from solidlsp.ls_utils import PathUtils from solidlsp.lsp_protocol_handler import lsp_types -from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, SymbolInformation from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo from solidlsp.settings import SolidLSPSettings @@ -617,8 +617,7 @@ class VueLanguageServer(SolidLanguageServer): return [vue_executable_path, "--stdio"], tsdk_path, ts_ls_executable_path - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: - root_uri = pathlib.Path(repository_absolute_path).as_uri() + def _create_base_initialize_params(self) -> dict: initialize_params = { "locale": "en", "capabilities": { @@ -644,15 +643,6 @@ class VueLanguageServer(SolidLanguageServer): "symbol": {"dynamicRegistration": True}, }, }, - "processId": os.getpid(), - "rootPath": repository_absolute_path, - "rootUri": root_uri, - "workspaceFolders": [ - { - "uri": root_uri, - "name": os.path.basename(repository_absolute_path), - } - ], "initializationOptions": { "vue": { "hybridMode": True, @@ -662,7 +652,7 @@ class VueLanguageServer(SolidLanguageServer): }, }, } - return initialize_params # type: ignore + return initialize_params def _start_typescript_server(self) -> None: try: @@ -811,7 +801,7 @@ class VueLanguageServer(SolidLanguageServer): log.info("Starting Vue server process") self.server.start() - initialize_params = self._get_initialize_params(self.repository_root_path) + initialize_params = self._create_initialize_params() log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) diff --git a/test/solidlsp/angular/test_angular_error_cases.py b/test/solidlsp/angular/test_angular_error_cases.py index f65e7d09..85e9375e 100644 --- a/test/solidlsp/angular/test_angular_error_cases.py +++ b/test/solidlsp/angular/test_angular_error_cases.py @@ -335,10 +335,10 @@ class TestAngularStartupCleanup: def boom(*_args: object, **_kwargs: object) -> dict: raise RuntimeError("simulated ngserver init failure") - # _get_initialize_params runs after both companions and ngserver have - # been spawned — exactly the failure window the cleanup wrapper + # _create_base_initialize_params runs after both companions and ngserver + # have been spawned — exactly the failure window the cleanup wrapper # protects against. - monkeypatch.setattr(ls, "_get_initialize_params", boom) + monkeypatch.setattr(ls, "_create_base_initialize_params", boom) try: with pytest.raises(RuntimeError, match="simulated ngserver init"): From 6d39822363caf70a42ce8f063c618eb31f0a7b80 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 12:58:34 +0200 Subject: [PATCH 25/57] Make _create_base_initialize_params fully abstract, forcing subclasses to implement it --- src/solidlsp/ls.py | 2 +- test/solidlsp/test_rename_didopen.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 5ebdd6e0..73e1bfc3 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -3321,6 +3321,7 @@ class SolidLanguageServer(ABC): def _create_initialize_params_builder(self) -> InitializeParamsBuilder: return DefaultInitializeParamsBuilder(self) + @abstractmethod def _create_base_initialize_params(self) -> dict | InitializeParams: """ Subclasses should override this method to provide server-specific InitializeParams settings, @@ -3340,7 +3341,6 @@ class SolidLanguageServer(ABC): :return: the base InitializeParams settings """ - raise NotImplementedError def _create_initialize_params(self) -> InitializeParams: """ diff --git a/test/solidlsp/test_rename_didopen.py b/test/solidlsp/test_rename_didopen.py index 735e4642..f2902d89 100644 --- a/test/solidlsp/test_rename_didopen.py +++ b/test/solidlsp/test_rename_didopen.py @@ -7,6 +7,9 @@ class DummyLanguageServer(SolidLanguageServer): def _start_server(self) -> None: raise AssertionError("Not used in this test") + def _create_base_initialize_params(self) -> dict: + return {} + def test_request_rename_symbol_edit_opens_file_before_rename(tmp_path) -> None: (tmp_path / "index.ts").write_text("export const x = 1;\n", encoding="utf-8") From a7b638528bb5ba2a1d36309c65aab4b1e4023e36 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 3 Jul 2026 15:07:49 +0200 Subject: [PATCH 26/57] Cache FilenameMatcher instances, effectively turning them into per-language singletons --- src/solidlsp/ls_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 4ac6c844..e46106e2 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -8,6 +8,7 @@ import re from collections.abc import Iterable from dataclasses import dataclass, field from enum import Enum +from functools import cache from pathlib import Path from typing import TYPE_CHECKING, Self @@ -310,6 +311,7 @@ class Language(str, Enum): """ return self.get_ls_class().supports_implementation_request() + @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI | self.PYTHON_TY | self.PYTHON_PYREFLY: From de370b4d008d9d17e101b6eefcf166a2d7d383cb Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:22:43 -0400 Subject: [PATCH 27/57] HLSL: build shader_language_server with cargo --locked to fix macOS CI Without --locked, cargo install freshly resolves shader-sense ^1.3.1, which since 2026-07-04 picks shader-sense 1.4.0. That release removed the public field GlslCompilationParams.preamble (split into preamble_path/preamble_content), which shader_language_server 1.3.1's server_config.rs still constructs, so the from-source build fails with E0560 and cargo exits 101. macOS is the only platform that builds the server from source (Linux/Windows download prebuilt release binaries), so only the macOS CI jobs fail: all 15 test/solidlsp/hlsl fixtures error at setup (run 28726267725, job 85184119276). --locked makes cargo honor the Cargo.lock packaged with the published crate, which pins shader-sense 1.3.1 - the dependency set the 1.3.1 release was built and tested from - and immunizes the build against future lockstep releases of the two crates. Verified locally: the unlocked build reproduces the exact E0560/exit-101 failure; the locked build compiles cleanly and yields a working binary reporting v1.3.1. Also adds --locked to the manual-install help text, matching the existing convention in taplo_server.py. --- CHANGELOG.md | 4 ++++ src/solidlsp/language_servers/hlsl_language_server.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951222..2ebd342e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,10 @@ Status of the `main` branch. Changes prior to the next official version change w * Language Servers: - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. + - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source + on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. + Without it, fresh dependency resolution pulled in shader-sense 1.4.0, which no longer compiles + against the pinned shader_language_server 1.3.1, breaking the macOS CI job. - `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`. Enables Yarn PnP setups with `typescript.tsdk` pointing at the Yarn-generated SDK. - TypeScript/VTS: disable automatic typing acquisition during initialization (no network diff --git a/src/solidlsp/language_servers/hlsl_language_server.py b/src/solidlsp/language_servers/hlsl_language_server.py index 41b945ef..a41713eb 100644 --- a/src/solidlsp/language_servers/hlsl_language_server.py +++ b/src/solidlsp/language_servers/hlsl_language_server.py @@ -81,7 +81,7 @@ class HlslLanguageServer(SolidLanguageServer): base_url = f"{_GITHUB_RELEASE_BASE}/{tag}" # macOS has no pre-built binaries; build from source via cargo install - cargo_install_cmd = ["cargo", "install", "shader_language_server", "--version", version, "--root", "."] + cargo_install_cmd = ["cargo", "install", "shader_language_server", "--version", version, "--locked", "--root", "."] deps = RuntimeDependencyCollection( [ @@ -141,7 +141,7 @@ class HlslLanguageServer(SolidLanguageServer): raise FileNotFoundError( "shader-language-server is not installed and no auto-install is available for your platform.\n" "Please install it using one of the following methods:\n" - " cargo: cargo install shader_language_server\n" + " cargo: cargo install shader_language_server --locked\n" " GitHub: Download from https://github.com/antaalt/shader-sense/releases\n" "On macOS, install the Rust toolchain (https://rustup.rs) and Serena will build from source automatically.\n" "See https://github.com/antaalt/shader-sense for more details." From 9ccd5f02a20da96d88fe63a240355311dc373b97 Mon Sep 17 00:00:00 2001 From: osmanrafiq Date: Mon, 6 Jul 2026 00:14:28 +0200 Subject: [PATCH 28/57] Fix Java Gradle import JAVA_HOME fallback (#1657) --- CHANGELOG.md | 2 + docs/02-usage/050_configuration.md | 4 +- .../language_servers/eclipse_jdtls.py | 60 +++++--- .../java/test_jdtls_path_resolution.py | 139 ++++++++++++++++++ 4 files changed, 185 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951222..2d69a6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ Status of the `main` branch. Changes prior to the next official version change w - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") when `julia` is enabled. #1577 - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 + - `Java`: use `JAVA_HOME` for Gradle import when `use_system_java_home` is enabled and `gradle_java_home` + is unset. #1657 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 03f44761..29c6df7d 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -695,8 +695,8 @@ The following settings are supported for the Java language server: | `maven_user_settings` | `~/.m2/settings.xml` | Path to Maven `settings.xml` | | `gradle_user_home` | `~/.gradle` | Path to Gradle user home directory | | `gradle_wrapper_enabled` | `false` | Use the project's Gradle wrapper (`gradlew`) instead of the bundled Gradle distribution. Enable this for projects with custom plugins or repositories. | -| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses the bundled JRE. | -| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | +| `gradle_java_home` | `null` | Path to the JDK used by Gradle. When unset, Gradle uses `JAVA_HOME` if `use_system_java_home` is enabled and `JAVA_HOME` is set; otherwise it falls back to Serena's bundled JRE. | +| `use_system_java_home` | `false` | Use the system's `JAVA_HOME` environment variable for JDTLS itself and, when `gradle_java_home` is unset, Gradle import. Enable this if your project requires a specific JDK vendor or version for Gradle's JDK checks. | | `gradle_version` | `8.14.2` | (vscode-java mode only) Override the Gradle distribution version Serena downloads by default. | | `vscode_java_version` | `1.54.0-923` | (vscode-java mode only) Override the bundled `vscode-java` runtime bundle version Serena downloads by default. | | `intellicode_version` | `1.2.30` | (vscode-java mode only) Override the IntelliCode VSIX version Serena downloads by default. | diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 320f7ed1..8c85998d 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -180,8 +180,10 @@ class EclipseJDTLS(SolidLanguageServer): - maven_user_settings: Path to Maven settings.xml file (default: ~/.m2/settings.xml) - gradle_user_home: Path to Gradle user home directory (default: ~/.gradle) - gradle_wrapper_enabled: Whether to use the project's Gradle wrapper (default: false) - - gradle_java_home: Path to JDK for Gradle (default: null, uses bundled JRE) - - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself (default: false) + - gradle_java_home: Path to JDK for Gradle (default: null, falls back to JAVA_HOME when + use_system_java_home is true, then the bundled JRE) + - use_system_java_home: Whether to use the system's JAVA_HOME for JDTLS itself and, when + gradle_java_home is unset, Gradle import (default: false) - jdtls_xmx: Maximum heap size for the JDTLS server JVM (default: "3G") - jdtls_xms: Initial heap size for the JDTLS server JVM (default: "100m") - intellicode_xmx: Maximum heap size for the IntelliCode embedded JVM (default: "1G") @@ -217,7 +219,7 @@ class EclipseJDTLS(SolidLanguageServer): # gradle_user_home: 'C:\\Users\\YourName\\.gradle' # Windows (use single quotes!) gradle_wrapper_enabled: true # set to true for projects with custom plugins/repositories gradle_java_home: "/path/to/jdk" # set to override Gradle's JDK - use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS + use_system_java_home: true # set to true to use system JAVA_HOME for JDTLS and Gradle fallback jdtls_xmx: "3G" # maximum heap size for the JDTLS server JVM jdtls_xms: "100m" # initial heap size for the JDTLS server JVM intellicode_xmx: "1G" # maximum heap size for the IntelliCode embedded JVM @@ -904,6 +906,40 @@ class EclipseJDTLS(SolidLanguageServer): log.info(f"Using bundled JRE for JDTLS: {java_home}") return {"syntaxserver": "false", "JAVA_HOME": java_home} + def _resolve_gradle_java_home(self) -> str: + """ + Resolve the Java home configured for JDTLS Gradle import settings. + + :return: Java home selected from explicit Gradle override, system ``JAVA_HOME`` when enabled, or bundled runtime. + :raises FileNotFoundError: If an explicitly configured ``gradle_java_home`` path does not exist. + """ + # explicit Gradle override... + gradle_java_home = self._custom_settings.get("gradle_java_home") + if gradle_java_home is not None: + if not os.path.exists(gradle_java_home): + error_msg = ( + f"Gradle Java home not found: {gradle_java_home}. " + f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " + f"or remove the setting to use JAVA_HOME or the bundled JRE fallback" + ) + log.error(error_msg) + raise FileNotFoundError(error_msg) + log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") + return gradle_java_home + + # system JAVA_HOME fallback... + use_system_java_home = self._custom_settings.get("use_system_java_home", False) + if use_system_java_home: + system_java_home = os.environ.get("JAVA_HOME") + if system_java_home: + log.info(f"Using system JAVA_HOME for Gradle: {system_java_home}") + return system_java_home + log.warning("use_system_java_home is set but JAVA_HOME is not set in environment, falling back to bundled JRE for Gradle") + + # bundled runtime fallback... + log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") + return self.runtime_dependency_paths.jre_path + def _create_base_initialize_params(self) -> dict: """ Returns the initialize parameters for the EclipseJDTLS server. @@ -977,20 +1013,8 @@ class EclipseJDTLS(SolidLanguageServer): f"Gradle wrapper {'enabled' if gradle_wrapper_enabled else 'disabled'} (configurable via ls_specific_settings -> java -> gradle_wrapper_enabled)" ) - # Gradle Java home: default to None, which means the bundled JRE is used - gradle_java_home = self._custom_settings.get("gradle_java_home") - if gradle_java_home is not None: - if not os.path.exists(gradle_java_home): - error_msg = ( - f"Gradle Java home not found: {gradle_java_home}. " - f"Fix: update path in ~/.serena/serena_config.yml (ls_specific_settings -> java -> gradle_java_home), " - f"or remove the setting to use the bundled JRE" - ) - log.error(error_msg) - raise FileNotFoundError(error_msg) - log.info(f"Using Gradle Java home from custom location: {gradle_java_home}") - else: - log.info(f"Using bundled JRE for Gradle: {self.runtime_dependency_paths.jre_path}") + # Gradle Java home: explicit setting, system JAVA_HOME when requested, then bundled runtime. + gradle_java_home = self._resolve_gradle_java_home() initialize_params = { "locale": "en", @@ -1312,7 +1336,7 @@ class EclipseJDTLS(SolidLanguageServer): # ./gradlew wrapper or a system-installed Gradle via its standard discovery rules. if self.runtime_dependency_paths.gradle_path is not None: gradle_settings["home"] = self.runtime_dependency_paths.gradle_path - gradle_settings["java"] = {"home": gradle_java_home if gradle_java_home is not None else self.runtime_dependency_paths.jre_path} + gradle_settings["java"] = {"home": gradle_java_home} return initialize_params def _start_server(self) -> None: diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 2154eb9b..3492134a 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -21,6 +21,7 @@ from solidlsp.language_servers.eclipse_jdtls import ( JDTLS_CONFIG_DIR_BY_PLATFORM, JDTLS_MIN_JDK_VERSION, EclipseJDTLS, + RuntimeDependencyPaths, ) from solidlsp.ls_exceptions import SolidLSPException from solidlsp.settings import SolidLSPSettings @@ -54,6 +55,144 @@ def _make_fake_jdtls_install( return root +def _make_runtime_dependency_paths(root: Path) -> RuntimeDependencyPaths: + """ + Build minimal runtime dependency paths for initialize-parameter tests. + + :param root: Root directory under which fake runtime files are created. + :return: Runtime paths sufficient for ``EclipseJDTLS._create_base_initialize_params``. + """ + jre_home = root / "jre-home" + jre_home.mkdir(parents=True) + jre_bin = jre_home / "bin" + jre_bin.mkdir() + jre_path = jre_bin / _JAVA_EXE_NAME + jre_path.touch() + + gradle_path = root / "gradle" + gradle_path.mkdir() + + launcher = root / "jdtls-launcher.jar" + launcher.touch() + config = root / "config" + config.mkdir() + lombok = root / "lombok.jar" + lombok.touch() + + return RuntimeDependencyPaths( + jre_path=str(jre_path), + jre_home_path=str(jre_home), + jdtls_launcher_jar_path=str(launcher), + jdtls_readonly_config_path=str(config), + lombok_jar_path=str(lombok), + gradle_path=str(gradle_path), + ) + + +def _make_uninitialized_jdtls( + repository_root: Path, custom_settings: dict[str, object], runtime_dependency_paths: RuntimeDependencyPaths +) -> EclipseJDTLS: + """ + Build an EclipseJDTLS instance without starting dependency setup or JDTLS. + + :param repository_root: Repository root reported to initialization-parameter generation. + :param custom_settings: Java language-server settings to attach to the instance. + :param runtime_dependency_paths: Runtime paths to attach to the instance. + :return: Partially initialized server suitable for pure parameter-generation tests. + """ + server = object.__new__(EclipseJDTLS) + server.repository_root_path = str(repository_root) + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + server.runtime_dependency_paths = runtime_dependency_paths + return server + + +def _gradle_java_home(initialize_params: dict) -> str: + """ + Return the Gradle Java home from initialize parameters. + + :param initialize_params: JDTLS initialize-parameter payload. + :return: Configured Gradle Java home. + """ + return initialize_params["initializationOptions"]["settings"]["java"]["import"]["gradle"]["java"]["home"] + + +# ---------------------------------------------------------------------------- +# Gradle Java-home initialize settings +# ---------------------------------------------------------------------------- + + +class TestGradleJavaHomeInitializeSettings: + def test_explicit_gradle_java_home_takes_precedence_over_java_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify explicit Gradle Java home wins over system ``JAVA_HOME``.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + explicit_gradle_jdk = tmp_path / "explicit-gradle-jdk" + explicit_gradle_jdk.mkdir() + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls( + repo, + {"gradle_java_home": str(explicit_gradle_jdk), "use_system_java_home": True}, + runtime_paths, + ) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(explicit_gradle_jdk) + + def test_uses_java_home_for_gradle_when_system_java_home_enabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses system ``JAVA_HOME`` when the system setting is enabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == str(env_jdk) + + def test_uses_bundled_runtime_for_gradle_when_system_java_home_disabled(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify Gradle import uses the bundled runtime when system Java home is disabled.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + env_jdk = tmp_path / "env-jdk" + env_jdk.mkdir() + monkeypatch.setenv("JAVA_HOME", str(env_jdk)) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": False}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_uses_bundled_runtime_for_gradle_when_requested_java_home_is_unset( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Verify Gradle import falls back to the bundled runtime when ``JAVA_HOME`` is unset.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + monkeypatch.delenv("JAVA_HOME", raising=False) + + server = _make_uninitialized_jdtls(repo, {"use_system_java_home": True}, runtime_paths) + + assert _gradle_java_home(server._create_base_initialize_params()) == runtime_paths.jre_path + + def test_missing_explicit_gradle_java_home_raises(self, tmp_path: Path) -> None: + """Verify missing explicit Gradle Java home remains a configuration error.""" + repo = tmp_path / "repo" + repo.mkdir() + runtime_paths = _make_runtime_dependency_paths(tmp_path / "runtime") + + server = _make_uninitialized_jdtls(repo, {"gradle_java_home": str(tmp_path / "missing-jdk")}, runtime_paths) + + with pytest.raises(FileNotFoundError, match="Gradle Java home not found"): + server._create_base_initialize_params() + + # ---------------------------------------------------------------------------- # _resolve_launcher_jar # ---------------------------------------------------------------------------- From e08dfc7133ae78a42fa251f7e98861bcf98060f0 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Mon, 6 Jul 2026 15:32:51 -0600 Subject: [PATCH 29/57] Java (eclipse_jdtls): don't hard-ignore packages named dist/lib/classes/out (#1645) EclipseJDTLS.is_ignored_dirname hard-ignored directories named target/build/bin/out/classes/dist/lib. Several of these (dist, lib, classes, out) are valid Java package identifiers, so any source file whose package path contained one was dropped from directory traversal and became invisible to the symbol tools (find_symbol, get_symbols_overview, find_referencing_symbols) even when git check-ignore reported nothing. Per maintainer guidance in #1645, remove the override entirely: the base is_ignored_dirname already skips _ALWAYS_IGNORED_DIRS (VCS/venv/cache/IDE internals), and real build output is already excluded via .gitignore, so the hardcoded list was both redundant and harmful. Adds a regression test (no Java/JDTLS process required) that fails on main and asserts the formerly-hardcoded names are no longer ignored while _ALWAYS_IGNORED_DIRS still are. --- CHANGELOG.md | 4 +++ .../language_servers/eclipse_jdtls.py | 18 ------------ .../java/test_jdtls_path_resolution.py | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee6e2e8..8ac7d1a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,10 @@ Status of the `main` branch. Changes prior to the next official version change w - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 - `Java`: use `JAVA_HOME` for Gradle import when `use_system_java_home` is enabled and `gradle_java_home` is unset. #1657 + - `Java`: stop hard-ignoring directories named `target`/`build`/`bin`/`out`/`classes`/`dist`/`lib` in + `EclipseJDTLS`. These are all valid Java package identifiers, so ignoring them by name hid legitimate + source from the symbol tools even when they were not gitignored. Removed the hardcoded override; real + build output is already excluded via `.gitignore`. #1645 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. diff --git a/src/solidlsp/language_servers/eclipse_jdtls.py b/src/solidlsp/language_servers/eclipse_jdtls.py index 8c85998d..1539002b 100644 --- a/src/solidlsp/language_servers/eclipse_jdtls.py +++ b/src/solidlsp/language_servers/eclipse_jdtls.py @@ -254,24 +254,6 @@ class EclipseJDTLS(SolidLanguageServer): ls_resources_dir = self.ls_resources_dir(self._solidlsp_settings) return self.DependencyProvider(self._custom_settings, ls_resources_dir, self._solidlsp_settings, self.repository_root_path) - @override - def is_ignored_dirname(self, dirname: str) -> bool: - # Ignore common Java build directories from different build tools: - # - Maven: target - # - Gradle: build, .gradle - # - Eclipse: bin, .settings - # - IntelliJ IDEA: out, .idea - # - General: classes, dist, lib - return super().is_ignored_dirname(dirname) or dirname in [ - "target", # Maven - "build", # Gradle - "bin", # Eclipse - "out", # IntelliJ IDEA - "classes", # General - "dist", # General - "lib", # General - ] - class DependencyProvider(LanguageServerDependencyProvider): def __init__( self, diff --git a/test/solidlsp/java/test_jdtls_path_resolution.py b/test/solidlsp/java/test_jdtls_path_resolution.py index 3492134a..7e7cd71c 100644 --- a/test/solidlsp/java/test_jdtls_path_resolution.py +++ b/test/solidlsp/java/test_jdtls_path_resolution.py @@ -615,3 +615,32 @@ class TestComputeWorkspaceHash: configured_hash = EclipseJDTLS.DependencyProvider._compute_workspace_hash(self.REPO, self.DEFAULT_LAUNCHER, settings) assert configured_hash != legacy_hash + + +# ---------------------------------------------------------------------------- +# is_ignored_dirname (directory-traversal filter) +# ---------------------------------------------------------------------------- + + +class TestIsIgnoredDirname: + """Regression for #1645: the Java language server must not hard-ignore directories whose names + collide with build-tool output (``lib``, ``dist``, ``classes``, ``out``, ...). Those are all + valid Java package identifiers, so ignoring them by name hid legitimate source from the symbol + tools even when ``git check-ignore`` reported nothing. Real build output is already filtered via + ``.gitignore``, so directory traversal should only skip the language-agnostic + ``_ALWAYS_IGNORED_DIRS`` (VCS/venv/cache/IDE internals) inherited from the base language server. + """ + + @pytest.fixture + def jdtls(self) -> EclipseJDTLS: + # is_ignored_dirname only reads the class-level _ALWAYS_IGNORED_DIRS, so an uninitialized + # instance is sufficient here (no Java or JDTLS process required). + return object.__new__(EclipseJDTLS) + + @pytest.mark.parametrize("dirname", ["lib", "dist", "classes", "out", "target", "build", "bin"]) + def test_valid_package_dirnames_are_not_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is False + + @pytest.mark.parametrize("dirname", [".git", ".venv", ".idea", ".serena", ".mypy_cache"]) + def test_always_ignored_dirs_are_still_ignored(self, jdtls: EclipseJDTLS, dirname: str) -> None: + assert jdtls.is_ignored_dirname(dirname) is True From 3ab9ff3847d09a353484093a40818f44f08d5ce1 Mon Sep 17 00:00:00 2001 From: AmirF194 Date: Tue, 7 Jul 2026 11:50:56 -0600 Subject: [PATCH 30/57] fix(memory): reject absolute and escaping memory names in get_memory_file_path --- CHANGELOG.md | 2 ++ src/serena/memories/memory_manager.py | 51 +++++++++++++++++---------- test/serena/test_memories_manager.py | 46 ++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ee6e2e8..8f97e067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,8 @@ Status of the `main` branch. Changes prior to the next official version change w * Memories: - Make memory iteration follow symbolic links + - Fix: a memory name that was absolute (e.g. `/etc/cron.d/backdoor`) or contained empty path + segments could write outside the `memories` folder. * Dependencies: - Add dependency `oslex` diff --git a/src/serena/memories/memory_manager.py b/src/serena/memories/memory_manager.py index 370e3572..daa03725 100644 --- a/src/serena/memories/memory_manager.py +++ b/src/serena/memories/memory_manager.py @@ -147,11 +147,41 @@ class MemoryManager: # use a callable replacement to avoid backreference interpretation of characters in ref_new return re.subn(pattern, lambda _m: ref_new, content) + def _resolve_memory_path(self, base_dir: Path, parts: Sequence[str]) -> Path: + """ + Builds the ``*.md`` path for ``parts`` under ``base_dir``, creating any parent + subdirectories, and guarantees the result stays inside ``base_dir``. + + The containment check is a defense-in-depth backstop for :meth:`get_memory_file_path`'s + up-front segment validation: even if a crafted name slipped through, the built path must + never escape the memories sandbox (which would let an agent read/write/delete arbitrary + files). The check runs *before* any directory is created, so a rejected name cannot leave + stray directories behind either. It is deliberately *lexical* (``normpath``, no symlink + resolution): directory symlinks placed inside the memories folder are a supported way to + share memories (e.g. a monorepo symlinking each submodule's memory dir), and those must + keep resolving to their targets at I/O time. + """ + filename = f"{parts[-1]}.md" + subdir = base_dir if len(parts) == 1 else base_dir.joinpath(*parts[:-1]) + candidate = subdir / filename + base_norm = Path(os.path.normpath(base_dir)) + if not Path(os.path.normpath(candidate)).is_relative_to(base_norm): + raise ValueError(f"Memory name resolves outside the memories directory. Got: {'/'.join(parts)}") + subdir.mkdir(parents=True, exist_ok=True) + return candidate + def get_memory_file_path(self, name: str) -> Path: name = self._sanitize_name(name) parts = name.split("/") + if ".." in parts: - raise ValueError(f"Memory name cannot contain '..' segments for security reasons. Got: {name}") + raise ValueError(f"Memory name cannot contain '..' segments. Got: {name}") + + # Reject absolute names and empty path segments: pathlib discards the base directory when + # joined with an absolute path (e.g. "/etc/cron.d/backdoor" would reset to "/etc/cron.d"), + # letting a memory name escape the sandbox. A leading "/" produces an empty first segment. + if os.path.isabs(name) or "" in parts: + raise ValueError(f"Memory name cannot be absolute or contain empty path segments. Got: {name}") if self._is_global(name): if name == self.GLOBAL_TOPIC: @@ -160,26 +190,11 @@ class MemoryManager: ) # Strip "global/" prefix and resolve against global dir sub_name = name[len(self.GLOBAL_TOPIC) + 1 :] - parts = sub_name.split("/") - filename = f"{parts[-1]}.md" - if len(parts) > 1: - subdir = self._global_memory_dir / "/".join(parts[:-1]) - subdir.mkdir(parents=True, exist_ok=True) - return subdir / filename - return self._global_memory_dir / filename + return self._resolve_memory_path(self._global_memory_dir, sub_name.split("/")) # Project-local memory assert self._project_memory_dir is not None, "Project dir was not passed at initialization" - - filename = f"{parts[-1]}.md" - - if len(parts) > 1: - # Create subdirectory path - subdir = self._project_memory_dir / "/".join(parts[:-1]) - subdir.mkdir(parents=True, exist_ok=True) - return subdir / filename - - return self._project_memory_dir / filename + return self._resolve_memory_path(self._project_memory_dir, parts) def _check_write_access(self, name: str, is_tool_context: bool) -> None: # in tool context, memories can be read-only diff --git a/test/serena/test_memories_manager.py b/test/serena/test_memories_manager.py index 79be6832..158ab379 100644 --- a/test/serena/test_memories_manager.py +++ b/test/serena/test_memories_manager.py @@ -303,6 +303,52 @@ class TestListMemoriesFollowsSymlinks: assert manager.load_memory("submodule/nested/sub_b") == "# b" +class TestGetMemoryFilePathContainment: + """Regression: :meth:`MemoryManager.get_memory_file_path` must never return a path outside the + memories sandbox. An *absolute* memory name (e.g. ``/etc/cron.d/backdoor``) previously caused + ``pathlib`` to discard the base directory and reset to the absolute path, escaping + ``.serena/memories``. Since save/load/delete/move all route through this method, that let an + agent read, write, or delete arbitrary ``*.md`` files outside the sandbox. The guard must also + hold for the ``global/...`` branch, and the existing ``..`` rejection must keep working. + """ + + def test_absolute_project_memory_name_escapes_sandbox_is_rejected(self, fs_manager: MemoryManager, tmp_path) -> None: + # An absolute name pointing outside the memories dir. On the buggy code this silently + # returned a path under ``escape_target`` (outside ``memories``); the fix rejects it. + escape = str(tmp_path / "escape_target" / "backdoor") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path(escape) + + def test_absolute_project_memory_name_system_path_is_rejected(self, fs_manager: MemoryManager) -> None: + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("/etc/cron.d/backdoor") + + def test_absolute_global_memory_name_is_rejected(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + # "global//etc/..." routes through the global branch; the empty segment / absolute sub-name + # must be rejected there too. Redirect the global dir so the test never touches the real one. + monkeypatch.setattr(fs_manager, "_global_memory_dir", tmp_path / "global") + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("global//etc/cron.d/backdoor") + + def test_dotdot_segment_still_rejected(self, fs_manager: MemoryManager) -> None: + # the pre-existing guard must keep working + with pytest.raises(ValueError): + fs_manager.get_memory_file_path("../../etc/passwd") + + @pytest.mark.parametrize("name", ["notes", "topic/notes", "a/b/c/deep", "mem:topic/foo.md"]) + def test_accepted_project_names_resolve_inside_memories_dir(self, fs_manager: MemoryManager, name: str) -> None: + assert fs_manager._project_memory_dir is not None + path = fs_manager.get_memory_file_path(name) + assert path.resolve().is_relative_to(fs_manager._project_memory_dir.resolve()) + + def test_accepted_global_name_resolves_inside_global_dir(self, fs_manager: MemoryManager, tmp_path, monkeypatch) -> None: + global_dir = tmp_path / "global" + global_dir.mkdir() + monkeypatch.setattr(fs_manager, "_global_memory_dir", global_dir) + path = fs_manager.get_memory_file_path("global/topic/notes") + assert path.resolve().is_relative_to(global_dir.resolve()) + + class TestValidateReferentialIntegrity: def test_clean_report_when_all_references_resolve(self, fs_manager: MemoryManager) -> None: _write(fs_manager, "auth", "# auth notes") From d32fb248d443c4f0ccac091eddb257eead672d36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:40:46 +0000 Subject: [PATCH 31/57] Bump the uv group across 1 directory with 2 updates Bumps the uv group with 2 updates in the / directory: [agno](https://github.com/agno-agi/agno) and [soupsieve](https://github.com/facelessuser/soupsieve). Updates `agno` from 2.5.10 to 2.6.6 - [Release notes](https://github.com/agno-agi/agno/releases) - [Commits](https://github.com/agno-agi/agno/compare/v2.5.10...v2.6.6) Updates `soupsieve` from 2.8.3 to 2.8.4 - [Release notes](https://github.com/facelessuser/soupsieve/releases) - [Commits](https://github.com/facelessuser/soupsieve/compare/2.8.3...2.8.4) --- updated-dependencies: - dependency-name: agno dependency-version: 2.6.6 dependency-type: direct:production dependency-group: uv - dependency-name: soupsieve dependency-version: 2.8.4 dependency-type: indirect dependency-group: uv ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- uv.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bdd04a6f..2daa2dcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ dev = [ "pyasn1==0.6.3", "PyJWT==2.12.0", ] -agno = ["agno==2.5.10", "sqlalchemy==2.0.41"] # agno bumped for session state overwrite CVE +agno = ["agno==2.6.6", "sqlalchemy==2.0.41"] # agno bumped for session state overwrite CVE google = ["google-genai==1.27.0"] [project.urls] diff --git a/uv.lock b/uv.lock index 3595e84a..d82e273a 100644 --- a/uv.lock +++ b/uv.lock @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "agno" -version = "2.5.10" +version = "2.6.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docstring-parser" }, @@ -38,9 +38,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/ac/006e5907991b7c8a99d3ed7b39620a9fa4d3b5d069beba478202fe02d97f/agno-2.5.10.tar.gz", hash = "sha256:a00b2e700ac5661fc0af42996141524f75e2a374b7d7abc411b2bdfe3a9346d7", size = 1797557, upload-time = "2026-03-17T17:15:05.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/b7/17c737b7a482b204eeb287e2c1616ed4abea93d71501810c37f269d002fb/agno-2.6.6.tar.gz", hash = "sha256:c8767ff7528a4f8b7aa8c0543592c3b9cc3c2e3f5a2852555a1c493408df3acf", size = 2025094, upload-time = "2026-05-14T07:35:01.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/8b/df66cab1b74ea915f847e081abf21a498323580b34312cca6226895aff7f/agno-2.5.10-py3-none-any.whl", hash = "sha256:17ccea453a2007708499265f1df3696ee53ca9a286ca42551d2c5d20d9a41715", size = 2141266, upload-time = "2026-03-17T17:15:03.44Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d1/1a9078840c00cd2b6a2968dd11cc107c72290424ff7dbd4baf9a87617ac3/agno-2.6.6-py3-none-any.whl", hash = "sha256:ba8795e020d2a036af312734c1a796b5064466d4c45ab6d64ea51ec5ec2b2cd7", size = 2405077, upload-time = "2026-05-14T07:34:59.063Z" }, ] [[package]] @@ -2875,7 +2875,7 @@ google = [ [package.metadata] requires-dist = [ - { name = "agno", marker = "extra == 'agno'", specifier = "==2.5.10" }, + { name = "agno", marker = "extra == 'agno'", specifier = "==2.6.6" }, { name = "anthropic", specifier = "==0.59.0" }, { name = "beautifulsoup4", specifier = "==4.14.2" }, { name = "cryptography", specifier = "==48.0.1" }, @@ -2991,11 +2991,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] From 90334cc131b314a9b2fa7faf4a544c59237a9f0a Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:24:32 -0400 Subject: [PATCH 32/57] Solidity: await validation completion for diagnostics instead of a fixed wait The Nomic solidity-language-server validates asynchronously: document changes are debounced (~250ms), validation runs a forge/solc compile, and completion is signalled with the custom notification custom/validation-job-status -- emitted on every completion path (success, validation failure, job errors; verified in the bundled 0.8.4 package, which publishes the document's diagnostics before the signal). Serena ignored the signal ("Unhandled method 'custom/validation-job-status'") and waited a fixed 2.5s for a non-empty publishDiagnostics, returning [] whenever validation finished later. Warm environments fit the window; slow or cold ones (macOS and Windows CI runners, which download solc 0.8.20 on every run -- the CI cache only covers ~/.serena/language_servers/static, not ~/.local/share/svm) often do not. This is the root cause of the empty-diagnostics failures of test_file_diagnostics[solidity]: xfailed on macOS since its introduction (bd7e64e8) and failing on Windows on Tests run #4236, job catch-all (windows-latest). Shrinking the wait reproduces the exact failure signature (AssertionError: []) on Linux. Changes: - Handle custom/validation-job-status (threading.Event + last payload). The payload carries no document URI, so the event is global to the server instance. - request_text_document_diagnostics keeps the 2.5s non-empty publish wait as the fast path. When it misses, it awaits the completion signal against a 60s deadline (aligned with the indexing wait) and takes the generation-advancing publication for the request URI, accepting an empty result as the validated answer; a completion that brings no publication for the URI (e.g. one belonging to another document) re-arms the wait until the deadline. If the signal never arrives, a warning is logged and the previous empty-result behavior applies, bounded by the deadline. - Remove the darwin xfail from test_file_diagnostics; add regression tests for the fallback delivery (fast path forced to miss), for the never-signalled bound, and for re-arming on a spurious completion belonging to another document. - CHANGELOG entry under Language Servers. Design notes: - Mirrors #1639 (TypeScript/VTS: event-based indexing wait replacing a fixed sleep). - The simpler alternative -- accepting empty publications and raising the fixed wait -- was rejected: without the completion signal, an early clear-on-open publication would be taken as the answer, and every clean file would stall for the full wait. - Supersedes the quarantine approach of draft PR #1674 (broadening the xfail to non-Linux); whichever lands second resolves the trivial conflict on the xfail block. --- CHANGELOG.md | 1 + .../solidity_language_server.py | 66 ++++++++++- .../solidity/test_solidity_diagnostics.py | 105 ++++++++++++++++-- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7d1a1..1275013b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/src/solidlsp/language_servers/solidity_language_server.py b/src/solidlsp/language_servers/solidity_language_server.py index 515bd5ce..434c536a 100644 --- a/src/solidlsp/language_servers/solidity_language_server.py +++ b/src/solidlsp/language_servers/solidity_language_server.py @@ -10,7 +10,7 @@ import pathlib import platform import shutil import threading -from time import sleep +from time import monotonic, sleep from typing import Any from overrides import override @@ -40,6 +40,14 @@ class SolidityLanguageServer(SolidLanguageServer): Requires Node.js and npm to be installed. """ + _VALIDATION_COMPLETION_TIMEOUT = 60.0 + """upper bound for awaiting the validation-completion signal (``custom/validation-job-status``), + aligned with the indexing wait; on cold environments validation includes downloading the pinned + solc version""" + + _POST_VALIDATION_PUBLISH_WAIT = 2.5 + """grace period for the diagnostics publication that accompanies the completion signal""" + @staticmethod def _determine_log_level(line: str) -> int: """Suppress known non-critical stderr output from the Solidity language server.""" @@ -66,6 +74,11 @@ class SolidityLanguageServer(SolidLanguageServer): "solidity", solidlsp_settings, ) + self._validation_completed = threading.Event() + """set when the language server reports an asynchronous validation run as finished + (``custom/validation-job-status``); cleared at the start of each diagnostics request""" + self._last_validation_status: Any | None = None + """payload of the most recent ``custom/validation-job-status`` notification, kept for logging""" def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -242,11 +255,19 @@ class SolidityLanguageServer(SolidLanguageServer): if indexed_count[0] >= expected_count: all_indexed.set() + def on_validation_job_status(params: Any) -> None: + # signals completion of an asynchronous validation run (a forge/solc compile); + # the payload carries no document URI, so the event is global to the server instance + self._last_validation_status = params + log.debug(f"Solidity LSP: validation job finished: {params}") + self._validation_completed.set() + 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) self.server.on_notification("custom/file-indexed", on_file_indexed) + self.server.on_notification("custom/validation-job-status", on_validation_job_status) log.info("Starting Solidity language server process") self.server.start() @@ -300,6 +321,9 @@ class SolidityLanguageServer(SolidLanguageServer): "character": len(lines[-1]), } + # any validation that finishes after this point may satisfy the fallback wait below + self._validation_completed.clear() + self.server.notify.did_open_text_document( { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict LSPConstants.TEXT_DOCUMENT: { @@ -328,12 +352,52 @@ class SolidityLanguageServer(SolidLanguageServer): ], } ) + # fast path: wait for a non-empty diagnostics publication (warm environments) diagnostics = self._wait_for_relevant_published_diagnostics( uri=uri, after_generation=diagnostics_before_request, timeout=self._get_published_diagnostics_wait_timeout(True), allow_cached=True, ) + + # fallback: validation (a forge/solc compile) runs asynchronously in the server and can + # exceed the initial wait on slow or cold environments (e.g. CI runners downloading the + # pinned solc); await its completion signal, then take the publication made for this + # request, accepting an empty result as the validated answer. The signal carries no + # document URI, so a completion belonging to another document can wake the wait early; + # re-arm and keep waiting until the deadline in that case. + if diagnostics is None: + # fast path missed; validation is still running -- await its completion signal so the + # wait below is visible in logs on slow/cold environments + log.debug( + f"Solidity diagnostics not yet published for {uri}; " + f"awaiting validation completion (up to {self._VALIDATION_COMPLETION_TIMEOUT:.0f}s)" + ) + deadline = monotonic() + self._VALIDATION_COMPLETION_TIMEOUT + while diagnostics is None: + remaining = deadline - monotonic() + if remaining <= 0 or not self._validation_completed.wait(timeout=remaining): + log.warning( + f"Solidity validation completion was not signalled within " + f"{self._VALIDATION_COMPLETION_TIMEOUT:.0f}s for {uri} " + f"(last status: {self._last_validation_status}); returning without diagnostics" + ) + break + diagnostics = self._wait_for_published_diagnostics( + uri=uri, + after_generation=diagnostics_before_request, + timeout=self._POST_VALIDATION_PUBLISH_WAIT, + ) + if diagnostics is None: + # a completion fired but published nothing for our URI (e.g. a run for another + # document); re-arm and keep waiting so the ongoing wait stays visible + log.debug(f"Solidity validation completed without diagnostics for {uri} yet; continuing to wait") + self._validation_completed.clear() + else: + log.debug( + f"Solidity diagnostics resolved via validation completion " + f"(status={self._last_validation_status}): {len(diagnostics)} diagnostic(s)" + ) finally: self.server.notify.did_close_text_document( { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict diff --git a/test/solidlsp/solidity/test_solidity_diagnostics.py b/test/solidlsp/solidity/test_solidity_diagnostics.py index fd3cd6ba..21a11f3c 100644 --- a/test/solidlsp/solidity/test_solidity_diagnostics.py +++ b/test/solidlsp/solidity/test_solidity_diagnostics.py @@ -1,19 +1,42 @@ -import sys - import pytest from solidlsp import SolidLanguageServer +from solidlsp.language_servers.solidity_language_server import SolidityLanguageServer from solidlsp.ls_config import Language from test.solidlsp.util.diagnostics import assert_file_diagnostics +class _NeverSetEvent: + """Stand-in for :class:`threading.Event` whose wait never succeeds.""" + + def wait(self, timeout: float | None = None) -> bool: + return False + + def clear(self) -> None: + return None + + def set(self) -> None: + return None + + +class _AlwaysSignalledEvent: + """Stand-in for :class:`threading.Event` whose wait always reports a completion; counts clears.""" + + def __init__(self) -> None: + self.cleared = 0 + + def wait(self, timeout: float | None = None) -> bool: + return True + + def clear(self) -> None: + self.cleared += 1 + + def set(self) -> None: + return None + + @pytest.mark.solidity class TestSolidityDiagnostics: - @pytest.mark.xfail( - sys.platform == "darwin", - reason="Unknown — passes on Ubuntu but consistently fails on macOS CI; root cause not yet identified.", - strict=False, - ) @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: assert_file_diagnostics( @@ -22,3 +45,71 @@ class TestSolidityDiagnostics: (), min_count=1, ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_via_validation_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Diagnostics must still arrive via the validation-completion fallback when the initial + publish wait does not observe them (the failure mode of cold/slow CI runners). + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss so that only the validation-completion fallback can deliver + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + + assert_file_diagnostics( + language_server, + "contracts/DiagnosticsSample.sol", + (), + min_count=1, + ) + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_without_validation_signal( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A server that never signals validation completion must yield an empty result within the + bound instead of hanging or raising. + """ + assert isinstance(language_server, SolidityLanguageServer) + + # force the fast path to miss and make the completion signal unobtainable + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_VALIDATION_COMPLETION_TIMEOUT", 0.2) + monkeypatch.setattr(language_server, "_validation_completed", _NeverSetEvent()) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + assert diagnostics == [] + + @pytest.mark.parametrize("language_server", [Language.SOLIDITY], indirect=True) + def test_file_diagnostics_rearms_after_spurious_completion( + self, language_server: SolidLanguageServer, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A completion signal belonging to another document (the payload carries no URI) must + re-arm the fallback wait instead of ending the request without diagnostics. + """ + assert isinstance(language_server, SolidityLanguageServer) + + expected_diagnostic = { + "uri": "file:///spurious-completion-test", + "severity": 1, + "message": "delivered after re-arm", + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "code": None, + } + # first wake is spurious (no publication for the request URI), second delivers + grace_reads: list[list | None] = [None, [expected_diagnostic]] + event = _AlwaysSignalledEvent() + + # force the fast path to miss and script the fallback's wake/read sequence + monkeypatch.setattr(language_server, "_wait_for_relevant_published_diagnostics", lambda **kwargs: None) + monkeypatch.setattr(language_server, "_validation_completed", event) + monkeypatch.setattr(language_server, "_wait_for_published_diagnostics", lambda **kwargs: grace_reads.pop(0)) + + diagnostics = language_server.request_text_document_diagnostics("contracts/DiagnosticsSample.sol", min_severity=1) + + # one clear at request start plus exactly one re-arm after the spurious wake + assert event.cleared == 2 + assert not grace_reads # both scripted reads consumed + assert [diagnostic["message"] for diagnostic in diagnostics] == ["delivered after re-arm"] From 9580a6aec75a012faa2e1d91ebb8c627dca1190a Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Sun, 5 Jul 2026 05:43:57 -0400 Subject: [PATCH 33/57] Svelte + TypeScript: fail loudly on companion TS timeouts; deterministic test fixture Make the Svelte companion TypeScript server raise on readiness and indexing timeouts instead of silently proceeding with a partial index, turning flaky/wrong cross-file results into clear failures: - TypeScriptLanguageServer: timeout handling factored into overridable hooks (_handle_server_ready_timeout, _handle_project_indexing_timeout); the base server keeps its historical permissive behavior, so plain TypeScript setups are unchanged. - SvelteTypeScriptServer: both overrides raise TimeoutError; only the indexing-timeout error includes describe_indexing_state(), instead of serving requests from a cold or partially indexed program. - Configurable server_ready_timeout / indexing_timeout via ls_specific_settings. - SvelteLanguageServer: file-open failures during companion preparation raise SvelteCompanionPreparationError with a count-first file listing (capped at 10), chained to the first underlying error so the cause is visible without DEBUG logging. Apply the same fail-loudly principle to the test fixture bootstrap: - Svelte test repo: npm ci with a committed package-lock.json, removing dependency-resolution non-determinism from CI runs. - Run svelte-kit sync explicitly during fixture setup and fail loudly if .svelte-kit/tsconfig.json cannot be generated. The fixture's "prepare" script masks sync failures ("svelte-kit sync || echo ''"), and a known npm bug (npm/cli#4828) can silently skip platform-specific optional dependencies; the result is unresolvable $lib path aliases and cross-file tests failing with partial results instead of a clear, actionable error. - codespell: skip third-party fixture repositories, using the anchored pattern ./test/resources/repos (codespell matches skip globs against the ./-prefixed paths it walks, so an anchor-less pattern never matches). Also add direct regression tests for the timeout-policy split (test/solidlsp/test_typescript_timeout_policy.py: base stays permissive, companion raises, settings plumbing and precedence, and start-or-completion indexing-wait semantics). The tests use no mocking or patching of any kind; a hand-written call-recording fake drives the companion guard against real file discovery under a pytest tmp_path. They spawn no LS process and avoid external readiness races; one wait test uses a real Timer with a 30s ceiling. The configuration docs state that the TypeScript table lists both knobs; the Svelte table lists indexing_timeout, with readiness inheritance documented in prose. --- CHANGELOG.md | 5 + docs/02-usage/050_configuration.md | 8 + pyproject.toml | 4 +- .../svelte_language_server.py | 61 +- .../typescript_language_server.py | 113 +- .../repos/svelte/test_repo/.gitignore | 3 +- .../repos/svelte/test_repo/package-lock.json | 1864 +++++++++++++++++ test/solidlsp/svelte/conftest.py | 78 +- test/solidlsp/svelte/test_svelte_basic.py | 5 +- .../solidlsp/svelte/test_svelte_references.py | 8 +- .../test_typescript_timeout_policy.py | 269 +++ 11 files changed, 2358 insertions(+), 60 deletions(-) create mode 100644 test/resources/repos/svelte/test_repo/package-lock.json create mode 100644 test/solidlsp/test_typescript_timeout_policy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac7d1a1..6c07723a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w processed by the Svelte LS instead of the TypeScript LS. - `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty results in svelte-only mode (`languages: [svelte]`. #1552 + - Svelte + TypeScript: make companion TypeScript server raise on readiness and indexing timeouts (instead + of silent "proceeding anyway"), so that partial indexing surfaces as a clear failure instead of flaky/wrong + cross-file results; add configurable timeouts (`server_ready_timeout`, `indexing_timeout`) + and overridable timeout hooks (base TS stays permissive). Svelte test fixture now uses `npm ci` + + committed `package-lock.json`. - `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed") when `julia` is enabled. #1577 - `Java`: invalidate JDTLS workspace cache when Java import settings change #1576 diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 29c6df7d..ec7e5f95 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -1031,6 +1031,7 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. | | `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. | | `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. | +| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. | #### Svelte @@ -1048,8 +1049,15 @@ Supported settings: | `typescript_language_server_version` | `5.1.3` (falls back to `ls_specific_settings.typescript.typescript_language_server_version`) | Override the `typescript-language-server` npm package version for the companion server. | | `typescript_svelte_plugin_version` | `0.3.52` | Override the `typescript-svelte-plugin` npm package version used for `.svelte`-aware TS resolution. | | `npm_registry` | `null` | Override the npm registry Serena uses for all managed installs. | +| `indexing_timeout` | `120.0` (falls back to `ls_specific_settings.typescript.indexing_timeout`) | Timeout in seconds for the companion TS server to finish indexing `.svelte` files. On timeout, startup fails with a diagnostic indexing-state summary instead of serving cross-file results from a partially indexed program. | | `initialization_options_configuration` | `{}` | Deep-merge overrides for any of the ten plugin configuration sections (`svelte`, `prettier`, `emmet`, `typescript`, `javascript`, `js/ts`, `css`, `less`, `scss`, `html`). | +Unlike the plain TypeScript server, the companion is strict about readiness: it raises on server-ready and +indexing timeouts instead of proceeding with a cold or partially indexed program (which would silently degrade +cross-file renames and references). The companion reads `server_ready_timeout` and `indexing_timeout` from +`ls_specific_settings.typescript` with raised defaults (30s and 120s respectively); `ls_specific_settings.svelte.indexing_timeout` +takes precedence for the `.svelte`-file indexing wait. + All four packages are tracked via a version file; changing any version setting triggers a clean reinstall. #### TypeScript via `vtsls` diff --git a/pyproject.toml b/pyproject.toml index bdd04a6f..057d1e3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -382,7 +382,9 @@ markers = [ [tool.codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file -skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py' +# We skip third-party test fixture repositories entirely (they contain +# external package lockfiles, minified code, and arbitrary dependency strings). +skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py,./test/resources/repos' check-hidden = true ignore-regex = '\.\w+' ignore-words-list = 'paket,EDN,als,ue' diff --git a/src/solidlsp/language_servers/svelte_language_server.py b/src/solidlsp/language_servers/svelte_language_server.py index 3c7f377b..ad82617e 100644 --- a/src/solidlsp/language_servers/svelte_language_server.py +++ b/src/solidlsp/language_servers/svelte_language_server.py @@ -34,6 +34,12 @@ log = logging.getLogger(__name__) TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"}) JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"}) SVELTE_EXT = frozenset({".svelte"}) +# cap the file listing in SvelteCompanionPreparationError so a large repo cannot bloat the message +_MAX_FAILED_FILES_IN_ERROR = 10 + + +class SvelteCompanionPreparationError(RuntimeError): + """Raised when the companion TypeScript server cannot be prepared deterministically.""" def _is_ts_file(uri: str) -> bool: @@ -54,6 +60,9 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly. """ + INDEXING_PROGRESS_TIMEOUT = 120.0 + SERVER_READY_TIMEOUT = 30.0 + class DependencyProvider(TypeScriptLanguageServer.DependencyProvider): """Returns the pre-installed typescript-language-server binary. @@ -142,6 +151,16 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer): self.server.on_request("workspace/configuration", workspace_configuration_handler) super()._start_server() + @override + def _handle_server_ready_timeout(self, timeout: float) -> None: + raise TimeoutError(f"Svelte companion TypeScript server did not become ready within {timeout:.0f}s") + + @override + def _handle_project_indexing_timeout(self, timeout: float) -> None: + raise TimeoutError( + f"Svelte companion TypeScript server project indexing did not complete within {timeout:.0f}s ({self.describe_indexing_state()})" + ) + class SvelteLanguageServer(SolidLanguageServer): """ @@ -151,6 +170,9 @@ class SvelteLanguageServer(SolidLanguageServer): * ``svelte_language_server_version``: version of ``svelte-language-server`` to install (default: ``0.18.0``). * ``npm_registry``: optional alternative npm-compatible registry URL. + * ``indexing_timeout``: optional timeout in seconds for companion TS indexing of + Svelte files. Falls back to ``ls_specific_settings["typescript"].indexing_timeout`` + or the Svelte companion default. * ``initialization_options_configuration``: optional dict merged into ``initializeParams.initializationOptions.configuration`` (same top-level keys as in Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …). @@ -313,6 +335,15 @@ class SvelteLanguageServer(SolidLanguageServer): log.debug("Error processing svelte file %s: %s", svelte_file, exc) return svelte_files + def _get_companion_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for companion TS indexing after opening Svelte files.""" + ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + timeout = self._custom_settings.get( + "indexing_timeout", + ts_settings.get("indexing_timeout", SvelteTypeScriptServer.INDEXING_PROGRESS_TIMEOUT), + ) + return float(timeout) + def _ensure_svelte_files_indexed_on_ts_server(self) -> None: """Open all .svelte files on the companion TS server so the plugin includes them in the TS program. @@ -332,6 +363,8 @@ class SvelteLanguageServer(SolidLanguageServer): # prepare progress tracking BEFORE opening files to avoid a race self._ts_server.expect_indexing() + failed_svelte_files = [] + first_open_error: Exception | None = None for svelte_file in svelte_files: try: with self._ts_server.open_file(svelte_file) as file_buffer: @@ -339,15 +372,29 @@ class SvelteLanguageServer(SolidLanguageServer): self._indexed_svelte_file_uris.append(file_buffer.uri) except Exception as exc: log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc) + if first_open_error is None: + first_open_error = exc + failed_svelte_files.append(svelte_file) + + if failed_svelte_files: + shown_files = sorted(failed_svelte_files)[:_MAX_FAILED_FILES_IN_ERROR] + remainder = len(failed_svelte_files) - len(shown_files) + listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "") + raise SvelteCompanionPreparationError( + f"Failed to open {len(failed_svelte_files)} Svelte file(s) on companion TypeScript server: {listing}" + ) from first_open_error self._svelte_files_indexed = True log.info("Svelte file indexing complete; waiting for companion TS server to finish processing") - timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT - if self._ts_server.wait_for_indexing(timeout=timeout): + timeout = self._get_companion_indexing_timeout() + if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout): log.info("Companion TypeScript server finished indexing .svelte files") else: - log.warning("Timeout (%ss) waiting for companion TS server to index .svelte files; proceeding anyway", timeout) + raise TimeoutError( + f"Companion TypeScript server did not finish indexing {len(svelte_files)} .svelte files within {timeout:.0f}s " + f"({self._ts_server.describe_indexing_state()})" + ) def _cleanup_indexed_svelte_files(self) -> None: """Decrement ref-counts for all .svelte files opened during indexing.""" @@ -384,13 +431,13 @@ class SvelteLanguageServer(SolidLanguageServer): ) log.info("Starting companion SvelteTypeScriptServer") self._ts_server.start() - log.info("Waiting for companion SvelteTypeScriptServer to be ready ...") - if not self._ts_server.server_ready.wait(timeout=30.0): - log.warning("Timeout waiting for companion SvelteTypeScriptServer; proceeding anyway") - self._ts_server.server_ready.set() self._ts_server_started = True log.info("Companion SvelteTypeScriptServer ready") self._ensure_svelte_files_indexed_on_ts_server() + except (TimeoutError, SvelteCompanionPreparationError): + log.exception("Failed to prepare companion SvelteTypeScriptServer; aborting Svelte server startup") + self._stop_typescript_server() + raise except Exception: log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS") self._ts_server = None diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 278cd10c..03bda1c3 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -73,6 +73,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): - typescript_version: Version of TypeScript to install (default: "5.9.3") - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") - indexing_timeout: float, timeout in seconds for project indexing (default: 30.0) + - server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0) """ @classmethod @@ -82,6 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): # Safety timeout for $/progress-based indexing wait. Normally the event fires # well within this window; the timeout is only hit if the server never sends progress. INDEXING_PROGRESS_TIMEOUT = 30.0 + SERVER_READY_TIMEOUT = 10.0 def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ @@ -114,6 +116,35 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ return self._indexing_complete.wait(timeout=timeout) + def _wait_for_indexing_start_or_completion(self, timeout: float, start_grace: float | None = None) -> bool: + """Wait until TypeScript indexing has started and drained, or provably never started. + + :param timeout: Maximum seconds to wait once active indexing progress is observed. + :param start_grace: Maximum seconds to wait for progress to begin after opening files. + :return: True if indexing completed or no progress began within the grace period, False on timeout. + """ + grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace + + # wait for progress to begin + progress_deadline = time.monotonic() + grace + while time.monotonic() < progress_deadline: + with self._progress_lock: + if self._active_progress_tokens: + break + if self._indexing_complete.is_set(): + return True + time.sleep(0.05) + + # treat absent progress as ready + with self._progress_lock: + has_active_progress = bool(self._active_progress_tokens) + if not has_active_progress: + self._indexing_complete.set() + return True + + # wait for active progress to drain + return self.wait_for_indexing(timeout=timeout) + def expect_indexing(self) -> None: """Signal that new files are about to be opened and async indexing should be awaited. @@ -123,6 +154,44 @@ class TypeScriptLanguageServer(SolidLanguageServer): """ self._indexing_complete.clear() + def describe_indexing_state(self) -> str: + """:return: compact diagnostic state for TypeScript indexing progress.""" + with self._progress_lock: + active_tokens = sorted(token for token in self._active_progress_tokens if token) + complete = self._indexing_complete.is_set() + + token_text = ", ".join(active_tokens) if active_tokens else "" + return f"complete={complete}, active_progress_tokens={token_text}" + + def _get_server_ready_timeout(self) -> float: + """:return: maximum seconds to wait for the TypeScript server-ready signal.""" + return float(self._custom_settings.get("server_ready_timeout", self.SERVER_READY_TIMEOUT)) + + def _get_indexing_timeout(self) -> float: + """:return: maximum seconds to wait for TypeScript project indexing.""" + return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)) + + def _handle_server_ready_timeout(self, timeout: float) -> None: + """Handle a TypeScript server-ready timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a cold server. + """ + log.info("Timeout waiting for TypeScript server to become ready after %.0fs, proceeding anyway", timeout) + self.server_ready.set() + + def _handle_project_indexing_timeout(self, timeout: float) -> None: + """Handle a TypeScript project-indexing timeout. + + The base TypeScript server keeps the historical permissive behavior. Strict companion + servers override this hook to fail before serving requests from a partially indexed program. + """ + log.warning( + "TypeScript project indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) @@ -383,26 +452,22 @@ class TypeScriptLanguageServer(SolidLanguageServer): } self.server.notify.initialized({}) - if self.server_ready.wait(timeout=10.0): + server_ready_timeout = self._get_server_ready_timeout() + if self.server_ready.wait(timeout=server_ready_timeout): log.info("TypeScript server is ready") else: - log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway") - # Fallback: assume server is ready after timeout - self.server_ready.set() + self._handle_server_ready_timeout(server_ready_timeout) # Wait for any async project loading to complete. # typescript-language-server may send $/progress for "Initializing JS/TS # language features…" after initialized. If no progress is sent, # _indexing_complete stays SET and wait() returns immediately. - indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) + indexing_timeout = self._get_indexing_timeout() log.info("Waiting for TypeScript project indexing to complete (if async)...") if self.wait_for_indexing(timeout=indexing_timeout): log.info("TypeScript project indexing complete") else: - log.warning( - "TypeScript project indexing did not complete within %.0fs; proceeding anyway", - indexing_timeout, - ) + self._handle_project_indexing_timeout(indexing_timeout) self._activate_additional_workspaces() @@ -438,10 +503,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): @override def _wait_for_additional_workspace_indexing(self) -> None: - if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT): + timeout = self._get_indexing_timeout() + if self.wait_for_indexing(timeout=timeout): log.info("Additional workspace indexing complete") else: - log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway") + log.warning( + "Additional workspace indexing did not complete within %.0fs; proceeding anyway (%s)", + timeout, + self.describe_indexing_state(), + ) @override def _get_published_diagnostics_uri(self, request_uri: str) -> str: @@ -470,19 +540,14 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _wait_for_cross_file_references_if_needed(self) -> None: if self._has_waited_for_cross_file_references: return - # Give tsserver a short grace period to start reporting $/progress after the file - # was opened. If progress starts, wait until it drains (bounded by indexing_timeout); - # if it never starts, tsserver considers the project loaded and we proceed after the - # grace period — which is no worse than the previous fixed 2-second wait. - grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S - while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens: - time.sleep(0.05) - if self._active_progress_tokens: - timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT) - if self.wait_for_indexing(timeout=timeout): - log.info("TypeScript cross-file indexing complete") - else: - log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout) + + timeout = self._get_indexing_timeout() + if self._wait_for_indexing_start_or_completion(timeout=timeout): + log.info("TypeScript cross-file indexing complete") + else: + log.warning( + "TypeScript cross-file indexing did not complete within %.0fs; proceeding (%s)", timeout, self.describe_indexing_state() + ) self._has_waited_for_cross_file_references = True @override diff --git a/test/resources/repos/svelte/test_repo/.gitignore b/test/resources/repos/svelte/test_repo/.gitignore index fdfcaa3b..1e7ac1f2 100644 --- a/test/resources/repos/svelte/test_repo/.gitignore +++ b/test/resources/repos/svelte/test_repo/.gitignore @@ -23,6 +23,5 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # Lock files -package-lock.json pnpm-lock.yaml -yarn.lock \ No newline at end of file +yarn.lock diff --git a/test/resources/repos/svelte/test_repo/package-lock.json b/test/resources/repos/svelte/test_repo/package-lock.json new file mode 100644 index 00000000..5fa3a17a --- /dev/null +++ b/test/resources/repos/svelte/test_repo/package-lock.json @@ -0,0 +1,1864 @@ +{ + "name": "test-repo", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "test-repo", + "version": "0.0.1", + "devDependencies": { + "@fontsource/fira-mono": "^5.2.7", + "@neoconfetti/svelte": "^2.2.2", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.6.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/fira-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/fira-mono/-/fira-mono-5.2.7.tgz", + "integrity": "sha512-wYrAn6i3nH6luqQBZxtWUpl4UTUvs9AEbEeZxksPMwIqyjRRaxHTNW3c2VfM50gabS2IS7pT8lVWS2USB4ukYA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@neoconfetti/svelte": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@neoconfetti/svelte/-/svelte-2.2.2.tgz", + "integrity": "sha512-E7xCFVEEm5Ctnj2udTJy1b9oaTvjz1zi1mYdEtE8rB5BVwq6kHisosDS+zdWN5PMfEMjtbsOV9Cl6tsNSAD1sA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.69.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", + "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", + "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", + "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/solidlsp/svelte/conftest.py b/test/solidlsp/svelte/conftest.py index 24b5a836..1b6183b1 100644 --- a/test/solidlsp/svelte/conftest.py +++ b/test/solidlsp/svelte/conftest.py @@ -13,41 +13,79 @@ log = logging.getLogger(__name__) repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo" NODE_MODULES = repo_path / "node_modules" +PACKAGE_LOCK = repo_path / "package-lock.json" SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json" SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json" +SVELTE_KIT_TSCONFIG = repo_path / ".svelte-kit" / "tsconfig.json" INSTALL_LOCK = repo_path / ".svelte-install.lock" +def _fixture_ready() -> bool: + return SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists() and SVELTE_KIT_TSCONFIG.exists() + + +def _run_svelte_kit_sync(npm_executable: str) -> None: + """Generate .svelte-kit (notably its tsconfig.json carrying the $lib path aliases), failing loudly on error. + + The fixture's own ``prepare`` script masks sync failures (``svelte-kit sync || echo ''``); a missing + ``.svelte-kit/tsconfig.json`` leaves the fixture tsconfig's ``extends`` dangling, tsserver silently loses + the ``$lib`` path aliases, and cross-file tests fail with partial results that look like LS flakes. + """ + sync = subprocess.run( + [npm_executable, "exec", "--", "svelte-kit", "sync"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if sync.returncode != 0 or not SVELTE_KIT_TSCONFIG.exists(): + pytest.fail( + f"svelte-kit sync failed (rc={sync.returncode}) or did not produce {SVELTE_KIT_TSCONFIG}; " + "without it the $lib path aliases do not resolve and cross-file svelte tests fail with partial results.\n" + "Known cause: npm silently skipping platform-specific optional dependencies (npm/cli#4828), which " + "leaves rolldown without its native binding; remedy: remove node_modules and reinstall.\n" + f"stdout:\n{sync.stdout}\nstderr:\n{sync.stderr}" + ) + log.info("svelte-kit sync succeeded; %s is present", SVELTE_KIT_TSCONFIG) + + @pytest.fixture(scope="session", autouse=True) def _install_svelte_test_repo_node_modules() -> None: - """Populate the Svelte fixture's project dependencies via npm.""" - if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): - log.info("Svelte test repo node_modules already populated; skipping npm install") + """Populate the Svelte fixture's project dependencies via npm and generate .svelte-kit.""" + if _fixture_ready(): + log.info("Svelte test repo node_modules and .svelte-kit already populated; skipping npm install") return npm_executable = shutil.which("npm.cmd") or shutil.which("npm") if npm_executable is None: pytest.skip("npm is not available; cannot install Svelte test repo dependencies") + if not PACKAGE_LOCK.exists(): + pytest.fail(f"Svelte fixture lockfile is missing: {PACKAGE_LOCK}. Regenerate it before running npm ci.") + with FileLock(str(INSTALL_LOCK)): - if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists(): - log.info("Svelte test repo node_modules populated by another worker; skipping npm install") + if _fixture_ready(): + log.info("Svelte test repo dependencies populated by another worker; skipping npm install") return - log.warning("Installing npm dependencies into the Svelte test repo at %s.", repo_path) - proc = subprocess.run( - [npm_executable, "install"], - cwd=str(repo_path), - capture_output=True, - text=True, - check=False, - env=os.environ.copy(), - ) - if proc.returncode != 0: - log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) - pytest.skip(f"npm install failed in {repo_path} (rc={proc.returncode}); see logs for details") - if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): - pytest.skip("npm install completed but required Svelte fixture packages are missing") + log.warning("Installing npm dependencies into the Svelte test repo at %s with npm ci.", repo_path) + proc = subprocess.run( + [npm_executable, "ci"], + cwd=str(repo_path), + capture_output=True, + text=True, + check=False, + env=os.environ.copy(), + ) + if proc.returncode != 0: + log.error("npm ci failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr) + pytest.skip(f"npm ci failed in {repo_path} (rc={proc.returncode}); see logs for details") - log.info("Svelte test repo node_modules installed successfully") + if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists(): + pytest.skip("npm ci completed but required Svelte fixture packages are missing") + + log.info("Svelte test repo node_modules installed successfully") + + _run_svelte_kit_sync(npm_executable) diff --git a/test/solidlsp/svelte/test_svelte_basic.py b/test/solidlsp/svelte/test_svelte_basic.py index e49ebd0b..7218cc0f 100644 --- a/test/solidlsp/svelte/test_svelte_basic.py +++ b/test/solidlsp/svelte/test_svelte_basic.py @@ -76,9 +76,10 @@ class TestSvelteLanguageServer: coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)") definitions = language_server.request_definition(file_path, coords.line, coords.col) + definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions) - assert len(definitions) == 1, definitions - assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte" + assert len(definitions) == 1, definition_paths + assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None: diff --git a/test/solidlsp/svelte/test_svelte_references.py b/test/solidlsp/svelte/test_svelte_references.py index 7e25597c..07630c8d 100644 --- a/test/solidlsp/svelte/test_svelte_references.py +++ b/test/solidlsp/svelte/test_svelte_references.py @@ -14,13 +14,13 @@ class TestSvelteReferences: refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17) ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} - assert "src/routes/(sverdle)/words.server.ts" in ref_paths - assert "src/lib/game.ts" in ref_paths - assert "src/routes/(sverdle)/+page.svelte" in ref_paths + assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths) + assert "src/lib/game.ts" in ref_paths, sorted(ref_paths) + assert "src/routes/(sverdle)/+page.svelte" in ref_paths, sorted(ref_paths) @pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True) def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None: refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13) ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs} - assert "src/routes/(sverdle)/+page.server.ts" in ref_paths + assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths) diff --git a/test/solidlsp/test_typescript_timeout_policy.py b/test/solidlsp/test_typescript_timeout_policy.py new file mode 100644 index 00000000..f88ae17d --- /dev/null +++ b/test/solidlsp/test_typescript_timeout_policy.py @@ -0,0 +1,269 @@ +"""Regression tests for the TypeScript/Svelte timeout policy split. + +The base TypeScript server keeps its historical permissive behavior on readiness and +indexing timeouts (log and proceed), while the Svelte companion TS server is strict +(raise instead of serving requests from a cold or partially indexed program). These +tests pin that policy and the settings plumbing directly, without spawning language +server processes, so a future refactor cannot silently revert either side. +""" + +import threading +import time + +import pytest + +from solidlsp.language_servers.svelte_language_server import ( + SvelteCompanionPreparationError, + SvelteLanguageServer, + SvelteTypeScriptServer, +) +from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer +from solidlsp.settings import SolidLSPSettings + + +def _bare_ts_server(cls: type[TypeScriptLanguageServer], custom_settings: dict | None = None) -> TypeScriptLanguageServer: + """Create an instance without running __init__ (no process, no repo scan), setting only the + state the timeout machinery touches; same technique as test_rename_didopen.py. + """ + server = object.__new__(cls) + server.server_ready = threading.Event() + server._progress_lock = threading.Lock() + server._active_progress_tokens = set() + server._indexing_complete = threading.Event() + server._indexing_complete.set() # mirrors __init__: initially no active work + server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings) + return server + + +class TestBaseTypeScriptTimeoutPolicy: + """The base server must stay permissive: plain TypeScript setups are unchanged.""" + + def test_server_ready_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert not server.server_ready.is_set() + + server._handle_server_ready_timeout(10.0) # must not raise + + assert server.server_ready.is_set() + + def test_indexing_timeout_proceeds(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + server._handle_project_indexing_timeout(30.0) # must not raise + + def test_timeout_defaults(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + assert server._get_server_ready_timeout() == 10.0 + assert server._get_indexing_timeout() == 30.0 + + def test_timeouts_configurable_via_ls_specific_settings(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer, {"server_ready_timeout": 1.5, "indexing_timeout": 2.5}) + assert server._get_server_ready_timeout() == 1.5 + assert server._get_indexing_timeout() == 2.5 + + +class TestSvelteCompanionTimeoutPolicy: + """The Svelte companion must be strict: raise instead of serving from a cold/partial program.""" + + def test_server_ready_timeout_raises(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + + with pytest.raises(TimeoutError, match="did not become ready within 30s"): + server._handle_server_ready_timeout(30.0) + + assert not server.server_ready.is_set() # a strict server must not fake readiness + + def test_indexing_timeout_raises_with_diagnostic_state(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + server.expect_indexing() + server._active_progress_tokens.add("initializing-js-ts-features") + + with pytest.raises(TimeoutError) as exc_info: + server._handle_project_indexing_timeout(120.0) + + message = str(exc_info.value) + assert "did not complete within 120s" in message + assert "complete=False" in message + assert "initializing-js-ts-features" in message + + def test_companion_timeout_defaults_are_raised(self) -> None: + server = _bare_ts_server(SvelteTypeScriptServer) + assert server._get_server_ready_timeout() == 30.0 + assert server._get_indexing_timeout() == 120.0 + + +class TestWaitForIndexingStartOrCompletion: + def test_returns_false_when_active_progress_never_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("stuck-progress") + + assert server._wait_for_indexing_start_or_completion(timeout=0.1, start_grace=0.0) is False + + def test_treats_absent_progress_as_ready(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + assert server._indexing_complete.is_set() + + def test_returns_immediately_when_no_indexing_expected(self) -> None: + # _indexing_complete is initially set; a long grace must not block in that case + server = _bare_ts_server(TypeScriptLanguageServer) + + start = time.monotonic() + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=30.0) is True + # must return via the early is_set() check, not sit out the 30s grace; the path is + # pure in-memory, so a 10s bound cannot flake on a slow runner + assert time.monotonic() - start < 10.0 + + def test_returns_true_when_progress_completes(self) -> None: + server = _bare_ts_server(TypeScriptLanguageServer) + server.expect_indexing() + server._active_progress_tokens.add("indexing-1") + + def finish_indexing() -> None: + with server._progress_lock: + server._active_progress_tokens.clear() + server._indexing_complete.set() + + timer = threading.Timer(0.05, finish_indexing) + timer.start() + try: + # both timer orderings pass: an early fire takes the absent-progress branch, + # which sets the event itself, while a late fire wakes Event.wait; ordering + # cannot flake this test. It fails only if the spawned thread does not run + # within the 30s ceiling, which means the runner is hung. + assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True + finally: + timer.cancel() + + +class _FakeSolidLSPSettings: + """Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can + prove the svelte-key value takes precedence over the typescript-key value. + """ + + def __init__(self, ts_indexing_timeout: float) -> None: + self._ts_indexing_timeout = ts_indexing_timeout + + def get_ls_specific_settings(self, language: object) -> "SolidLSPSettings.CustomLSSettings": + return SolidLSPSettings.CustomLSSettings({"indexing_timeout": self._ts_indexing_timeout}) + + +class _FakeCompanionTSServer: + """Hand-written stand-in for the companion TS server that records calls, so the guard tests assert + observable behavior (raised errors, message shape, which timeout is used) instead of mock interactions. + """ + + class _FileBuffer: + def __init__(self, uri: str) -> None: + self.uri = uri + self.ref_count = 0 + + def __enter__(self) -> "_FakeCompanionTSServer._FileBuffer": + return self + + def __exit__(self, *exc: object) -> bool: + return False + + def __init__(self, *, wait_result: bool = True, open_error: Exception | None = None) -> None: + self.calls: list[str] = [] + self.wait_timeout: float | None = None + self._wait_result = wait_result + self._open_error = open_error + + def expect_indexing(self) -> None: + self.calls.append("expect_indexing") + + def open_file(self, relative_path: str) -> "_FakeCompanionTSServer._FileBuffer": + self.calls.append("open_file") + if self._open_error is not None: + raise self._open_error + return self._FileBuffer(uri=f"file:///{relative_path}") + + def _wait_for_indexing_start_or_completion(self, timeout: float) -> bool: + self.calls.append("wait") + self.wait_timeout = timeout + return self._wait_result + + def describe_indexing_state(self) -> str: + return "complete=False, active_progress_tokens=stuck-progress" + + +class TestSvelteLanguageServerCompanionGuard: + """SvelteLanguageServer._ensure_svelte_files_indexed_on_ts_server must fail loudly, not degrade.""" + + def _bare_svelte_ls(self, ts_server: _FakeCompanionTSServer, repo_path: str) -> SvelteLanguageServer: + ls = object.__new__(SvelteLanguageServer) + ls.repo_path = repo_path + ls._svelte_files_indexed = False + ls._indexed_svelte_file_uris = [] + ls._custom_settings = SolidLSPSettings.CustomLSSettings({"indexing_timeout": 0.01}) + # conflicting typescript-key value: the timeout assertions below prove the svelte key wins + ls._solidlsp_settings = _FakeSolidLSPSettings(ts_indexing_timeout=999.0) + ls._ts_server = ts_server + return ls + + def test_raises_when_companion_indexing_times_out(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=False) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(TimeoutError, match="did not finish indexing 1 .svelte files"): + ls._ensure_svelte_files_indexed_on_ts_server() + + # the svelte-key indexing_timeout (0.01), not the typescript-key (999.0), must reach the wait + assert ts_server.wait_timeout == 0.01 + + def test_succeeds_and_arms_progress_tracking_before_opening_files(self, tmp_path) -> None: + (tmp_path / "App.svelte").touch() + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "skip.svelte").touch() + (tmp_path / ".hidden").mkdir() + (tmp_path / ".hidden" / "skip.svelte").touch() + ts_server = _FakeCompanionTSServer(wait_result=True) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + ls._ensure_svelte_files_indexed_on_ts_server() + + assert ts_server.calls.count("open_file") == 1 + # a second call is a no-op — the observable consequence of the indexed flag + calls_after_indexing = list(ts_server.calls) + ls._ensure_svelte_files_indexed_on_ts_server() + assert ts_server.calls == calls_after_indexing + # expect_indexing must be armed BEFORE any file is opened, or early progress is lost to a race + assert ts_server.calls[0] == "expect_indexing" + assert ts_server.calls.index("expect_indexing") < ts_server.calls.index("open_file") + + def test_collects_failed_opens_and_raises_preparation_error(self, tmp_path) -> None: + (tmp_path / "B.svelte").touch() + (tmp_path / "A.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError, match="2 Svelte file") as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + assert "A.svelte, B.svelte" in str(exc_info.value) + # the first underlying error must be chained so the cause survives without DEBUG logs + assert isinstance(exc_info.value.__cause__, RuntimeError) + # a failed preparation must short-circuit before the indexing wait + assert "wait" not in ts_server.calls + + def test_preparation_error_caps_file_listing(self, tmp_path) -> None: + for i in reversed(range(15)): + (tmp_path / f"{i:02d}.svelte").touch() + ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed")) + ls = self._bare_svelte_ls(ts_server, str(tmp_path)) + + with pytest.raises(SvelteCompanionPreparationError) as exc_info: + ls._ensure_svelte_files_indexed_on_ts_server() + + message = str(exc_info.value) + assert "15 Svelte file(s)" in message + assert "09.svelte" in message # the 10th entry of the sorted listing is still shown + assert "10.svelte" not in message # the 11th is capped + assert "and 5 more" in message From 1901e63dc45d1f063741bf001067ac52391271be Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sun, 12 Jul 2026 03:05:13 -0600 Subject: [PATCH 34/57] fix(search): glob '?' matches one character, not two (#1682) --- CHANGELOG.md | 3 +++ src/serena/util/text_utils.py | 2 +- test/serena/test_text_utils.py | 17 ++++++++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78703962..1ce07eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Fix: in `glob_to_regex` / `search_text(is_glob=True)`, a `?` wildcard matched two characters instead + of one (it emitted `..` rather than `.`); it now matches a single character, consistent with the + `?` semantics documented for `glob_match` and the `test_??.py` example in `search_text`'s docstring. - Add notion of trusted projects via new global configuration setting `trusted_project_path_patterns`. Current effects: - `ls_specific_settings` defined in project configurations will only be applied for trusted projects diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 67110f70..992c2437 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -124,7 +124,7 @@ def glob_to_regex(glob_pat: str) -> str: if ch == "*": regex_parts.append(".*") elif ch == "?": - regex_parts.append("..") + regex_parts.append(".") elif ch == "\\": i += 1 if i < len(glob_pat): diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 71b0aad6..936779fc 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -1,9 +1,10 @@ +import re from collections.abc import Callable import pytest from serena.util.file_proxy import FileCollection, FileProxy -from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text +from serena.util.text_utils import LineType, MultiFileContentReplacer, glob_to_regex, search_files, search_text class TestSearchText: @@ -201,6 +202,20 @@ class TestSearchText: assert len(matches_curly) == 1 assert "{bar}" in matches_curly[0].lines[0].line_content + def test_glob_to_regex_question_matches_single_char(self): + """A glob '?' matches exactly one character, matching glob_match's documented semantics.""" + rx = glob_to_regex("a?c") + assert re.fullmatch(rx, "abc") is not None + assert re.fullmatch(rx, "ac") is None + assert re.fullmatch(rx, "abbc") is None + + def test_search_text_glob_question_single_char(self): + """search_text('c?t', is_glob=True) must match 'cat' (one char), not 'coat' (two chars).""" + content = "value_cat_end\nvalue_coat_end" + matches = search_text("c?t", content=content, is_glob=True, multiline=False) + assert len(matches) == 1 + assert "value_cat_end" in matches[0].lines[0].line_content + def test_search_text_no_matches(self): """Test searching with a pattern that doesn't match anything.""" content = """ From c933fd8ec2f4936040bc10768525afed230a017d Mon Sep 17 00:00:00 2001 From: xor <127287135+xormania@users.noreply.github.com> Date: Sun, 12 Jul 2026 05:18:27 -0400 Subject: [PATCH 35/57] SolidLSP: classify missing paths in is_ignored_path instead of raising (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Language servers can report locations of generated files that are not on disk. JDTLS does this for Lombok-generated types, reporting e.g. target/classes/test_repo/LombokModel$LombokModelBuilder.class while indexing; is_ignored_path then raised FileNotFoundError, turning an intermittent indexing state into a hard test failure (test_bare_symbol_names[java]; Tests run #4240, job jvm (macos-latest)). Changes: - is_ignored_path classifies missing paths with the usual lexical rules instead of raising: ignored-dirname components, the unsupported-extension rule, then the configured pathspec. A missing path is treated as a file if its name has a suffix and as a directory otherwise (so the leaf of a missing suffixless path is also checked against ignored dirnames). Behavior for existing paths is unchanged: the checks were reordered (ignored-dirname before unsupported-extension), but each check is pure, so the outcomes are identical. - SymbolLocationRequest.convert_location_item skips locations whose absolute path does not exist (log + return None), so phantom build artifacts never reach consumers regardless of ignore configuration. - New unit tests (test double, no language server process; they run in the catch-all CI batch) pin the missing-path classification — including the exact Lombok .class path shape from the CI failure — and the skipping of symbol locations that are not on disk. Notes: - The removed raise arrived verbatim with the multilspy import (2f607ff2) and was never a deliberate guard. - The Project layer's equivalent check already tolerates missing paths (9c19d5f7), but returns False unconditionally. The LS layer instead classifies lexically so that LS-reported build artifacts still classify as ignored: with #1645 having removed the JDTLS build-dirname ignores, it is the unsupported-extension rule that catches *.class paths. --- CHANGELOG.md | 4 + src/solidlsp/ls.py | 37 ++-- test/solidlsp/test_is_ignored_path_missing.py | 196 ++++++++++++++++++ 3 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 test/solidlsp/test_is_ignored_path_missing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df4879d3..1c2a62b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Make tool call errors surface explicitly as errors at the MCP protocol level * Language Servers: + - Fix: `SolidLanguageServer.is_ignored_path` raised `FileNotFoundError` for paths that are not present + on disk, crashing symbol tools when a language server reported locations of generated files + (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now + classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 73e1bfc3..57e44489 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -1367,26 +1367,27 @@ class SolidLanguageServer(ABC): Determine if a path should be ignored based on file type and ignore patterns. + Missing paths are classified without raising, since language servers may report + locations of generated files that are not present on disk (e.g. compiled classes + under build output). A missing path is treated as a file if its name has a suffix + and as a directory otherwise; directory-only ignore patterns (e.g. ``build/``) + match only existing directories. + :param relative_path: Relative path to check :param ignore_unsupported_files: whether files that are not supported source files should be ignored :return: True if the path should be ignored, False otherwise """ abs_path = os.path.join(self.repository_root_path, relative_path) - if not os.path.exists(abs_path): - raise FileNotFoundError(f"File {abs_path} not found, the ignore check cannot be performed") - - # Check file extension if it's a file - is_file = os.path.isfile(abs_path) - if is_file and ignore_unsupported_files: - fn_matcher = self.get_source_fn_matcher() - if not fn_matcher.is_relevant_filename(abs_path): - return True - - # Create normalized path for consistent handling rel_path = Path(relative_path) - # Check each part of the path against always fulfilled ignore conditions + # determine whether the path is a file: from the filesystem if the path exists, lexically otherwise + exists = os.path.exists(abs_path) + if not exists: + log.debug("Path %s does not exist; the ignore check is performed on the path itself", abs_path) + is_file = os.path.isfile(abs_path) if exists else bool(rel_path.suffix) + + # check each directory component against always-ignored dirnames dir_parts = rel_path.parts if is_file: dir_parts = dir_parts[:-1] @@ -1396,6 +1397,12 @@ class SolidLanguageServer(ABC): if self.is_ignored_dirname(part): return True + # apply the unsupported-extension rule to files + if is_file and ignore_unsupported_files: + fn_matcher = self.get_source_fn_matcher() + if not fn_matcher.is_relevant_filename(abs_path): + return True + return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) @contextmanager @@ -1667,6 +1674,12 @@ class SolidLanguageServer(ABC): ) return None + # skip locations of generated files that are not present on disk + # (language servers may report e.g. compiled classes under build output) + if not os.path.exists(abs_path): + log.info("%s found symbol at non-existent path: %s", self.request_name, abs_path) + return None + if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) return None diff --git a/test/solidlsp/test_is_ignored_path_missing.py b/test/solidlsp/test_is_ignored_path_missing.py new file mode 100644 index 00000000..56afc5e7 --- /dev/null +++ b/test/solidlsp/test_is_ignored_path_missing.py @@ -0,0 +1,196 @@ +"""Unit tests: missing-path handling in ``SolidLanguageServer``. + +``is_ignored_path`` must not raise on missing paths, and symbol locations resolving to +non-existent files must be skipped. Language servers may report locations of generated +files that are not present on disk, e.g. JDTLS reporting a Lombok-generated +``LombokModel$LombokModelBuilder.class`` under ``target/classes``; the ignore check must +classify such paths instead of raising ``FileNotFoundError``. + +No language markers: these use a local test double and run in catch-all. +""" + +from __future__ import annotations + +from pathlib import Path + +import pathspec +import pytest + +from solidlsp import SolidLanguageServer, ls_types +from solidlsp.ls_config import Language +from solidlsp.ls_utils import PathUtils + + +class _IgnoredPathServer(SolidLanguageServer): + """Minimal test double exposing only :meth:`is_ignored_path` dependencies.""" + + def __init__( + self, + root: Path, + language: Language, + *, + ignored_dirnames: tuple[str, ...] = (), + ignore_lines: tuple[str, ...] = (), + ) -> None: + self.repository_root_path = str(root) + self.language = language + self._ignored_dirnames = frozenset(ignored_dirnames) + self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, ignore_lines) + + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in self._ignored_dirnames + + def _start_server(self) -> None: + raise AssertionError("The test double must not start a language server") + + def _create_base_initialize_params(self) -> dict[str, object]: + raise AssertionError("The test double must not build initialize params") + + +def test_missing_lombok_class_under_target_is_ignored_not_raised(tmp_path: Path) -> None: + """JDTLS-style missing build artifact under target/classes -- must be ignored, never raise.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = "target/classes/test_repo/LombokModel$LombokModelBuilder.class" + + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_source_file_not_in_ignored_dir_is_not_ignored(tmp_path: Path) -> None: + """A missing source path classifies as not-ignored.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is False + + +def test_missing_source_file_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + missing = "generated/app.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_directory_with_ignored_dirname_leaf_is_ignored(tmp_path: Path) -> None: + """The leaf of a missing suffixless path may denote a directory and is checked against ignored dirnames.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + assert ls.is_ignored_path("generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path("src/generated", ignore_unsupported_files=False) is True + assert ls.is_ignored_path(".venv", ignore_unsupported_files=False) is True + + +def test_missing_extensionless_path_is_treated_as_directory(tmp_path: Path) -> None: + """A missing suffixless path is not subject to the unsupported-extension rule for files.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("newpkg") is False + + +def test_missing_ignored_by_pathspec_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignore_lines=("generated.py",)) + missing = "generated.py" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing) is True + + +def test_missing_unsupported_extension_can_be_allowed(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + missing = "src/foo.pyc" + assert not (tmp_path / missing).exists() + assert ls.is_ignored_path(missing, ignore_unsupported_files=False) is False + + +def test_existing_path_under_ignored_dirname_is_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert ls.is_ignored_path("generated/app.py") is True + + +def test_existing_source_file_is_not_ignored(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + src = tmp_path / "pkg" / "mod.py" + src.parent.mkdir(parents=True) + src.write_text("x = 1\n") + assert ls.is_ignored_path("pkg/mod.py") is False + + +@pytest.mark.parametrize( + "rel", + [ + "target/classes/test_repo/LombokModel$LombokModelBuilder.class", + "build/classes/Foo.class", + "out/production/Foo.class", + ], +) +def test_java_build_output_paths_never_raise(tmp_path: Path, rel: str) -> None: + """Compiled-class paths classify as ignored via the unsupported-extension rule, present or not + (build dirnames are deliberately not hard-ignored for Java). + """ + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + # neither present nor absent should raise + assert ls.is_ignored_path(rel) is True + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(b"") + assert ls.is_ignored_path(rel) is True + + +class _StubSymbolLocationRequest(SolidLanguageServer.SymbolLocationRequest): + """Minimal concrete subclass exposing only :meth:`convert_location_item` dependencies.""" + + def send_request(self) -> object | None: + raise AssertionError("The test double must not send LSP requests") + + def normalize_response(self, response: object | None) -> list[ls_types.Location]: + raise AssertionError("The test double must not normalize LSP responses") + + +def _location_request(ls: SolidLanguageServer) -> SolidLanguageServer.SymbolLocationRequest: + return _StubSymbolLocationRequest(ls, "src/app.py", 0, 0, request_name="test_request") + + +def _location_item(path: Path) -> dict: + zero = {"line": 0, "character": 0} + return {"uri": PathUtils.path_to_uri(str(path)), "range": {"start": zero, "end": zero}} + + +def test_location_at_missing_path_is_skipped(tmp_path: Path) -> None: + """Locations whose absolute path is not on disk (e.g. LS-reported build artifacts) are dropped.""" + ls = _IgnoredPathServer(tmp_path, Language.JAVA) + missing = tmp_path / "target" / "classes" / "LombokModel$LombokModelBuilder.class" + assert not missing.exists() + assert _location_request(ls).convert_location_item(_location_item(missing)) is None + + +def test_location_at_missing_source_path_is_skipped(tmp_path: Path) -> None: + """A missing path that is not ignored is dropped by the existence check alone.""" + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + assert ls.is_ignored_path("pkg/mod.py") is False + assert _location_request(ls).convert_location_item(_location_item(tmp_path / "pkg" / "mod.py")) is None + + +def test_location_at_existing_ignored_path_is_skipped(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON, ignored_dirnames=("generated",)) + source_file = tmp_path / "generated" / "app.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + assert _location_request(ls).convert_location_item(_location_item(source_file)) is None + + +def test_location_at_existing_source_path_is_converted(tmp_path: Path) -> None: + ls = _IgnoredPathServer(tmp_path, Language.PYTHON) + source_file = tmp_path / "pkg" / "mod.py" + source_file.parent.mkdir(parents=True) + source_file.write_text("x = 1\n") + location = _location_request(ls).convert_location_item(_location_item(source_file)) + assert location is not None + assert Path(location["absolutePath"]) == source_file + assert Path(location["relativePath"]) == Path("pkg/mod.py") From 837e1c249607bcf2ba589d38a51c8ce80023d7da Mon Sep 17 00:00:00 2001 From: Siddhartha Singh Date: Sun, 12 Jul 2026 15:18:07 +0530 Subject: [PATCH 36/57] preemptive fix in rust-analyzer: prevent indefinite startup hang and harden init (#1680) Hanging was never reproduced, this is preemptive hardening - Add a 120s timeout to the server_ready wait (previously waited with no timeout, so a server that crashed or never sent experimental/serverStatus with quiescent=true would hang startup forever). On timeout we log a warning and proceed, matching kotlin/matlab/powershell/etc. - Harden the language/status and experimental/serverStatus notification handlers to use dict.get() instead of raising KeyError on unexpected payloads (a KeyError in the quiescent handler could itself prevent server_ready from ever being set, feeding the hang). - Relax the over-strict completionProvider capability check: an exact-dict equality assertion crashed initialization whenever rust-analyzer changed or added a completionProvider field. These values are not used downstream, so a mismatch is now logged as a warning instead of asserted. --- .../language_servers/rust_analyzer.py | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/solidlsp/language_servers/rust_analyzer.py b/src/solidlsp/language_servers/rust_analyzer.py index 65f29665..2c01ed87 100644 --- a/src/solidlsp/language_servers/rust_analyzer.py +++ b/src/solidlsp/language_servers/rust_analyzer.py @@ -676,9 +676,8 @@ class RustAnalyzer(SolidLanguageServer): """ def register_capability_handler(params: dict) -> None: - assert "registrations" in params - for registration in params["registrations"]: - if registration["method"] == "workspace/executeCommand": + for registration in params.get("registrations", []): + if registration.get("method") == "workspace/executeCommand": self.initialize_searcher_command_available.set() self.resolve_main_method_available.set() return @@ -687,7 +686,7 @@ class RustAnalyzer(SolidLanguageServer): # TODO: Should we wait for # server -> client: {'jsonrpc': '2.0', 'method': 'language/status', 'params': {'type': 'ProjectStatus', 'message': 'OK'}} # Before proceeding? - if params["type"] == "ServiceReady" and params["message"] == "ServiceReady": + if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady": self.service_ready_event.set() def execute_client_command_handler(params: dict) -> list: @@ -697,7 +696,7 @@ class RustAnalyzer(SolidLanguageServer): return def check_experimental_status(params: dict) -> None: - if params["quiescent"] == True: + if params.get("quiescent") is True: self.server_ready.set() def window_log_message(msg: dict) -> None: @@ -718,13 +717,25 @@ class RustAnalyzer(SolidLanguageServer): log.info("Sending initialize request from LSP client to LSP server and awaiting response") init_response = self.server.send.initialize(initialize_params) - assert init_response["capabilities"]["textDocumentSync"]["change"] == 2 # type: ignore - assert "completionProvider" in init_response["capabilities"] - assert init_response["capabilities"]["completionProvider"] == { - "resolveProvider": True, - "triggerCharacters": [":", ".", "'", "("], - "completionItem": {"labelDetailsSupport": True}, - } + # Validate key server capabilities. These are sanity checks, not hard requirements: + # rust-analyzer may evolve its advertised capabilities across versions, so a mismatch is + # logged rather than asserted. (An over-strict equality check on completionProvider here + # previously crashed startup whenever upstream added or changed a completionProvider field.) + capabilities = init_response.get("capabilities", {}) if isinstance(init_response, dict) else {} + text_document_sync = capabilities.get("textDocumentSync") + change_kind = text_document_sync.get("change") if isinstance(text_document_sync, dict) else text_document_sync + if change_kind != 2: + log.warning("rust-analyzer: unexpected textDocumentSync.change=%r (expected 2 = incremental)", change_kind) + if "completionProvider" not in capabilities: + log.warning("rust-analyzer: server did not advertise a completionProvider capability") self.server.notify.initialized({}) - self.server_ready.wait() + # Wait for rust-analyzer to finish initial indexing (it emits experimental/serverStatus with + # quiescent=true when ready). Use a timeout so a server that crashes or never signals + # readiness cannot hang startup indefinitely (previously this waited with no timeout). + _SERVER_READY_TIMEOUT = 120.0 + log.info("Waiting for rust-analyzer to signal readiness (quiescent)...") + if self.server_ready.wait(timeout=_SERVER_READY_TIMEOUT): + log.info("rust-analyzer ready") + else: + log.warning("rust-analyzer did not signal readiness within %.0fs; proceeding anyway", _SERVER_READY_TIMEOUT) From 27b0747057f4e527df4045c665b9e0427fb158cf Mon Sep 17 00:00:00 2001 From: Siddhartha Singh Date: Sun, 12 Jul 2026 15:24:05 +0530 Subject: [PATCH 37/57] fix(solidlsp): correct inverted case-folding in FilenameMatcher (#1679) The code path was never used, so the bug never surfaced --- src/solidlsp/ls_config.py | 2 +- test/solidlsp/test_ls_config.py | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/solidlsp/test_ls_config.py diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index e46106e2..c1e02d7e 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -40,7 +40,7 @@ class FilenameMatcher: a *complete* extension — i.e. the extension must either end the string or be followed by a non-extension-character (anything other than a letter, digit, or underscore). """ - if self._case_sensitive: + if not self._case_sensitive: string = string.lower() for ext in self._file_extensions: if re.search(rf"{re.escape(ext)}(?:\W|$)", string): diff --git a/test/solidlsp/test_ls_config.py b/test/solidlsp/test_ls_config.py new file mode 100644 index 00000000..680bb399 --- /dev/null +++ b/test/solidlsp/test_ls_config.py @@ -0,0 +1,46 @@ +from solidlsp.ls_config import FilenameMatcher + + +class TestFilenameMatcherCaseSensitivity: + """Regression tests for FilenameMatcher case-sensitivity handling. + + ``is_relevant_filename`` and ``string_contains_relevant_filename`` must apply the same + case-folding rule: fold to lower case only when ``case_sensitive=False``. A previously inverted + check in ``string_contains_relevant_filename`` folded the input when ``case_sensitive=True`` + (and left it unfolded when ``case_sensitive=False``), so both modes could mismatch. + """ + + def test_case_insensitive_matches_uppercase_input(self) -> None: + matcher = FilenameMatcher(".py", ".pyi", case_sensitive=False) + # Uppercase / mixed-case input must match a lower-case extension when case-insensitive. + assert matcher.is_relevant_filename("FOO.PY") + assert matcher.is_relevant_filename("Foo.PyI") + assert matcher.string_contains_relevant_filename("opened FOO.PY for editing") + assert matcher.string_contains_relevant_filename("see Foo.PyI:10") + + def test_case_sensitive_respects_extension_case(self) -> None: + # R uses case-significant extensions: .R and .r are distinct. + matcher = FilenameMatcher(".R", case_sensitive=True) + assert matcher.is_relevant_filename("script.R") + assert not matcher.is_relevant_filename("script.r") + # The same must hold for the substring variant (previously inverted). + assert matcher.string_contains_relevant_filename("edited script.R just now") + assert not matcher.string_contains_relevant_filename("edited script.r just now") + + def test_two_methods_agree_on_case_folding(self) -> None: + """The two matcher methods must never disagree on a filename that ends the string.""" + for case_sensitive in (True, False): + for extensions in ((".py",), (".R", ".Rmd"), (".ts", ".tsx")): + matcher = FilenameMatcher(*extensions, case_sensitive=case_sensitive) + for candidate in ("main.py", "Main.PY", "app.R", "app.r", "x.ts", "X.TSX"): + assert matcher.is_relevant_filename(candidate) == matcher.string_contains_relevant_filename(candidate), ( + f"disagreement for {candidate!r} with extensions={extensions} case_sensitive={case_sensitive}" + ) + + def test_string_contains_requires_complete_extension(self) -> None: + """A registered extension must be a *complete* extension, not a prefix of a longer word.""" + matcher = FilenameMatcher(".py", case_sensitive=False) + assert matcher.string_contains_relevant_filename("run main.py now") + assert matcher.string_contains_relevant_filename("main.py") + # ".py" embedded in ".python" is not a complete extension occurrence. + assert not matcher.string_contains_relevant_filename("file.python") From 7b06a67509201eecec5434e107764e3fbb150855 Mon Sep 17 00:00:00 2001 From: weiconghe <13976098570@163.com> Date: Fri, 3 Jul 2026 22:49:28 +0800 Subject: [PATCH 38/57] Perl: expose file_filter/ignore_dirs via ls_specific_settings (#1449) Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories in `perl.ignoreDirs`. Both were hardcoded in PerlLanguageServer, so projects whose Perl source uses non-standard extensions (e.g. .cgi / .psgi web handlers, which can dominate a mature codebase) had no way to make those files visible to symbol/reference queries without forking Serena (#1449). Surface both lists through `ls_specific_settings["perl"]` (keys `file_filter` and `ignore_dirs`), defaulting to the existing values so behaviour is unchanged for current projects. The resolution lives in a pure static method `_resolve_filter_settings` so the config plumbing is covered by unit tests that don't require a Perl runtime. Keep Serena's symbol index in sync with the LS (#1449, per review): `find_symbol` and symbol indexing are driven by `Language.PERL.get_source_fn_matcher()`, so adding an extension (e.g. .cgi) to `file_filter` alone would leave the matcher untouched and the extension's symbols invisible. Since that matcher is now a @cache'd per-language singleton, add a `FilenameMatcher.add_extensions()` mutator and call it from `PerlLanguageServer` so the configured extensions also reach the symbol index, the ignore checks, and language composition. Avoid cross-project leakage (per review): because the matcher is a process-wide singleton, extensions added for one project would otherwise persist into the next. `FilenameMatcher` now snapshots its initial configuration and exposes `reset()`, and `SolidLanguageServer.__init__` resets the matcher right after `self.language` is determined, so every activation starts from the language's defaults before the subclass re-applies its own `file_filter`. This complements 860c5841 (which added .t to the default list as a surface-level fix) by addressing the root configurability gap that the maintainer acknowledged in #1449. --- CHANGELOG.md | 6 + docs/02-usage/050_configuration.md | 28 +++ .../language_servers/perl_language_server.py | 58 ++++- src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 33 +++ test/solidlsp/perl/__init__.py | 0 test/solidlsp/perl/test_perl_config.py | 220 ++++++++++++++++++ 7 files changed, 341 insertions(+), 9 deletions(-) create mode 100644 test/solidlsp/perl/__init__.py create mode 100644 test/solidlsp/perl/test_perl_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index defdf57b..98f7044b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,12 @@ Status of the `main` branch. Changes prior to the next official version change w (e.g. JDTLS reporting Lombok-generated classes under `target/classes`). Missing paths are now classified by the usual ignore rules, and symbol locations resolving to non-existent files are skipped. - Solidity: fix diagnostics intermittently coming back empty on slow or cold environments (macOS/Windows CI). + - Perl: expose `file_filter` and `ignore_dirs` via `ls_specific_settings["perl"]`, so projects with + non-standard extensions (e.g. `.cgi`, `.psgi`) can make those files visible to Perl::LanguageServer. + Configured extensions are also synced into the Perl source-file matcher (now a cached singleton), + keeping `find_symbol` / symbol indexing consistent with the LS; the matcher is reset on every + language server activation so one project's reconfiguration does not leak into the next. + `FilenameMatcher` gains `add_extensions` / `reset` methods. Defaults are unchanged. #1449 - C/C++ (clangd): improve support and documentation for Unreal Engine 5 projects. - HLSL (`shader-language-server`): pass `--locked` to `cargo install` when building from source on macOS (and in the manual-install instructions), honoring the crate's packaged `Cargo.lock`. diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index ec7e5f95..21813640 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -871,6 +871,34 @@ Notes: - Use the FPC compiler driver (`fpc`/`fpc.exe`), not backend compilers like `ppc386.exe`. - These settings are passed as environment variables to the pasls process. +#### Perl + +Serena uses [Perl::LanguageServer](https://metacpan.org/pod/Perl::LanguageServer) for Perl support. Install Perl and the server with `cpanm Perl::LanguageServer`; Linux and macOS only (the server does not run on Windows). + +Perl::LanguageServer only indexes files whose extension is in its `perl.fileFilter` and skips directories listed in `perl.ignoreDirs`. Both are exposed below so projects with non-standard extensions (e.g. `.cgi` / `.psgi` web handlers) can make those files visible (#1449). + +**Configuration:** + +Configure the language server via `ls_specific_settings.perl` in `serena_config.yml`: + +| Setting | Default | Description | +| -------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `file_filter` | `[".pm", ".pl", ".t"]` | File extensions (with leading dot) that Perl::LanguageServer should index, e.g. `[".pm", ".pl", ".t", ".cgi"]`. | +| `ignore_dirs` | `[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]` | Directory names Perl::LanguageServer should skip when indexing. | + +Example configuration: + +```yaml +ls_specific_settings: + perl: + file_filter: [".pm", ".pl", ".t", ".cgi", ".psgi"] + ignore_dirs: [".git", "blib", "local", "vendor", "cover_db"] +``` + +Notes: +- Extensions added via `file_filter` are also synced into Serena's Perl source-file matcher, so `find_symbol` and symbol indexing treat the same files as the language server. Defaults are unchanged when these keys are omitted. +- The matcher is reset on every language server activation, so one project's `file_filter` does not leak into another. + #### PHP (`Intelephense`) Serena uses Intelephense for the `php` language key. diff --git a/src/solidlsp/language_servers/perl_language_server.py b/src/solidlsp/language_servers/perl_language_server.py index 9b785ded..7e3744b6 100644 --- a/src/solidlsp/language_servers/perl_language_server.py +++ b/src/solidlsp/language_servers/perl_language_server.py @@ -12,7 +12,7 @@ from typing import Any from overrides import override from solidlsp.ls import SolidLanguageServer -from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils from solidlsp.lsp_protocol_handler.lsp_types import DidChangeConfigurationParams from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo @@ -20,15 +20,26 @@ from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) +# Defaults handed to Perl::LanguageServer via its `perl.fileFilter` / `perl.ignoreDirs` settings. +# Exposed for override via `ls_specific_settings["perl"]`. Extensions added via `file_filter` are +# also synced into Language.PERL.get_source_fn_matcher() (a cached singleton) so Serena's symbol +# index and the language server agree on which files are Perl sources (#1449). +_DEFAULT_FILE_FILTER: list[str] = [".pm", ".pl", ".t"] +_DEFAULT_IGNORE_DIRS: list[str] = [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"] + class PerlLanguageServer(SolidLanguageServer): """ Provides Perl specific instantiation of the LanguageServer class using Perl::LanguageServer. - """ - # Keep in sync with Language.PERL.get_source_fn_matcher() in solidlsp/ls_config.py: - # extensions missing here are invisible to Perl::LanguageServer's project index. - _FILE_FILTER: list = [".pm", ".pl", ".t"] + You can pass the following entries in ``ls_specific_settings["perl"]``: + - file_filter: List of file extensions (with leading dot) that Perl::LanguageServer should + index, e.g. ``[".pm", ".pl", ".t", ".cgi"]``. Defaults to ``[".pm", ".pl", ".t"]``. + The same extensions are added to Serena's Perl source-file matcher, so ``find_symbol`` + and symbol indexing treat them consistently (see #1449). + - ignore_dirs: Directory names Perl::LanguageServer should skip when indexing. Defaults to + ``[".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"]``. + """ @staticmethod def _get_perl_version() -> str | None: @@ -101,6 +112,31 @@ class PerlLanguageServer(SolidLanguageServer): return "perl -MPerl::LanguageServer -e 'Perl::LanguageServer::run'" + @staticmethod + def _resolve_filter_settings(solidlsp_settings: SolidLSPSettings) -> tuple[list[str], list[str]]: + """Resolve the ``fileFilter`` / ``ignoreDirs`` to hand to Perl::LanguageServer. + + Reads optional overrides from ``ls_specific_settings["perl"]`` (keys ``file_filter`` and + ``ignore_dirs``); falls back to the defaults otherwise. Extracted as a pure function so the + configuration plumbing can be unit-tested without starting the language server. + """ + perl_settings = solidlsp_settings.get_ls_specific_settings(Language.PERL) + file_filter = perl_settings.get("file_filter", list(_DEFAULT_FILE_FILTER)) + ignore_dirs = perl_settings.get("ignore_dirs", list(_DEFAULT_IGNORE_DIRS)) + return file_filter, ignore_dirs + + @staticmethod + def _sync_source_fn_matcher(file_filter: list[str]) -> None: + """Keep Serena's Perl source-file matcher in sync with the LS ``fileFilter``. + + ``Language.PERL.get_source_fn_matcher()`` is a ``@cache``d per-language singleton, so adding + the configured extensions here propagates to every consumer of the matcher — symbol index + traversal (``project.is_ignored_path``), the LS ignore check (``ls.is_ignored_path``), and + language composition detection. Without this, ``find_symbol`` would not surface symbols in + files whose extensions were added to ``file_filter`` (#1449). + """ + Language.PERL.get_source_fn_matcher().add_extensions(*file_filter) + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): # Setup runtime dependencies before initializing perl_ls_cmd = self._setup_runtime_dependencies() @@ -109,6 +145,10 @@ class PerlLanguageServer(SolidLanguageServer): config, repository_root_path, ProcessLaunchInfo(cmd=perl_ls_cmd, cwd=repository_root_path), "perl", solidlsp_settings ) self.request_id = 0 + self._file_filter, self._ignore_dirs = self._resolve_filter_settings(solidlsp_settings) + # Sync Serena's source-file matcher with the configured extensions so find_symbol and the + # language server agree on which files are Perl sources (see #1449). + self._sync_source_fn_matcher(self._file_filter) def _create_base_initialize_params(self) -> dict: """ @@ -153,8 +193,8 @@ class PerlLanguageServer(SolidLanguageServer): perl_config = { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } return [perl_config] @@ -189,8 +229,8 @@ class PerlLanguageServer(SolidLanguageServer): "settings": { "perl": { "perlInc": [self.repository_root_path, "."], - "fileFilter": self._FILE_FILTER, - "ignoreDirs": [".git", ".svn", "blib", "local", ".carton", "vendor", "_build", "cover_db"], + "fileFilter": self._file_filter, + "ignoreDirs": self._ignore_dirs, } } } diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 57e44489..f296fee6 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -699,6 +699,11 @@ class SolidLanguageServer(ABC): """ identifies the language server (not to be confused with the language id passed to the language server) """ + # The source filename matcher is a @cache'd per-language singleton. A previous project may + # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation + # starts from the language's default extensions, then language-server subclasses re-apply + # their own settings during the rest of __init__. + self.language.get_source_fn_matcher().reset() self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} self._published_diagnostics_generation_by_uri: dict[str, int] = {} self._published_diagnostics_generation = 0 diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index c1e02d7e..8d27c6e3 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -26,6 +26,38 @@ class FilenameMatcher: """ self._file_extensions = list(set(file_extensions)) if case_sensitive else list(set(ext.lower() for ext in file_extensions)) self._case_sensitive = case_sensitive + # Snapshot of the initial configuration, used by ``reset``. Relevant for matchers that are + # per-language singletons (``Language.get_source_fn_matcher`` is ``@cache``d): extensions added + # via ``add_extensions`` for one project must not leak into the next, so the singleton is reset + # to this snapshot at every language server initialisation. + self._initial_file_extensions = list(self._file_extensions) + + def reset(self) -> None: + """ + Restore the matcher to its initial set of extensions (as provided at construction). + + Undoes any extensions added via :meth:`add_extensions`. Intended for the per-language + singleton matchers, which are reset at the start of every language server initialisation so + that a previous project's reconfiguration does not leak into a newly activated one. + """ + self._file_extensions = list(self._initial_file_extensions) + + def add_extensions(self, *file_extensions: str) -> None: + """ + Add further file extensions to this matcher (idempotent). + + This is intended for matchers that are per-language singletons, i.e. those returned by + :meth:`Language.get_source_fn_matcher` (which is ``@cache``d): extensions that a user + configures for a language server (e.g. ``.cgi`` for Perl) can be added here so that every + consumer of the matcher — symbol index traversal, ignore checks, language composition — + treats the same set of files as sources, staying in sync with the language server. + + :param file_extensions: the additional file extensions, e.g. ``.cgi`` + """ + for ext in file_extensions: + norm = ext if self._case_sensitive else ext.lower() + if norm not in self._file_extensions: + self._file_extensions.append(norm) def is_relevant_filename(self, fn: str) -> bool: if not self._case_sensitive: @@ -311,6 +343,7 @@ class Language(str, Enum): """ return self.get_ls_class().supports_implementation_request() + # NOTE: Caching results in a singleton per enum item, which is a precondition for persistent configuration of the matcher. @cache def get_source_fn_matcher(self) -> FilenameMatcher: match self: diff --git a/test/solidlsp/perl/__init__.py b/test/solidlsp/perl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/perl/test_perl_config.py b/test/solidlsp/perl/test_perl_config.py new file mode 100644 index 00000000..3b954d9d --- /dev/null +++ b/test/solidlsp/perl/test_perl_config.py @@ -0,0 +1,220 @@ +"""Configuration plumbing for ``PerlLanguageServer``'s fileFilter / ignoreDirs. + +``Perl::LanguageServer`` only indexes files whose extension is in ``perl.fileFilter`` and skips +directories listed in ``perl.ignoreDirs``; both are pushed to the LS at startup (see +``_start_server``). The defaults must stay backward-compatible, but projects with non-standard +layouts — e.g. ``.cgi`` / ``.psgi`` Perl web handlers that can dominate a mature codebase — need +a way to extend visibility (#1449). + +These tests pin the configuration plumbing without starting the language server (which would +require a Perl runtime), so they run in every environment. They also cover the source-file matcher +sync that keeps ``find_symbol`` consistent with the LS (#1449). +""" + +from pathlib import Path + +from solidlsp.language_servers.perl_language_server import ( + _DEFAULT_FILE_FILTER, + _DEFAULT_IGNORE_DIRS, + PerlLanguageServer, +) +from solidlsp.ls_config import FilenameMatcher, Language +from solidlsp.settings import SolidLSPSettings + + +def _settings(tmp_path: Path, ls_specific_settings: dict | None = None) -> SolidLSPSettings: + """A SolidLSPSettings rooted under ``tmp_path`` with optional perl overrides.""" + return SolidLSPSettings( + solidlsp_dir=str(tmp_path / ".solidlsp"), + ls_specific_settings=ls_specific_settings or {}, + ) + + +class TestResolveFilterSettings: + def test_defaults_when_no_ls_specific_settings(self, tmp_path: Path) -> None: + # Behaviour unchanged for projects that don't set ls_specific_settings at all. + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_defaults_when_perl_key_absent(self, tmp_path: Path) -> None: + # ls_specific_settings configured for another language must not leak into Perl. + settings = _settings(tmp_path, {Language.PYTHON: {"something": "unrelated"}}) + + file_filter, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == _DEFAULT_FILE_FILTER + assert ignore_dirs == _DEFAULT_IGNORE_DIRS + + def test_custom_file_filter_makes_extra_extensions_visible(self, tmp_path: Path) -> None: + # #1449: a Perl web backend must be able to surface .cgi / .psgi handlers. + custom = [".pm", ".pl", ".t", ".cgi", ".psgi"] + settings = _settings(tmp_path, {Language.PERL: {"file_filter": custom}}) + + file_filter, _ = PerlLanguageServer._resolve_filter_settings(settings) + + assert file_filter == custom + assert ".cgi" in file_filter + + def test_custom_ignore_dirs(self, tmp_path: Path) -> None: + custom = [".git", "blib", "local", "cover_db", "t"] + settings = _settings(tmp_path, {Language.PERL: {"ignore_dirs": custom}}) + + _, ignore_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert ignore_dirs == custom + + def test_both_overrides_apply_independently(self, tmp_path: Path) -> None: + file_filter = [".pm", ".pl", ".cgi"] + ignore_dirs = [".git", "vendor"] + settings = _settings( + tmp_path, + {Language.PERL: {"file_filter": file_filter, "ignore_dirs": ignore_dirs}}, + ) + + resolved_filter, resolved_dirs = PerlLanguageServer._resolve_filter_settings(settings) + + assert resolved_filter == file_filter + assert resolved_dirs == ignore_dirs + + def test_default_lists_are_not_mutated_across_instances(self, tmp_path: Path) -> None: + # The resolver must copy the module-level defaults, otherwise one instance mutating its + # returned list (or the LS handlers appending to self._file_filter) would corrupt every + # subsequently created default-configured instance. + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + file_filter.append(".cgi") + + next_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + + assert next_filter == _DEFAULT_FILE_FILTER + assert ".cgi" not in _DEFAULT_FILE_FILTER + + +class TestFilenameMatcherAddExtensions: + def test_adds_new_extension(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi") + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + + def test_is_idempotent(self) -> None: + # Re-adding an existing extension must not duplicate it; calling sync twice (e.g. across + # two PerlLanguageServer instances) must be a no-op. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".pm", ".pl") + matcher.add_extensions(".cgi") + matcher.add_extensions(".cgi") + + assert len(matcher._file_extensions) == baseline + 1 + assert matcher.is_relevant_filename("foo.cgi") + + def test_respects_case_insensitivity(self) -> None: + matcher = FilenameMatcher(".pm", case_sensitive=False) + + matcher.add_extensions(".CGI") + + # case-insensitive matcher normalises to lower case + assert matcher.is_relevant_filename("Handler.Cgi") + assert ".cgi" in matcher._file_extensions + assert ".CGI" not in matcher._file_extensions + + +class TestFilenameMatcherReset: + def test_reset_undoes_added_extensions(self) -> None: + matcher = FilenameMatcher(".pm", ".pl") + + matcher.add_extensions(".cgi", ".psgi") + assert matcher.is_relevant_filename("h.cgi") + + matcher.reset() + + assert not matcher.is_relevant_filename("h.cgi") + assert not matcher.is_relevant_filename("h.psgi") + assert matcher.is_relevant_filename("h.pm") + + def test_reset_restores_original_after_case_insensitive_add(self) -> None: + matcher = FilenameMatcher(".PM", case_sensitive=False) + + matcher.add_extensions(".cgi") + matcher.reset() + + # original extension survives the round-trip (normalised to lower case at construction) + assert matcher._file_extensions == [".pm"] + assert not matcher.is_relevant_filename("h.cgi") + + def test_reset_is_idempotent(self) -> None: + # SolidLanguageServer.__init__ calls reset on every activation; repeated resets must be safe + # and must not shrink below the original configuration. + matcher = FilenameMatcher(".pm", ".pl") + baseline = len(matcher._file_extensions) + + matcher.add_extensions(".cgi") + matcher.reset() + matcher.reset() + + assert len(matcher._file_extensions) == baseline + + +class TestSourceFnMatcherSync: + def test_custom_file_filter_extends_perl_matcher(self, tmp_path: Path) -> None: + # #1449: find_symbol relies on Language.PERL.get_source_fn_matcher(); unless the configured + # extensions are synced into it, symbols in .cgi/.psgi files stay invisible even though the + # LS indexes them. get_source_fn_matcher() is a @cache singleton, so reset() afterwards. + matcher = Language.PERL.get_source_fn_matcher() + try: + assert not matcher.is_relevant_filename("handler.cgi") # guard: not matched by default + + file_filter, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi", ".psgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert matcher.is_relevant_filename("lib/Foo.pm") + assert matcher.is_relevant_filename("web/handler.cgi") + assert matcher.is_relevant_filename("app.psgi") + finally: + matcher.reset() + + def test_default_file_filter_leaves_matcher_unchanged(self, tmp_path: Path) -> None: + # The default file_filter matches the existing Perl matcher extensions, so syncing it must + # be a no-op (no duplicate entries, no new matches). + matcher = Language.PERL.get_source_fn_matcher() + try: + initial = list(matcher._file_extensions) + file_filter, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path)) + PerlLanguageServer._sync_source_fn_matcher(file_filter) + + assert sorted(matcher._file_extensions) == sorted(initial) + finally: + matcher.reset() + + def test_reset_prevents_cross_project_leak(self, tmp_path: Path) -> None: + # The matcher is a per-language singleton shared across projects. Project A reconfigures + # file_filter (adds .cgi); when project B is activated, SolidLanguageServer.__init__ resets + # the matcher first, so project B must NOT see .cgi even though project A added it. + # Mirrors the reset-then-sync ordering of PerlLanguageServer.__init__. + matcher = Language.PERL.get_source_fn_matcher() + try: + # project A: custom file_filter with .cgi + filter_a, _ = PerlLanguageServer._resolve_filter_settings( + _settings(tmp_path, {Language.PERL: {"file_filter": [".pm", ".pl", ".t", ".cgi"]}}) + ) + PerlLanguageServer._sync_source_fn_matcher(filter_a) + assert matcher.is_relevant_filename("handler.cgi") + + # project B activates: base __init__ resets, then default config is applied (no .cgi) + matcher.reset() + filter_b, _ = PerlLanguageServer._resolve_filter_settings(_settings(tmp_path / "proj_b")) + PerlLanguageServer._sync_source_fn_matcher(filter_b) + + assert not matcher.is_relevant_filename("handler.cgi"), ( + "project B inherited project A's .cgi — reset did not undo the reconfiguration" + ) + assert matcher.is_relevant_filename("lib/Foo.pm") + finally: + matcher.reset() From 45d4aaa20dfb96b48760491df283f57e486f9312 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 12 Jul 2026 16:58:02 +0200 Subject: [PATCH 39/57] Use get_registered_project instead of Project.load with exception handling --- src/serena/cli.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/serena/cli.py b/src/serena/cli.py index 453546af..77d84180 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -1124,17 +1124,11 @@ class MemoryCommands(AutoRegisteringGroup): exist at the configured location). Never auto-creates the ``.serena`` directory — if no project configuration is found, the user is directed at ``serena project create``. """ - from serena.project import Project - serena_config = SerenaConfig.from_config_file() - project_path = os.path.abspath(project) - try: - proj = Project.load(project_path, serena_config=serena_config, autogenerate=False) - except FileNotFoundError as e: - raise click.UsageError( - f"{e}\nNo Serena project found at {project_path}. Create one first with:\n serena project create {project_path}" - ) from e - return proj.memory_manager + registered_project = serena_config.get_registered_project(project) + if registered_project is None: + raise click.UsageError(f"No Serena project found for '{project}'. Create one first.") + return registered_project.get_project_instance(serena_config).memory_manager @staticmethod @click.command( From 9825316c234e0e31b1ad3bb46ebc4a42dece905a Mon Sep 17 00:00:00 2001 From: weiconghe <46336277+weiconghe@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:54:23 +0800 Subject: [PATCH 40/57] feat: add QML language support via Qt's qmlls (#1635) --- .github/workflows/pytest.yml | 29 +++- CHANGELOG.md | 2 + docs/01-about/020_programming-languages.md | 2 + pyproject.toml | 1 + .../language_servers/qml_language_server.py | 128 ++++++++++++++++++ src/solidlsp/ls_config.py | 11 ++ test/conftest.py | 6 + .../qml/test_repo/src/CustomComponent.qml | 14 ++ .../repos/qml/test_repo/src/UserComponent.qml | 23 ++++ .../qml/test_repo/src/diagnostics_sample.qml | 5 + .../repos/qml/test_repo/src/main.qml | 20 +++ .../repos/qml/test_repo/src/shapes.qml | 14 ++ test/solidlsp/qml/__init__.py | 0 test/solidlsp/qml/test_qml_basic.py | 125 +++++++++++++++++ 14 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 src/solidlsp/language_servers/qml_language_server.py create mode 100644 test/resources/repos/qml/test_repo/src/CustomComponent.qml create mode 100644 test/resources/repos/qml/test_repo/src/UserComponent.qml create mode 100644 test/resources/repos/qml/test_repo/src/diagnostics_sample.qml create mode 100644 test/resources/repos/qml/test_repo/src/main.qml create mode 100644 test/resources/repos/qml/test_repo/src/shapes.qml create mode 100644 test/solidlsp/qml/__init__.py create mode 100644 test/solidlsp/qml/test_qml_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 18fd67ac..60beb1ff 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -64,9 +64,10 @@ jobs: env: MARKERS_JVM: "java or csharp or fsharp or kotlin or scala or groovy or clojure or bsl" MARKERS_NATIVE: "cpp or rust or go or zig or pascal or swift" # swift is macOS-only (skips elsewhere via the guard) - # other-langs: grab-bag + scripting languages + haskell. Haskell (GHC/HLS) is installed only on - # Linux -- it's too slow/expensive to build on the macOS runner -- and skips on Windows+macOS via the guard. - MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al" + # other-langs: grab-bag + scripting languages + haskell + qml. Haskell (GHC/HLS) and qml (qmlls) are + # installed only on Linux -- too slow/expensive to build on the macOS runner (Haskell), or not worth + # the cost of a cross-OS Qt pull (qmlls) -- and skip on Windows+macOS via the central conftest guard. + MARKERS_OTHER_LANGS: "ruby or php or lua or luau or bash or powershell or elixir or erlang or dart or haxe or haskell or terraform or rego or ansible or yaml or toml or markdown or latex or crystal or cue or fortran or ada or matlab or systemverilog or hlsl or msl or al or qml" # niche: slow, mostly-cached toolchains. ocaml + haskell are the two slowest -- kept in separate # batches (ocaml here, haskell in other-langs). nix + perl skip on Windows via the guard. MARKERS_NICHE: "julia or r or perl or lean4 or nix or ocaml" @@ -418,6 +419,28 @@ jobs: mv regal.exe "$HOME/bin/" echo "$HOME/bin" >> $GITHUB_PATH fi + - name: Install qmlls (QML Language Server) + # qmlls needs Qt 6.6+ for textDocument/documentSymbol (the feature these tests assert); Ubuntu + # noble's apt only has Qt 6.4.2, whose qmlls returns -32601 "method not found" for it. So install + # the official standalone build (TheQtCompanyRnD/qmlls-workflow, linked from the Qt docs) -- an 8 MB + # single binary built from recent Qt dev. Restricted to Linux (a cross-OS Qt pull isn't worth it); + # QML skips on Windows/macOS via the conftest guard. libodbc2 is its only non-bundled runtime dep. + if: matrix.batch == 'other-langs' && runner.os == 'Linux' + shell: bash + run: | + QMLLS_VERSION="0.7" + QMLLS_DIR="$HOME/.qmlls" + mkdir -p "$QMLLS_DIR" + curl -fsSL -o /tmp/qmlls.zip "https://github.com/TheQtCompanyRnD/qmlls-workflow/releases/download/${QMLLS_VERSION}/qmllanguageserver-linux-x64-${QMLLS_VERSION}.zip" + unzip -o /tmp/qmlls.zip -d "$QMLLS_DIR" >/dev/null + rm /tmp/qmlls.zip + chmod +x "$QMLLS_DIR/qmlls" + # libodbc.so.2 is qmlls's only non-bundled runtime dep; install only if absent on the image. + ldconfig -p | grep -q 'libodbc.so.2' || { sudo apt-get update && sudo apt-get install -y libodbc2; } + # Fail loudly if any shared lib is still unresolved (e.g. a missing runtime dep). + missing="$(ldd "$QMLLS_DIR/qmlls" | grep 'not found' || true)" + [ -z "$missing" ] || { echo "ERROR: qmlls has unresolved runtime libs:"; echo "$missing"; exit 1; } + echo "$QMLLS_DIR" >> "$GITHUB_PATH" - name: Cache Free Pascal Compiler if: matrix.batch == 'native' id: cache-fpc diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f7044b..0e22ce18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,8 @@ Status of the `main` branch. Changes prior to the next official version change w build output is already excluded via `.gitignore`. #1645 - Improve quoting of arguments in shell executions - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). + - Add **QML** support via Qt's [`qmlls`](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html) language + server (requires Qt 6 with `qmlls`/`qmlls6` on PATH). #1381 - PHP: add support for PHPantom as alternative to the already supported PHP LS #1554. - Add new launch command customization options: `ls_args`, `ls_extra_args` and `ls_base_cmd` - Add new configuration option `ls_workspace_folders` to allow indexed source folders to be specified diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index d827971f..3cbacea8 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -130,6 +130,8 @@ Some languages require additional installations or setup steps, as noted. [pyrefly](https://github.com/facebook/pyrefly) (language `python_pyrefly`), [Jedi](https://github.com/palotas/jedi-language-server) (language `python_jedi`); ty and pyrefly require `uv`/`uvx` in PATH) +* **QML** + (requires Qt 6, provides `qmlls` or `qmlls6` on PATH; see the [Qt qmlls documentation](https://doc.qt.io/qt-6/qtqml-tool-qmlls.html)) * **R** (requires installation of the `languageserver` R package) * **Ruby** diff --git a/pyproject.toml b/pyproject.toml index 7d230e03..4b1020f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -378,6 +378,7 @@ markers = [ "scss: language server running for SCSS / Sass / CSS (some-sass-language-server)", "angular: language server running for Angular (uses @angular/language-server + @angular/language-service tsserver plugin)", "ada: language server running for Ada / SPARK (uses AdaCore Ada Language Server)", + "qml: language server running for QML (uses Qt's qmlls)", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/qml_language_server.py b/src/solidlsp/language_servers/qml_language_server.py new file mode 100644 index 00000000..e1ac3031 --- /dev/null +++ b/src/solidlsp/language_servers/qml_language_server.py @@ -0,0 +1,128 @@ +""" +Provides QML specific instantiation of the LanguageServer class using Qt's qmlls. +""" + +import logging +import shutil + +from solidlsp.ls import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderSinglePath, + SolidLanguageServer, +) +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class QmlLanguageServer(SolidLanguageServer): + """ + Provides QML specific instantiation of the LanguageServer class using qmlls. + + qmlls is the official QML language server shipped with Qt 6. + It must be installed separately; see https://doc.qt.io/qt-6/qtqml-tool-qmlls.html + for installation instructions. + + The dependency provider looks for ``qmlls6`` first, then falls back to ``qmlls``. + Users can override the executable via the ``ls_path`` entry in ``ls_specific_settings.qml``. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + super().__init__(config, repository_root_path, None, "qml", solidlsp_settings) + + def _create_dependency_provider(self) -> LanguageServerDependencyProvider: + return self.DependencyProvider(self._custom_settings, self._ls_resources_dir) + + class DependencyProvider(LanguageServerDependencyProviderSinglePath): + def _get_or_install_core_dependency(self) -> str: + """ + Discover the qmlls executable on PATH. + + Tries ``qmlls6`` first (Qt 6+), then falls back to ``qmlls``. + + :return: path to the qmlls executable + :raises FileNotFoundError: if qmlls is neither on PATH nor provided via ``ls_path`` + """ + qmlls_binary = shutil.which("qmlls6") or shutil.which("qmlls") + if qmlls_binary is None: + raise FileNotFoundError( + "qmlls (QML language server) is not installed or not in PATH.\n" + "Please install Qt 6 and ensure 'qmlls' (or 'qmlls6') is available on your PATH.\n" + "See: https://doc.qt.io/qt-6/qtqml-tool-qmlls.html" + ) + return qmlls_binary + + def _create_launch_command(self, core_path: str) -> list[str]: + # qmlls communicates via stdio by default; no extra flags are required. + return [core_path] + + def _create_base_initialize_params(self) -> dict: + """ + Return the language-specific initialize params for the QML language server. + + ``processId``, ``rootPath``, ``rootUri`` and ``workspaceFolders`` are populated by the + default ``InitializeParamsBuilder`` and must not be set here. + """ + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "completion": { + "dynamicRegistration": True, + "completionItem": { + "snippetSupport": True, + "documentationFormat": ["markdown", "plaintext"], + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "configuration": True, + }, + }, + } + return initialize_params + + def _start_server(self) -> None: + """Start the QML language server process.""" + + def register_capability_handler(_params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(_params: dict) -> None: + return + + 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 QML language server (qmlls) process") + self.server.start() + initialize_params = self._create_initialize_params() + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + # verify server capabilities + capabilities = init_response["capabilities"] + log.info(f"QML language server capabilities: {list(capabilities.keys())}") + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8d27c6e3..2aa87fdc 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -173,6 +173,11 @@ class Language(str, Enum): The editor must already be running with its built-in LSP enabled (default). Supports .gd and .gdscript files. """ + QML = "qml" + """QML language server using Qt's qmlls (or qmlls6). + Supports .qml files. Requires Qt 6 installation providing qmlls on PATH. + See https://doc.qt.io/qt-6/qtqml-tool-qmlls.html + """ # 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""" @@ -557,6 +562,8 @@ class Language(str, Enum): return FilenameMatcher(".ads", ".adb", ".ada", case_sensitive=False) case self.GDSCRIPT: return FilenameMatcher(".gd", ".gdscript") + case self.QML: + return FilenameMatcher(".qml") case self.HTML: return FilenameMatcher(".html", ".htm") case self.SCSS: @@ -836,6 +843,10 @@ class Language(str, Enum): from solidlsp.language_servers.godot_language_server import GodotLanguageServer return GodotLanguageServer + case self.QML: + from solidlsp.language_servers.qml_language_server import QmlLanguageServer + + return QmlLanguageServer case self.HTML: from solidlsp.language_servers.vscode_html_language_server import VsCodeHtmlLanguageServer diff --git a/test/conftest.py b/test/conftest.py index fc046273..6b25dec3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -254,6 +254,7 @@ Flag indicating whether the tests are running in the GitHub CI environment. is_windows = platform.system() == "Windows" is_macos = platform.system() == "Darwin" +is_linux = platform.system() == "Linux" _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { @@ -407,6 +408,11 @@ def _determine_disabled_languages() -> list[Language]: result.append(Language.REGO) if _sh.which("elm") is None and not is_ci: result.append(Language.ELM) + # qmlls is installed (standalone build; see pytest.yml) only on the Ubuntu other-langs CI batch. It is + # expected there, so a missing binary on Linux CI is NOT skipped here -- the test runs and fails loudly, + # catching a CI setup regression. On Windows/macOS CI (never installed) and off-CI without the binary it skips. + if (_sh.which("qmlls6") is None and _sh.which("qmlls") is None) and not (is_ci and is_linux): + result.append(Language.QML) # === 3. Disabled wherever the precondition is missing (including on CI) === # 3a. Platform precondition: these language servers have no native Windows support. diff --git a/test/resources/repos/qml/test_repo/src/CustomComponent.qml b/test/resources/repos/qml/test_repo/src/CustomComponent.qml new file mode 100644 index 00000000..e44ce814 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/CustomComponent.qml @@ -0,0 +1,14 @@ +import QtQuick 2.15 + +Rectangle { + id: root + width: 200 + height: root.width + color: "green" + + Text { + id: label + text: root.width + "x" + root.height + anchors.centerIn: parent + } +} diff --git a/test/resources/repos/qml/test_repo/src/UserComponent.qml b/test/resources/repos/qml/test_repo/src/UserComponent.qml new file mode 100644 index 00000000..56b7a7b9 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/UserComponent.qml @@ -0,0 +1,23 @@ +import QtQuick 2.15 + +Item { + width: 400 + height: 300 + + CustomComponent { + x: 50 + y: 50 + } + + Rectangle { + y: 180 + width: 200 + height: 100 + color: "yellow" + + Text { + text: "Another shape" + anchors.centerIn: parent + } + } +} diff --git a/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml b/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml new file mode 100644 index 00000000..38bd34e5 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/diagnostics_sample.qml @@ -0,0 +1,5 @@ +import QtQuick 2.15 + +Item { + width: 100 + height: 100 diff --git a/test/resources/repos/qml/test_repo/src/main.qml b/test/resources/repos/qml/test_repo/src/main.qml new file mode 100644 index 00000000..3dcfc214 --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/main.qml @@ -0,0 +1,20 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +ApplicationWindow { + id: mainWindow + title: "Test Application" + width: 800 + height: 600 + + Button { + id: mainButton + text: "Click Me" + width: 100 + height: 50 + + onClicked: { + console.log("Button clicked") + } + } +} diff --git a/test/resources/repos/qml/test_repo/src/shapes.qml b/test/resources/repos/qml/test_repo/src/shapes.qml new file mode 100644 index 00000000..13a4fabb --- /dev/null +++ b/test/resources/repos/qml/test_repo/src/shapes.qml @@ -0,0 +1,14 @@ +import QtQuick 2.15 + +Rectangle { + id: customRect + width: 200 + height: 100 + color: "lightblue" + + Text { + id: label + text: "Hello QML" + anchors.centerIn: parent + } +} diff --git a/test/solidlsp/qml/__init__.py b/test/solidlsp/qml/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/solidlsp/qml/test_qml_basic.py b/test/solidlsp/qml/test_qml_basic.py new file mode 100644 index 00000000..97b658a7 --- /dev/null +++ b/test/solidlsp/qml/test_qml_basic.py @@ -0,0 +1,125 @@ +""" +Basic integration tests for the QML language server (qmlls) functionality. + +These tests validate document symbols, reference search, and diagnostics +using the QML test repository. + +Requires ``qmlls6`` or ``qmlls`` on PATH (shipped with Qt 6). +""" + +import os +from pathlib import Path + +import pytest + +from serena.util.text_utils import find_text_coordinates +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from test.conftest import language_tests_enabled +from test.solidlsp.conftest import read_repo_file +from test.solidlsp.util.diagnostics import assert_file_diagnostics + +pytestmark = [ + pytest.mark.qml, + pytest.mark.skipif(not language_tests_enabled(Language.QML), reason="QML tests are disabled (qmlls/qmlls6 not available)"), +] + + +class TestQmlLanguageServer: + """Test QML language server startup and basic features.""" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_ls_is_running(self, language_server: SolidLanguageServer) -> None: + """Test that the language server starts successfully.""" + assert language_server.is_running() + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_document_symbols_main(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the main file. + + qmlls names QML objects by their type (ApplicationWindow, Button); properties such as + ``id`` and ``width`` appear as child symbols, so we assert on the component type names. + """ + file_path = os.path.join("src", "main.qml") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols if s.get("name")] + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "ApplicationWindow" in root_names, f"ApplicationWindow root missing. Roots: {root_names}" + assert "Button" in symbol_names, f"Button component missing. Symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_document_symbols_shapes(self, language_server: SolidLanguageServer) -> None: + """Test that document symbols are returned for the shapes file.""" + file_path = os.path.join("src", "shapes.qml") + doc_symbols = language_server.request_document_symbols(file_path) + all_symbols, root_symbols = doc_symbols.get_all_symbols_and_roots() + + root_names = [s.get("name") for s in root_symbols if s.get("name")] + symbol_names = [s.get("name") for s in all_symbols if s.get("name")] + assert "Rectangle" in root_names, f"Rectangle root missing. Roots: {root_names}" + assert "Text" in symbol_names, f"Text component missing. Symbols: {symbol_names}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.QML], indirect=True) + def test_find_references_within_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that references are found within the same file. + + CustomComponent.qml references ``root.width`` on multiple lines (declaration via + ``id: root`` on line 3 and usages on lines 5 and 10). We click on a usage of + ``root`` and verify that the other usages are found. + """ + file_path = str(repo_path / "src" / "CustomComponent.qml") + content = read_repo_file(language_server, os.path.join("src", "CustomComponent.qml")) + + # Probe at the `root` identifier in `root.width` on line 10 (0-indexed) — + # the occurrence inside the Text block. `find_text_coordinates` uses a + # capturing group around the target text so `coords` lands on `root`. + coords = find_text_coordinates(content, r"(root)\.width") + assert coords is not None, "Could not locate root.width in CustomComponent.qml" + + references = language_server.request_references(file_path, coords.line, coords.col + 1) + assert references, f"Expected non-empty references for root, got {references=}" + + # All references should be within CustomComponent.qml (no cross-file + # references to ``root``, which is scoped to this component). + ref_files = {loc["uri"].split("/")[-1] for loc in references} + assert "CustomComponent.qml" in ref_files, f"All references should be in CustomComponent.qml, got {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + @pytest.mark.parametrize("repo_path", [Language.QML], indirect=True) + def test_find_references_cross_file(self, language_server: SolidLanguageServer, repo_path: Path) -> None: + """Test that references are found across files. + + CustomComponent.qml defines a QML component type that is instantiated in + UserComponent.qml. Clicking on the ``CustomComponent`` type usage in + UserComponent.qml should return at least one reference, ideally spanning + both files. + """ + file_path = str(repo_path / "src" / "UserComponent.qml") + content = read_repo_file(language_server, os.path.join("src", "UserComponent.qml")) + + # Locate the `CustomComponent` type usage in UserComponent.qml. + coords = find_text_coordinates(content, r"(CustomComponent)") + assert coords is not None, "Could not locate CustomComponent type in UserComponent.qml" + + references = language_server.request_references(file_path, coords.line, coords.col + 1) + assert references, f"Expected non-empty references for CustomComponent, got {references=}" + + ref_files = {loc["uri"].split("/")[-1] for loc in references} + assert "UserComponent.qml" in ref_files, f"Expected at least one reference in UserComponent.qml, got {ref_files}" + + @pytest.mark.parametrize("language_server", [Language.QML], indirect=True) + def test_file_diagnostics(self, language_server: SolidLanguageServer) -> None: + """Test that diagnostics are reported for a QML file with errors. + + diagnostics_sample.qml contains an unknown property (``colorr`` instead of + ``color``) which qmlls should flag as an error or warning. + """ + assert_file_diagnostics( + language_server, + os.path.join("src", "diagnostics_sample.qml"), + (), + min_count=1, + ) From a3de03b5564d3b10aeb55aa3b50b6ae9f45b814d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 13 Jul 2026 22:30:55 +0200 Subject: [PATCH 41/57] Fix: jet_brains_run_inspections returned incorrectly transformed data The tool incorrectly applied the pythonification, even though it returns data not only in values but also in keys --- CHANGELOG.md | 1 + .../jetbrains/jetbrains_plugin_client.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e22ce18..a7fd62a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically upon project activation - Fix: `jet_brains_list_inspections` failed when only default parameters were used #1615 + - Fix: `jet_brains_run_inspections` returned incorrectly transformed data (malforming snake-case conversion applied to all keys) * Dashboard: - Make list of trusted hosts configurable, fixing host validation introduced in v1.5.2 allowing only diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 22fa9c27..090adbdf 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -303,7 +303,15 @@ class JetBrainsPluginClient(ToStringMixin): f"{self._plugin_version}. Ask the user to update the plugin!" ) - def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None) -> dict[str, Any]: + def _make_request(self, method: str, endpoint: str, data: Optional[dict] = None, pythonify: bool = True) -> dict[str, Any]: + """ + :param method: the HTTP method to use ("GET" or "POST") + :param endpoint: the endpoint to call (e.g., "/findSymbol") + :param data: the data to send in the request body (for POST requests) + :param pythonify: whether to recursively "pythonify" the response object, converting all keys to snake_case. + This must not be enabled if *any* key contains variable data rather (and therefore isn't a well-defined DTO structure). + :return: the response as a dictionary + """ url = f"{self._base_url}{endpoint}" response: Response | None = None @@ -320,7 +328,11 @@ class JetBrainsPluginClient(ToStringMixin): # Try to parse JSON response try: - return self._pythonify_response(response.json()) + response_json = response.json() + if pythonify: + return self._pythonify_response(response_json) + else: + return response_json except json.JSONDecodeError: # If response is not JSON, return raw text return {"response": response.text} @@ -695,7 +707,7 @@ class JetBrainsPluginClient(ToStringMixin): request_data["startLine"] = start_line if end_line is not None: request_data["endLine"] = end_line - return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data)) + return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False)) def list_inspections( self, From 98966c6a598bb96a63a6defda310b4d8c3b92616 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 13 Jul 2026 22:39:03 +0200 Subject: [PATCH 42/57] Remove incorrect DTOs for JetBrains /runInspectionsOnFile call --- src/serena/jetbrains/jetbrains_plugin_client.py | 7 +++++-- src/serena/jetbrains/jetbrains_types.py | 16 ---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index 090adbdf..eec3393c 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -686,7 +686,7 @@ class JetBrainsPluginClient(ToStringMixin): inspection_names: list[str] | None = None, start_line: int | None = None, end_line: int | None = None, - ) -> jb.RunInspectionsResponse: + ) -> dict: """ Runs IDE inspections on the given file and returns the results. @@ -695,6 +695,9 @@ class JetBrainsPluginClient(ToStringMixin): :param inspection_names: optional list of specific inspection names to run :param start_line: optional start line to restrict the inspection range :param end_line: optional end line to restrict the inspection range + :return: the inspection results as a dictionary. + NOTE: The response is currently *not* a well-defined DTO, because it stores variable data in keys. + Consequently, the `pythonify` option is disabled for this request, and the response is returned as-is. """ request_data: dict[str, Any] = { "relativePath": relative_path, @@ -707,7 +710,7 @@ class JetBrainsPluginClient(ToStringMixin): request_data["startLine"] = start_line if end_line is not None: request_data["endLine"] = end_line - return cast(jb.RunInspectionsResponse, self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False)) + return self._make_request("POST", "/runInspectionsOnFile", request_data, pythonify=False) def list_inspections( self, diff --git a/src/serena/jetbrains/jetbrains_types.py b/src/serena/jetbrains/jetbrains_types.py index ce488ea2..65add4fb 100644 --- a/src/serena/jetbrains/jetbrains_types.py +++ b/src/serena/jetbrains/jetbrains_types.py @@ -91,22 +91,6 @@ class TypeHierarchyResponse(TypedDict): num_levels_not_included: NotRequired[int] -class InspectionResultDTO(TypedDict): - inspection_name: str - severity: str - message: str - relative_path: str - start_line: int - end_line: int - start_col: NotRequired[int] - end_col: NotRequired[int] - quick_fix_available: NotRequired[bool] - - -class RunInspectionsResponse(TypedDict): - inspections: list[InspectionResultDTO] - - class InspectionInfoDTO(TypedDict): name: str group_path: str From 30c84f15e6d0946477f9bdd720092d9bd251b882 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 14 Jul 2026 10:38:25 +0200 Subject: [PATCH 43/57] Pyright: bump timeout for waiting for initial analysis from 5s to 60s. --- CHANGELOG.md | 1 + src/solidlsp/language_servers/pyright_server.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7fd62a6..4952c3bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Status of the `main` branch. Changes prior to the next official version change w the repository. #1627 - Rename configuration option `additional_workspace_folders` to `ls_additional_workspace_folders` and support the option across all language servers (previously limited to TypeScript). + - `Pyright`: bump timeout for waiting for initial analysis from 5s to 60s. * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/src/solidlsp/language_servers/pyright_server.py b/src/solidlsp/language_servers/pyright_server.py index b808d8f7..4a8c9fbc 100644 --- a/src/solidlsp/language_servers/pyright_server.py +++ b/src/solidlsp/language_servers/pyright_server.py @@ -24,6 +24,8 @@ class PyrightServer(SolidLanguageServer): Contains various configurations and settings specific to Python. """ + _TIMEOUT_FOR_INITIAL_ANALYSIS = 60.0 + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): """ Creates a PyrightServer instance. This class is not meant to be instantiated directly. @@ -231,8 +233,8 @@ class PyrightServer(SolidLanguageServer): # Wait for Pyright to complete its initial workspace analysis # This prevents zombie processes by ensuring background tasks finish - log.info("Waiting for Pyright to complete initial workspace analysis...") - if self.analysis_complete.wait(timeout=5.0): + log.info(f"Waiting up to {self._TIMEOUT_FOR_INITIAL_ANALYSIS}s for Pyright to complete initial workspace analysis...") + if self.analysis_complete.wait(timeout=self._TIMEOUT_FOR_INITIAL_ANALYSIS): log.info("Pyright initial analysis complete, server ready") else: log.warning("Timeout waiting for Pyright analysis completion, proceeding anyway") From c6029c6fb2ba36ebea2ca923519067ba20c9fcf3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Tue, 14 Jul 2026 10:41:43 +0200 Subject: [PATCH 44/57] Minor fix in docs claude-desktop -> desktop-app --- docs/02-usage/030_clients.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 6241246e..d8e30397 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -397,7 +397,7 @@ Add the `serena` MCP server configuration "command": "serena", "args": [ "start-mcp-server", - "--context=claude-desktop" + "--context=desktop-app" ] } } From 286a624a6aa60a0c5b97ac2ca874845d32731e1a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 11:03:22 +0200 Subject: [PATCH 45/57] Improve dependency provider usage instructions --- .serena/memories/adding_new_language_support_guide.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.serena/memories/adding_new_language_support_guide.md b/.serena/memories/adding_new_language_support_guide.md index 6eeed48e..db470b94 100644 --- a/.serena/memories/adding_new_language_support_guide.md +++ b/.serena/memories/adding_new_language_support_guide.md @@ -49,8 +49,8 @@ To implement a new language server using the DependencyProvider pattern: final launch command - **`LanguageServerDependencyProviderSinglePath`** - Alternative to inheriting from `...BaseCommand` directly for the case - of a single core dependency (e.g., an executable or JAR file); mostly present in existing implementations - for new - implementations, prefer `...BaseCommand` + of a single core dependency (e.g., an executable or JAR file); use when the single path is not used directly as a base command + otherwise inherit from `...BaseCommand` directly - Implement `_get_or_install_core_dependency()` to return the path to the core dependency, downloading/installing it automatically if necessary - Implement `_create_launch_command(core_path)` to build the full command from the core path - Reference implementations: `TypeScriptLanguageServer`, `Intelephense`, `ClojureLSP`, `ClangdLanguageServer` @@ -60,7 +60,6 @@ To implement a new language server using the DependencyProvider pattern: - Reference implementations: `EclipseJDTLS`, `CSharpLanguageServer`, `MatlabLanguageServer` **Implementation Pointers::** - - When returning the command, prefer the list-based representation for robustness - Override `create_launch_command_env` if the launch command needs environment variables to be set (defaults to `{}` in the base implementation) You should look at at least one existing implementation of each base class to understand how they work. From 4189e8acdba9fbf095b15482a14b1918a3d19d3f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 12 Jul 2026 16:58:27 +0200 Subject: [PATCH 46/57] Use asynchronous project language determination to speed up MCP startup When auto-generating a Serena project configuration at startup, compute the languages in a background thread and update them later to avoid delays of the MCP server startup. Where completeness of the language list matters (i.e. language server initialisation), a corresponding event is awaited to ensure completeness. --- CHANGELOG.md | 2 + src/serena/agent.py | 2 +- src/serena/config/serena_config.py | 251 ++++++++++++++++++++--------- src/serena/project.py | 9 +- 4 files changed, 184 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f7044b..3a419eee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Status of the `main` branch. Changes prior to the next official version change will appear here. * General: + - Speed up MCP startup when auto-creating projects at startup (e.g. using `--project-from-cwd`) by + determining the project's languages in a background thread #1683 - Fix: in `glob_to_regex` / `search_text(is_glob=True)`, a `?` wildcard matched two characters instead of one (it emitted `..` rather than `.`); it now matches a single character, consistent with the `?` semantics documented for `glob_match` and the `test_??.py` example in `search_text`'s docstring. diff --git a/src/serena/agent.py b/src/serena/agent.py index 637e8ae1..f21d6b03 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -1300,7 +1300,7 @@ class SerenaAgent: if project_instance is not None: log.info(f"Found registered project '{project_instance.project_name}' at path {project_instance.project_root}") elif os.path.isdir(project_root_or_name): - project_instance = self.serena_config.add_project_from_path(project_root_or_name) + project_instance = self.serena_config.add_project_from_path(project_root_or_name, asynchronous_autogen=True) log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}") if project_instance is None: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 6cb2a721..50d48aa5 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -6,6 +6,7 @@ import dataclasses import os import re import shutil +import threading from collections.abc import Iterator, Sequence from copy import deepcopy from dataclasses import dataclass, field @@ -298,6 +299,25 @@ Uses $projectDir and $projectFolderName as placeholders. """ +class ProjectConfigAutoGenerationMode(Enum): + NONE = "none" + """ + no auto-generation + """ + SYNCHRONOUS = "sync" + """ + synchronous auto-generation, i.e. the configuration is fully generated before returning from the function call + """ + ASYNCHRONOUS = "async" + """ + asynchronous auto-generation, where time-consuming configuration parts (currently only the + list of programming languages) are determined in a background thread and initialised as empty + """ + + def is_autogen_enabled(self): + return self != ProjectConfigAutoGenerationMode.NONE + + @dataclass(kw_only=True) class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): project_name: str @@ -315,7 +335,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # internal fields which are not mapped to/from the configuration file (must start with "_") _local_override_keys: list[str] = field(default_factory=list) - # class-level constants + # class-level members SERENA_PROJECT_FILE = "project.yml" SERENA_LOCAL_PROJECT_FILE = "project.local.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} @@ -325,10 +345,59 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): the comment normalisation strategy to use when loading/saving project configuration files. The template file must match this configuration (i.e. it must use leading comments if this is set to LEADING). """ + _async_completion_events = {} + """ + maps the object id of a ProjectConfig instance to an event which is set when the asynchronous auto-generation of + the configuration is complete (if applicable). + """ + _save_lock = threading.Lock() def _tostring_includes(self) -> list[str]: return ["project_name"] + @classmethod + def _determine_project_languages(cls, project_root: str, interactive: bool) -> list[Language]: + # determine languages automatically + log.info("Determining programming languages used in the project") + language_composition = determine_programming_language_composition(project_root) + log.info("Language composition: %s", language_composition) + if len(language_composition) == 0: + log.warning( + "No source files for supported language servers were found in %s. " + "Creating project with no configured languages. " + "Symbol-related tools (e.g. find_symbol, get_symbols_overview) will not work " + "when using the LSP backend. You can add languages later via the Serena dashboard " + "or by manually editing the project configuration.", + project_root, + ) + languages_to_use = [] + else: + # sort languages by number of files found + languages_and_percentages = sorted( + language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True + ) + # find the language with the highest percentage and enable it + top_language_pair = languages_and_percentages[0] + other_language_pairs = languages_and_percentages[1:] + languages_to_use = [top_language_pair[0]] + # if in interactive mode, ask the user which other languages to enable + if len(other_language_pairs) > 0 and interactive: + print( + "Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0].value, top_language_pair[1]) + ) + print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") + print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") + print(" Additional language servers use resources and some languages may require additional") + print(" system-level installations/configuration (see Serena documentation).") + print("\nWhich additional languages do you want to enable?") + for lang, perc in other_language_pairs: + enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) + if enable: + languages_to_use.append(lang) + print() + log.info("Using languages: %s", languages_to_use) + return languages_to_use + @classmethod def autogenerate( cls, @@ -338,6 +407,7 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): languages: list[Language] | None = None, save_to_disk: bool = True, interactive: bool = False, + asynchronous: bool = False, ) -> Self: """ Autogenerate a project configuration for a given project root. @@ -349,8 +419,12 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): :param languages: the languages of the project; if None, they will be determined automatically :param save_to_disk: whether to save the project configuration to disk :param interactive: whether to run in interactive CLI mode, asking the user for input where appropriate + :param asynchronous: whether to run in asynchronous mode, where time-consuming configuration parts (currently only the + determination of the list of programming languages) are determined in a background thread and initialised as empty :return: the project configuration """ + if interactive and asynchronous: + raise ValueError("Cannot use interactive mode with asynchronous auto-generation") project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") @@ -358,61 +432,62 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): log.info("Project root: %s", project_root) project_folder_name = project_root.name project_name = project_name or project_folder_name + use_asynchronous_language_determination = False if languages is None: - # determine languages automatically - log.info("Determining programming languages used in the project") - language_composition = determine_programming_language_composition(str(project_root)) - log.info("Language composition: %s", language_composition) - if len(language_composition) == 0: - log.warning( - "No source files for supported language servers were found in %s. " - "Creating project with no configured languages. " - "Symbol-related tools (e.g. find_symbol, get_symbols_overview) will not work " - "when using the LSP backend. You can add languages later via the Serena dashboard " - "or by manually editing the project configuration.", - project_root, - ) - languages_to_use: list[str] = [] + if asynchronous: + use_asynchronous_language_determination = True + languages_to_use = [] # temporarily empty, will be determined in background thread else: - # sort languages by number of files found - languages_and_percentages = sorted( - language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True - ) - # find the language with the highest percentage and enable it - top_language_pair = languages_and_percentages[0] - other_language_pairs = languages_and_percentages[1:] - languages_to_use = [top_language_pair[0].value] - # if in interactive mode, ask the user which other languages to enable - if len(other_language_pairs) > 0 and interactive: - print( - "Detected and enabled main language '%s' (%.2f%% of source files)." - % (top_language_pair[0].value, top_language_pair[1]) - ) - print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") - print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") - print(" Additional language servers use resources and some languages may require additional") - print(" system-level installations/configuration (see Serena documentation).") - print("\nWhich additional languages do you want to enable?") - for lang, perc in other_language_pairs: - enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) - if enable: - languages_to_use.append(lang.value) - print() - log.info("Using languages: %s", languages_to_use) + determined_languages = cls._determine_project_languages(str(project_root), interactive=interactive) + languages_to_use = [l.value for l in determined_languages] else: languages_to_use = [lang.value for lang in languages] config_with_comments, _ = cls._load_yaml_dict(PROJECT_TEMPLATE_FILE) config_with_comments["project_name"] = project_name config_with_comments["languages"] = languages_to_use + project_yml_path = serena_config.get_project_yml_location(str(project_root)) if save_to_disk: - project_yml_path = serena_config.get_project_yml_location(str(project_root)) log.info("Saving project configuration to %s", project_yml_path) - save_yaml(project_yml_path, config_with_comments) + with cls._save_lock: + save_yaml(project_yml_path, config_with_comments) project_local_yml_path = os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) shutil.copy(PROJECT_LOCAL_TEMPLATE_FILE, project_local_yml_path) - return cls._from_dict(config_with_comments, local_override_keys=[]) + project_config = cls._from_dict(config_with_comments, local_override_keys=[]) + + # if asynchronous language determination is used, start a background thread which updates and saves the configuration + # and set an event which can be awaited to ensure that the configuration is complete + if use_asynchronous_language_determination: + event = threading.Event() + project_config._async_completion_events[id(project_config)] = event + + def async_language_determination(): + try: + with LogTime("Asynchronous language determination", logger=log): + project_config.languages = cls._determine_project_languages(str(project_root), interactive=False) + if save_to_disk: + project_config.save(project_yml_path) + finally: + event.set() + + threading.Thread(target=async_language_determination, name="project-language-determination", daemon=True).start() + + return project_config + + def await_asynchronous_completion(self): + """ + Wait for the asynchronous auto-generation of the configuration to complete (if applicable), ensuring + that, in particular, the list of programming languages is complete, which may be determined asynchronously + when first creating the project configuration. + """ + event = ProjectConfig._async_completion_events.get(id(self)) + if event is None: + return + if not event.is_set(): + log.info("Waiting for asynchronous auto-generation of project configuration to complete ...") + event.wait() + log.info("Asynchronous auto-generation of project configuration completed.") @classmethod def default_project_yml_path(cls, project_root: str | Path) -> str: @@ -590,13 +665,18 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): return os.path.join(os.path.dirname(project_yml_path), cls.SERENA_LOCAL_PROJECT_FILE) @classmethod - def load(cls, project_root: Path | str, serena_config: "SerenaConfig", autogenerate: bool = False) -> Self: + def load( + cls, + project_root: Path | str, + serena_config: "SerenaConfig", + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.NONE, + ) -> Self: """ Load a ProjectConfig instance from the path to the project root. :param project_root: the path to the project root :param serena_config: the global Serena configuration - :param autogenerate: whether to auto-generate the configuration if it does not exist + :param autogen: the auto-generation mode to apply if the project configuration does not yet exist """ project_root = Path(project_root) project_folder_name = project_root.name @@ -605,8 +685,8 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): # auto-generate if necessary if not os.path.exists(yaml_path): - if autogenerate: - return cls.autogenerate(project_root, serena_config) + if autogen.is_autogen_enabled(): + return cls.autogenerate(project_root, serena_config, asynchronous=autogen == ProjectConfigAutoGenerationMode.ASYNCHRONOUS) else: raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") @@ -653,34 +733,35 @@ class ProjectConfig(SharedConfig, ModeSelectionDefinitionWithAddedModes): config_path = project_yml_path log.info("Saving updated project configuration to %s", config_path) - # get the current configuration as a dictionary - cur_dict = self._to_yaml_dict() + with self._save_lock: + # get the current configuration as a dictionary + cur_dict = self._to_yaml_dict() - # load commented map from the original file and update all non-overridden keys - config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION) - for key in cur_dict: - if key not in self._local_override_keys: - config_with_comments[key] = cur_dict[key] + # load commented map from the original file and update all non-overridden keys + config_with_comments, _ = self._load_yaml_dict(config_path, self.YAML_COMMENT_NORMALISATION) + for key in cur_dict: + if key not in self._local_override_keys: + config_with_comments[key] = cur_dict[key] - # transfer missing comments from the template file - template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION) - transfer_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION, force_update_all=True) + # transfer missing comments from the template file + template_config, _ = self._load_yaml_dict(PROJECT_TEMPLATE_FILE, self.YAML_COMMENT_NORMALISATION) + transfer_yaml_comments(template_config, config_with_comments, self.YAML_COMMENT_NORMALISATION, force_update_all=True) - # save project.yml - save_yaml(config_path, config_with_comments) + # save project.yml + save_yaml(config_path, config_with_comments) - # update project.local.yml to reflect overridden keys if necessary - if save_project_local_yml: - project_local_yml_path = self._project_local_yml_path(project_yml_path) - if self._local_override_keys and os.path.exists(project_local_yml_path): - log.info("Saving updated local project configuration to %s", project_local_yml_path) - local_config_with_comments, _ = self._load_yaml_dict( - project_local_yml_path, comment_normalisation=YamlCommentNormalisation.NONE, apply_defaults=False - ) - for key in self._local_override_keys: - if key in cur_dict: - local_config_with_comments[key] = cur_dict[key] - save_yaml(project_local_yml_path, local_config_with_comments) + # update project.local.yml to reflect overridden keys if necessary + if save_project_local_yml: + project_local_yml_path = self._project_local_yml_path(project_yml_path) + if self._local_override_keys and os.path.exists(project_local_yml_path): + log.info("Saving updated local project configuration to %s", project_local_yml_path) + local_config_with_comments, _ = self._load_yaml_dict( + project_local_yml_path, comment_normalisation=YamlCommentNormalisation.NONE, apply_defaults=False + ) + for key in self._local_override_keys: + if key in cur_dict: + local_config_with_comments[key] = cur_dict[key] + save_yaml(project_local_yml_path, local_config_with_comments) class RegisteredProject(ToStringMixin): @@ -717,8 +798,21 @@ class RegisteredProject(ToStringMixin): ) @classmethod - def from_project_root(cls, project_root: str | Path, serena_config: "SerenaConfig") -> "RegisteredProject": - project_config = ProjectConfig.load(project_root, serena_config=serena_config) + def from_project_root( + cls, + project_root: str | Path, + serena_config: "SerenaConfig", + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.NONE, + ) -> "RegisteredProject": + """ + Creates a RegisteredProject instance from a project root path, which must exist on disk. + + :param project_root: path to an existing directory + :param serena_config: the Serena configuration + :param autogen: the auto-generation mode to use for the project configuration if it does not yet exist + :return: the RegisteredProject instance + """ + project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogen=autogen) return RegisteredProject( project_root=str(project_root), project_config=project_config, @@ -1085,7 +1179,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): def get_registered_project(self, project_root_or_name: str, autoregister: bool = False) -> Optional[RegisteredProject]: """ :param project_root_or_name: path to the project root or the name of the project - :param autoregister: whether to register the project if it exists but is not registered yet + :param autoregister: whether to auto-register projects that are not yet registered in Serena's global configuration + but have an existing project configuration file. Project configuration files are never auto-generated. :return: the registered project, or None if not found """ # look for project by name @@ -1105,7 +1200,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): for project in self.projects: if project.matches_root_path(project_root_or_name): return project - # no registered project found; auto-register if project configuration exists + # no registered project found; optionally auto-register if a project configuration already exists if autoregister: config_path = self.get_project_yml_location(project_root_or_name) if os.path.isfile(config_path): @@ -1129,13 +1224,14 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): self.projects.append(registered_project) self._persist_projects() - def add_project_from_path(self, project_root: Path | str) -> "Project": + def add_project_from_path(self, project_root: Path | str, asynchronous_autogen: bool = False) -> "Project": """ Adds a new project to the Serena configuration from a given path, auto-generating the project with defaults if it does not exist. Will raise a FileExistsError if a project already exists at the path. :param project_root: the path to the project to add + :param asynchronous_autogen: whether to use asynchronous auto-generation for the project configuration :return: the project that was added """ from ..project import Project @@ -1152,7 +1248,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." ) - project_config = ProjectConfig.load(project_root, serena_config=self, autogenerate=True) + autogen = ProjectConfigAutoGenerationMode.ASYNCHRONOUS if asynchronous_autogen else ProjectConfigAutoGenerationMode.SYNCHRONOUS + project_config = ProjectConfig.load(project_root, serena_config=self, autogen=autogen) new_project = Project( project_root=str(project_root), diff --git a/src/serena/project.py b/src/serena/project.py index 87a54adb..51b27ce3 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -11,6 +11,7 @@ from sensai.util.string import TextBuilder, ToStringMixin from serena.config.serena_config import ( LanguageBackend, ProjectConfig, + ProjectConfigAutoGenerationMode, SerenaConfig, ) from serena.ls_manager import LanguageServerFactory, LanguageServerManager @@ -137,13 +138,13 @@ class Project(ToStringMixin): cls, project_root: str | Path, serena_config: "SerenaConfig", - autogenerate: bool = True, + autogen: ProjectConfigAutoGenerationMode = ProjectConfigAutoGenerationMode.SYNCHRONOUS, ) -> "Project": assert serena_config is not None project_root = Path(project_root).resolve() if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") - project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogenerate=autogenerate) + project_config = ProjectConfig.load(project_root, serena_config=serena_config, autogen=autogen) return Project(project_root=str(project_root), project_config=project_config, serena_config=serena_config) def save_config(self) -> None: @@ -438,6 +439,10 @@ class Project(ToStringMixin): :return: the language server manager, which is also stored in the project instance """ try: + # ensure that the project configuration, particularly the list of languages is complete, + # despite asynchronous first-time project configuration generation (which may not have completed yet) + self.project_config.await_asynchronous_completion() + # determine timeout to use for LS calls tool_timeout = self.serena_config.tool_timeout if tool_timeout is None or tool_timeout < 0: From 3d3739f6bdb97e362cb27b07696ee8f9ea09c87a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 20:08:58 +0200 Subject: [PATCH 47/57] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - rename file to target-name --- src/solidlsp/{ls.py => dependency_provider.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/solidlsp/{ls.py => dependency_provider.py} (100%) diff --git a/src/solidlsp/ls.py b/src/solidlsp/dependency_provider.py similarity index 100% rename from src/solidlsp/ls.py rename to src/solidlsp/dependency_provider.py From f200eef4524fd3afb403f40cfcd6e20f3bc8b7e6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 20:08:58 +0200 Subject: [PATCH 48/57] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - rename source-file to git-split-temp --- src/solidlsp/ls.py => git-split-temp | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/solidlsp/ls.py => git-split-temp (100%) diff --git a/src/solidlsp/ls.py b/git-split-temp similarity index 100% rename from src/solidlsp/ls.py rename to git-split-temp From cb1a690419f694891394256185e84ae6edd65259 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 20:08:59 +0200 Subject: [PATCH 49/57] Split history src/solidlsp/ls.py to src/solidlsp/dependency_provider.py - restore name of source-file --- git-split-temp => src/solidlsp/ls.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename git-split-temp => src/solidlsp/ls.py (100%) diff --git a/git-split-temp b/src/solidlsp/ls.py similarity index 100% rename from git-split-temp rename to src/solidlsp/ls.py From 58477ffd646cb1a32ef4c4caa150137739e50c78 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 20:11:28 +0200 Subject: [PATCH 50/57] Move dependency providers to separate module --- src/solidlsp/dependency_provider.py | 3123 +-------------------------- src/solidlsp/ls.py | 254 +-- 2 files changed, 17 insertions(+), 3360 deletions(-) diff --git a/src/solidlsp/dependency_provider.py b/src/solidlsp/dependency_provider.py index f296fee6..d3826eba 100644 --- a/src/solidlsp/dependency_provider.py +++ b/src/solidlsp/dependency_provider.py @@ -1,276 +1,13 @@ -import dataclasses -import hashlib -import json import logging import os -import pathlib import shutil -import threading from abc import ABC, abstractmethod -from collections import defaultdict -from collections.abc import Callable, Hashable, Iterator, Sequence -from contextlib import contextmanager -from copy import copy -from dataclasses import dataclass -from pathlib import Path, PurePath -from time import monotonic, perf_counter, sleep -from typing import Any, Self, Union, cast +from collections.abc import Sequence -import pathspec -from sensai.util.pickle import getstate, load_pickle -from sensai.util.string import ToStringMixin - -from serena.util.file_system import match_path -from serena.util.text_utils import MatchedConsecutiveLines -from solidlsp import ls_types -from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder -from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig -from solidlsp.ls_exceptions import SolidLSPException -from solidlsp.ls_process import LanguageServerInterface, StdioLanguageServer -from solidlsp.ls_types import UnifiedSymbolInformation -from solidlsp.ls_utils import FileUtils, PathUtils, TextUtils -from solidlsp.lsp_protocol_handler import lsp_types -from solidlsp.lsp_protocol_handler import lsp_types as LSPTypes -from solidlsp.lsp_protocol_handler.lsp_constants import LSPConstants -from solidlsp.lsp_protocol_handler.lsp_types import ( - Definition, - DefinitionParams, - DocumentSymbol, - ImplementationParams, - InitializeParams, - LocationLink, - RenameParams, - SymbolInformation, -) -from solidlsp.lsp_protocol_handler.server import ( - LSPError, - ProcessLaunchInfo, - StringDict, -) from solidlsp.settings import SolidLSPSettings -from solidlsp.util.cache import load_cache, save_cache - -RawDocumentSymbol = Union[DocumentSymbol, SymbolInformation] -""" -Type alias for the raw symbol information returned by a language server in response to a -`textDocument/documentSymbol` request. -The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformation` is also still used. -""" log = logging.getLogger(__name__) -_debug_enabled = log.isEnabledFor(logging.DEBUG) -"""Serves as a flag that triggers additional computation when debug logging is enabled.""" - - -@dataclasses.dataclass(kw_only=True) -class ReferenceInSymbol: - """A symbol retrieved when requesting reference to a symbol, together with the location of the reference""" - - symbol: ls_types.UnifiedSymbolInformation - line: int - character: int - - -class LSPFileBuffer: - """ - This class is used to store the contents of an open LSP file in memory. - """ - - def __init__( - self, - abs_path: Path, - uri: str, - encoding: str, - version: int, - language_id: str, - ref_count: int, - language_server: "SolidLanguageServer", - open_in_ls: bool = True, - ) -> None: - self.abs_path = abs_path - self.language_server = language_server - self.uri = uri - self._read_file_modified_date: float | None = None - self._contents: str | None = None - self.version = version - self.language_id = language_id - self.ref_count = ref_count - self.encoding = encoding - self._content_hash: str | None = None - self._is_open_in_ls = False - if open_in_ls: - self._open_in_ls() - - def _open_in_ls(self) -> None: - """ - Open the file in the language server if it is not already open. - """ - if self._is_open_in_ls: - return - self._is_open_in_ls = True - self.language_server.server.notify.did_open_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: self.uri, - LSPConstants.LANGUAGE_ID: self.language_id, - LSPConstants.VERSION: 0, - LSPConstants.TEXT: self.contents, - } - } - ) - - def close(self) -> None: - if self._is_open_in_ls: - self.language_server.server.notify.did_close_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: self.uri, - } - } - ) - - def ensure_open_in_ls(self) -> None: - """Ensure that the file is opened in the language server.""" - self._open_in_ls() - - @property - def contents(self) -> str: - file_modified_date = self.abs_path.stat().st_mtime - - # if contents are cached, check if they are stale (file modification since last read) and invalidate if so - if self._contents is not None: - assert self._read_file_modified_date is not None - if file_modified_date > self._read_file_modified_date: - self._contents = None - - if self._contents is None: - self._read_file_modified_date = file_modified_date - self._contents = FileUtils.read_file(str(self.abs_path), self.encoding) - self._content_hash = None - - return self._contents - - @contents.setter - def contents(self, new_contents: str) -> None: - """ - Sets new contents for the file buffer (in-memory change only). - Persistence of the change to disk must be handled separately. - - :param new_contents: the new contents to set - """ - self._contents = new_contents - self._content_hash = None - - @property - def content_hash(self) -> str: - if self._content_hash is None: - self._content_hash = hashlib.md5(self.contents.encode(self.encoding)).hexdigest() - return self._content_hash - - def split_lines(self) -> list[str]: - """Splits the contents of the file into lines.""" - return self.contents.split("\n") - - -class SymbolBody(ToStringMixin): - """ - Representation of the body of a symbol, which allows the extraction of the symbol's text - from the lines of the file it is defined in. - - Instances that share the same lines buffer are memory-efficient, - using only 4 integers and a reference to the lines buffer from which the text can be extracted, - i.e. a core representation of only about 40 bytes per body. - """ - - def __init__(self, lines: list[str], start_line: int, start_col: int, end_line: int, end_col: int) -> None: - self._lines = lines - self._start_line = start_line - self._start_col = start_col - self._end_line = end_line - self._end_col = end_col - - def _tostring_excludes(self) -> list[str]: - return ["_lines"] - - def get_text(self) -> str: - # extract relevant lines - symbol_body = "\n".join(self._lines[self._start_line : self._end_line + 1]) - - # remove leading content from the first line - symbol_body = symbol_body[self._start_col :] - - # remove trailing content from the last line - last_line = self._lines[self._end_line] - trailing_length = len(last_line) - self._end_col - if trailing_length > 0: - symbol_body = symbol_body[: -(len(last_line) - self._end_col)] - - return symbol_body - - -class SymbolBodyFactory: - """ - A factory for the creation of SymbolBody instances from symbols dictionaries. - Instances created from the same factory instance are memory-efficient, as they share - the same lines buffer. - """ - - def __init__(self, file_buffer: LSPFileBuffer): - self._lines = file_buffer.split_lines() - - def create_symbol_body(self, symbol: UnifiedSymbolInformation) -> SymbolBody: - existing_body = symbol.get("body", None) - if existing_body and isinstance(existing_body, SymbolBody): - return existing_body - - assert "location" in symbol - start_line = symbol["location"]["range"]["start"]["line"] - end_line = symbol["location"]["range"]["end"]["line"] - start_col = symbol["location"]["range"]["start"]["character"] - end_col = symbol["location"]["range"]["end"]["character"] - return SymbolBody(self._lines, start_line, start_col, end_line, end_col) - - -class DocumentSymbols: - # IMPORTANT: Instances of this class are persisted in the high-level document symbol cache - - def __init__(self, root_symbols: list[ls_types.UnifiedSymbolInformation]): - self.root_symbols = root_symbols - self._all_symbols: list[ls_types.UnifiedSymbolInformation] | None = None - - def __getstate__(self) -> dict: - return getstate(DocumentSymbols, self, transient_properties=["_all_symbols"]) - - def iter_symbols(self) -> Iterator[ls_types.UnifiedSymbolInformation]: - """ - Iterate over all symbols in the document symbol tree. - Yields symbols in a depth-first manner. - """ - if self._all_symbols is not None: - yield from self._all_symbols - return - - def traverse(s: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: - yield s - for child in s.get("children", []): - yield from traverse(child) - - for root_symbol in self.root_symbols: - yield from traverse(root_symbol) - - def get_all_symbols_and_roots(self) -> tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]: - """ - This function returns all symbols in the document as a flat list and the root symbols. - It exists to facilitate migration from previous versions, where this was the return interface of - the LS method that obtained document symbols. - - :return: A tuple containing a list of all symbols in the document and a list of root symbols. - """ - if self._all_symbols is None: - self._all_symbols = list(self.iter_symbols()) - return self._all_symbols, self.root_symbols - class LanguageServerDependencyProvider(ABC): """ @@ -507,2861 +244,3 @@ class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCo def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: return base_command + list(self._extra_args) - - -class SolidLanguageServer(ABC): - """ - High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface - """ - - CACHE_FOLDER_NAME = "cache" - RAW_DOCUMENT_SYMBOLS_CACHE_VERSION = 1 - """ - global version identifier for raw symbol caches; an LS-specific version is defined separately and combined with this. - This should be incremented whenever there is a change in the way raw document symbols are stored. - If the result of a language server changes in a way that affects the raw document symbols, - the LS-specific version should be incremented instead. - """ - RAW_DOCUMENT_SYMBOL_CACHE_FILENAME = "raw_document_symbols.pkl" - RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK = "document_symbols_cache_v23-06-25.pkl" - DOCUMENT_SYMBOL_CACHE_VERSION = 4 - DOCUMENT_SYMBOL_CACHE_FILENAME = "document_symbols.pkl" - - # Directories that should always be ignored regardless of language: - # VCS internals, virtual environments, caches, and serena's own data. - _ALWAYS_IGNORED_DIRS = frozenset( - { - ".git", - ".svn", - ".hg", - ".bzr", # VCS - ".venv", - ".env", # virtual environments - ".cache", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", # caches - ".tox", - ".nox", # test runners - ".idea", # IDE internals - ".serena", # serena's own data - ".vscode", # Doesn't contain symbols - } - ) - - # To be overridden and extended by subclasses - def is_ignored_dirname(self, dirname: str) -> bool: - """ - A language-specific condition for directories that should always be ignored. For example, venv - in Python and node_modules in JS/TS should be ignored always. - """ - return dirname in self._ALWAYS_IGNORED_DIRS - - @staticmethod - def _determine_log_level(line: str) -> int: - """ - Classify a stderr line from the language server to determine appropriate logging level. - - Language servers may emit informational messages to stderr that contain words like "error" - but are not actual errors. Subclasses can override this method to filter out known - false-positive patterns specific to their language server. - - :param line: The stderr line to classify - :return: A logging level (logging.DEBUG, logging.INFO, logging.WARNING, or logging.ERROR) - """ - line_lower = line.lower() - - # Default classification: treat lines with "error" or "exception" as ERROR level - if "error" in line_lower or "exception" in line_lower or line.startswith("E["): - return logging.ERROR - else: - return logging.INFO - - @classmethod - def get_language_enum_instance(cls) -> Language: - return Language.from_ls_class(cls) - - @classmethod - def supports_implementation_request(cls) -> bool: - """ - Return whether this language server supports ``textDocument/implementation``. - """ - return False - - @classmethod - def ls_resources_dir(cls, solidlsp_settings: SolidLSPSettings, mkdir: bool = True) -> str: - """ - Returns the directory where the language server resources are downloaded. - This is used to store language server binaries, configuration files, etc. - """ - result = os.path.join(solidlsp_settings.ls_resources_dir, cls.__name__) - - # Migration of previously downloaded LS resources that were downloaded to a subdir of solidlsp instead of to the user's home - pre_migration_ls_resources_dir = os.path.join(os.path.dirname(__file__), "language_servers", "static", cls.__name__) - if os.path.exists(pre_migration_ls_resources_dir): - if os.path.exists(result): - # if the directory already exists, we just remove the old resources - shutil.rmtree(result, ignore_errors=True) - else: - # move old resources to the new location - shutil.move(pre_migration_ls_resources_dir, result) - if mkdir: - os.makedirs(result, exist_ok=True) - return result - - @classmethod - def create( - cls, - config: LanguageServerConfig, - repository_root_path: str, - timeout: float | None = None, - solidlsp_settings: SolidLSPSettings | None = None, - ) -> "SolidLanguageServer": - """ - Creates a language specific LanguageServer instance based on the given configuration, and appropriate settings for the programming language. - - If language is Java, then ensure that jdk-17.0.6 or higher is installed, `java` is in PATH, and JAVA_HOME is set to the installation directory. - If language is JS/TS, then ensure that node (v18.16.0 or higher) is installed and in PATH. - - :param repository_root_path: The root path of the repository. - :param config: language server configuration. - :param logger: The logger to use. - :param timeout: the timeout for requests to the language server. If None, no timeout will be used. - :param solidlsp_settings: additional settings - :return LanguageServer: A language specific LanguageServer instance. - """ - ls: SolidLanguageServer - if solidlsp_settings is None: - solidlsp_settings = SolidLSPSettings() - - # Ensure repository_root_path is absolute to avoid issues with file URIs - repository_root_path = os.path.abspath(repository_root_path) - - ls_class = config.code_language.get_ls_class() - # All language server implementations are required to use the same signature of the constructor - # (which differs from the signature of the base class constructor). - ls = ls_class(config, repository_root_path, solidlsp_settings) - ls.set_request_timeout(timeout) - return ls - - def __init__( - self, - config: LanguageServerConfig, - repository_root_path: str, - process_launch_info: ProcessLaunchInfo | None, - language_id: str, - solidlsp_settings: SolidLSPSettings, - cache_version_raw_document_symbols: Hashable = 1, - ): - """ - Initializes a LanguageServer instance. - - Do not instantiate this class directly. Use `LanguageServer.create` method instead. - - :param config: the language server's configuration. - :param repository_root_path: the root path of the repository. - :param process_launch_info: (DEPRECATED: pass None and implement _create_dependency_provider instead) - the command used to start the actual language server. - The command must pass appropriate flags to the binary, so that it runs in the stdio mode, - as opposed to HTTP, TCP modes supported by some language servers. - :param language_id: The language identifier which will be passed to the language server in the `textDocument/didOpen` - notification by default. - If the language server uses multiple language identifiers, it must override the method `get_language_id_for_file` - to provide the appropriate identifier for each type of file. - :param solidlsp_settings: the global SolidLSP settings - :param cache_version_raw_document_symbols: the version, for caching, of the raw document symbols coming - from this specific language server. This should be incremented by subclasses calling this constructor - whenever the format of the raw document symbols changes (typically because the language server - improves/fixes its output). - """ - self.config = config - self._solidlsp_settings = solidlsp_settings - lang = self.get_language_enum_instance() - self._custom_settings = solidlsp_settings.get_ls_specific_settings(lang) - """ - the (user-provided) language server-specific settings - """ - self._ls_resources_dir = self.ls_resources_dir(solidlsp_settings) - log.debug(f"Custom config (LS-specific settings) for {lang}: {self._custom_settings}") - self._encoding = config.encoding - self.repository_root_path: str = repository_root_path - - log.debug( - f"Creating language server instance for {repository_root_path=} with {language_id=} and process launch info: {process_launch_info}" - ) - - self.language_id = language_id - """ - default language identifier to be passed to the language server in `textDocument/didOpen` notifications. - """ - self.open_file_buffers: dict[str, LSPFileBuffer] = {} - self.language = self.get_language_enum_instance() - """ - identifies the language server (not to be confused with the language id passed to the language server) - """ - # The source filename matcher is a @cache'd per-language singleton. A previous project may - # have extended it (e.g. Perl's file_filter adding .cgi); reset it here so every activation - # starts from the language's default extensions, then language-server subclasses re-apply - # their own settings during the rest of __init__. - self.language.get_source_fn_matcher().reset() - self._published_diagnostics: dict[str, list[ls_types.Diagnostic]] = {} - self._published_diagnostics_generation_by_uri: dict[str, int] = {} - self._published_diagnostics_generation = 0 - self._published_diagnostics_condition = threading.Condition() - - # initialise symbol caches - self.cache_dir = Path(self._solidlsp_settings.project_data_path) / self.CACHE_FOLDER_NAME / self.language_id - self.cache_dir.mkdir(parents=True, exist_ok=True) - # * raw document symbols cache - self._ls_specific_raw_document_symbols_cache_version = cache_version_raw_document_symbols - self._raw_document_symbols_cache: dict[str, tuple[str, list[DocumentSymbol] | list[SymbolInformation] | None]] = {} - """maps relative file paths to a tuple of (file_content_hash, raw_root_symbols)""" - self._raw_document_symbols_cache_is_modified: bool = False - self._load_raw_document_symbols_cache() - # * high-level document symbols cache - self._document_symbols_cache: dict[str, tuple[str, DocumentSymbols]] = {} - """maps relative file paths to a tuple of (file_content_hash, document_symbols)""" - self._document_symbols_cache_is_modified: bool = False - self._load_document_symbols_cache() - - self.server_started = False - if config.trace_lsp_communication: - - def logging_fn(source: str, target: str, msg: StringDict | str) -> None: - log.debug(f"LSP: {source} -> {target}: {msg!s}") - - else: - logging_fn = None - - # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, - # preparing the launch command beforehand - self._dependency_provider: LanguageServerDependencyProvider | None = None - if process_launch_info is 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 = self._create_language_server_interface(process_launch_info, logging_fn) - """ - the low-level language server interface - """ - self.server.on_any_notification(self._observe_server_notification) - - # Set up the pathspec matcher for the ignored paths - # for all absolute paths in ignored_paths, convert them to relative paths - processed_patterns = [] - for pattern in set(config.ignored_paths): - # Normalize separators (pathspec expects forward slashes) - pattern = pattern.replace(os.path.sep, "/") - processed_patterns.append(pattern) - log.debug(f"Processing {len(processed_patterns)} ignored paths from the config") - - # Create a pathspec matcher from the processed patterns - self._ignore_spec = pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, processed_patterns) - - self._has_waited_for_cross_file_references = False - - self._abs_workspace_folders_indexed = self.config.get_absolute_workspace_folders(self.repository_root_path) - self._abs_workspace_folders_additional = self.config.get_absolute_additional_workspace_folders(self.repository_root_path) - """ - additional workspace folders, which are passed to the language server in the initialization request - but which are not indexed by SolidLSP, i.e. not traversed for full symbol tree - """ - self._abs_workspace_folders_all = self._abs_workspace_folders_indexed + self._abs_workspace_folders_additional - - @property - def custom_settings(self) -> SolidLSPSettings.CustomLSSettings: - """ - The (user-provided) language server-specific settings. - """ - return self._custom_settings - - def _create_dependency_provider(self) -> LanguageServerDependencyProvider: - """ - Creates the dependency provider for this language server. - - Subclasses should override this method to provide their specific dependency provider. - This method is only called if process_launch_info is not passed to __init__. - """ - raise NotImplementedError( - f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" - ) - - def _create_process_launch_info(self) -> ProcessLaunchInfo: - assert self._dependency_provider is not None - cmd = self._dependency_provider.create_launch_command() - env = self._dependency_provider.create_launch_command_env() - return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) - - def _create_language_server_interface( - self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None - ) -> LanguageServerInterface: - """ - Creates the low-level language server interface for LSP communication. - - The interface is created but not started. - - The default implementation creates a StdioLanguageServer, but subclasses can override this method to create a different type - of interface if needed. - - :param process_launch_info: process launch information - :param logging_fn: the trace logging function - :return: the interface - """ - return StdioLanguageServer( - process_launch_info, - language=self.language, - determine_log_level=self._determine_log_level, - logger=logging_fn, - start_independent_lsp_process=self.config.start_independent_lsp_process, - ) - - # --- diagnostics-related functions --- - - def _observe_server_notification(self, method: str, params: Any) -> None: - """ - Observe notifications sent by the language server. - - This is used for generic cross-language bookkeeping that must work independently of - language-specific notification handlers. - """ - if method == "textDocument/publishDiagnostics": - self._store_published_diagnostics(params) - - def _store_published_diagnostics(self, params: Any) -> None: - """ - Store diagnostics received through ``textDocument/publishDiagnostics``. - """ - if not isinstance(params, dict): - return - - uri = params.get("uri") - diagnostics = params.get("diagnostics") - if not isinstance(uri, str) or not isinstance(diagnostics, list): - return - - normalized_diagnostics: list[ls_types.Diagnostic] = [] - for diagnostic in diagnostics: - if not isinstance(diagnostic, dict): - continue - if "message" not in diagnostic or "range" not in diagnostic: - continue - - normalized_diagnostic: ls_types.Diagnostic = { - "uri": uri, - "message": diagnostic["message"], - "range": diagnostic["range"], - } - severity = diagnostic.get("severity") - if isinstance(severity, int): - normalized_diagnostic["severity"] = ls_types.DiagnosticSeverity(severity) - - code = diagnostic.get("code") - if isinstance(code, int | str): - normalized_diagnostic["code"] = code - - if "source" in diagnostic: - normalized_diagnostic["source"] = diagnostic["source"] - normalized_diagnostics.append(ls_types.Diagnostic(**normalized_diagnostic)) - - # canonicalize the key so lookups using URIs produced by pathlib.Path.as_uri() match - # what servers publish (e.g. file:///c%3A/... or file:///c:/... vs. file:///C:/...) - key = self._canonicalize_published_diagnostics_uri(uri) - - with self._published_diagnostics_condition: - self._published_diagnostics_generation += 1 - self._published_diagnostics[key] = normalized_diagnostics - self._published_diagnostics_generation_by_uri[key] = self._published_diagnostics_generation - self._published_diagnostics_condition.notify_all() - - @staticmethod - def _canonicalize_published_diagnostics_uri(uri: str) -> str: - """ - Canonicalizes a ``file://`` URI so that diagnostics published by language servers - and lookups based on ``pathlib.Path.as_uri()`` agree on the same key. - - On Windows, servers may publish under ``file:///c%3A/...`` or ``file:///c:/...`` while - ``pathlib.Path.as_uri()`` produces ``file:///C:/...``. The canonical form uses an - upper-case drive letter and a plain colon. - """ - if os.name != "nt" or not uri.startswith("file:///"): - return uri - - # extract the segment after "file:///" up to the next slash and look for a drive letter - prefix = "file:///" - rest = uri[len(prefix) :] - slash = rest.find("/") - head = rest if slash < 0 else rest[:slash] - tail = "" if slash < 0 else rest[slash:] - - if (len(head) >= 2 and head[0].isalpha() and head[1] == ":") or ( - len(head) >= 4 and head[0].isalpha() and head[1:4].lower() == "%3a" - ): - head = head[0].upper() + ":" - else: - return uri - - return prefix + head + tail - - def _get_published_diagnostics_generation(self, uri: str) -> int: - key = self._canonicalize_published_diagnostics_uri(uri) - with self._published_diagnostics_condition: - return self._published_diagnostics_generation_by_uri.get(key, -1) - - def _wait_for_published_diagnostics( - self, - uri: str, - after_generation: int, - timeout: float, - ) -> list[ls_types.Diagnostic] | None: - key = self._canonicalize_published_diagnostics_uri(uri) - deadline = perf_counter() + timeout - with self._published_diagnostics_condition: - while True: - current_generation = self._published_diagnostics_generation_by_uri.get(key, -1) - if current_generation > after_generation: - return list(self._published_diagnostics.get(key, [])) - - remaining_timeout = deadline - perf_counter() - if remaining_timeout <= 0: - return None - self._published_diagnostics_condition.wait(timeout=remaining_timeout) - - def _get_cached_published_diagnostics(self, uri: str) -> list[ls_types.Diagnostic] | None: - key = self._canonicalize_published_diagnostics_uri(uri) - with self._published_diagnostics_condition: - diagnostics = self._published_diagnostics.get(key) - if diagnostics is None: - return None - return list(diagnostics) - - @staticmethod - def _diagnostic_matches_range(diagnostic: ls_types.Diagnostic, start_line: int, end_line: int) -> bool: - diagnostic_start_line = diagnostic["range"]["start"]["line"] - diagnostic_end_line = diagnostic["range"]["end"]["line"] - - # normalize inverted ranges: some servers (e.g. Julia's JuliaSyntax.jl for unterminated - # expressions) emit diagnostics whose end position lies before the start position. - # treat such ranges as a single span covering both lines. - lo = min(diagnostic_start_line, diagnostic_end_line) - hi = max(diagnostic_start_line, diagnostic_end_line) - - # when end_line < 0 the caller imposes no upper bound; only enforce the lower bound. - if end_line < 0: - return hi >= start_line - return lo <= end_line and hi >= start_line - - @staticmethod - def _diagnostic_matches_min_severity(diagnostic: ls_types.Diagnostic, min_severity: int) -> bool: - severity = diagnostic.get("severity") - if severity is None: - return True - return int(severity) <= min_severity - - @classmethod - def _filter_diagnostics( - cls, - diagnostics: list[ls_types.Diagnostic], - start_line: int, - end_line: int, - min_severity: int, - ) -> list[ls_types.Diagnostic]: - diagnostics = [d for d in diagnostics if cls._diagnostic_matches_range(d, start_line, end_line)] - diagnostics = [d for d in diagnostics if cls._diagnostic_matches_min_severity(d, min_severity)] - return diagnostics - - def _validate_text_document_diagnostics_request( - self, - relative_file_path: str, - start_line: int, - end_line: int, - min_severity: int, - ) -> str: - if not self.server_started: - log.error("request_text_document_diagnostics called before Language Server started") - raise SolidLSPException("Language Server not started") - if start_line < 0: - raise ValueError(f"start_line must be non-negative, got {start_line}") - if end_line != -1 and end_line < start_line: - raise ValueError(f"end_line must be -1 or >= start_line, got {end_line} < {start_line}") - if min_severity not in {1, 2, 3, 4}: - raise ValueError(f"min_severity must be one of 1, 2, 3, 4, got {min_severity}") - return pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - - def get_published_diagnostics_generation(self, relative_file_path: str) -> int: - """ - Get the generation number for the latest published diagnostics of a file. - - :param relative_file_path: The relative path of the file. - :return: the generation number, or ``-1`` if none were published yet. - """ - uri = pathlib.Path(str(PurePath(self.repository_root_path, relative_file_path))).as_uri() - return self._get_published_diagnostics_generation(uri) - - def get_cached_published_text_document_diagnostics( - self, - relative_file_path: str, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - ) -> list[ls_types.Diagnostic] | None: - """ - Get cached diagnostics received through ``textDocument/publishDiagnostics``. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for. - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - :return: the cached diagnostics, or ``None`` if no diagnostics were published yet. - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - diagnostics = self._get_cached_published_diagnostics(uri) - if diagnostics is None: - return None - return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) - - def request_published_text_document_diagnostics( - self, - relative_file_path: str, - after_generation: int = -1, - timeout: float = 2.5, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - allow_cached: bool = True, - ) -> list[ls_types.Diagnostic] | None: - """ - Wait for diagnostics received through ``textDocument/publishDiagnostics`` and return them. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for. - :param after_generation: only return diagnostics published after this generation. ``-1`` accepts the next publication. - :param timeout: the maximum time to wait for a newer publication. - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. ``-1`` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - :param allow_cached: whether to fall back to the current cached diagnostics if no newer publication arrives in time. - :return: the published diagnostics, or ``None`` if no diagnostics are available. - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - published_uri = self._get_published_diagnostics_uri(uri) - diagnostics: list[ls_types.Diagnostic] | None = None - - # keeping the document open - with self.open_file(relative_file_path): - diagnostics = self._wait_for_relevant_published_diagnostics( - uri=published_uri, - after_generation=after_generation, - timeout=timeout, - allow_cached=allow_cached, - ) - - if diagnostics is None: - return None - - return self._filter_diagnostics(diagnostics, start_line, end_line, min_severity) - - def _get_published_diagnostics_uri(self, request_uri: str) -> str: - """ - Gets the URI under which published diagnostics should be looked up. - """ - return request_uri - - def _supports_pull_diagnostics(self) -> bool: - """ - Whether the language server handles ``textDocument/diagnostic`` (LSP 3.17 pull - diagnostics) gracefully. Subclasses should override and return ``False`` when the - underlying server does not implement pull diagnostics, especially when sending - the request would terminate the server (e.g. LanguageServer.jl raises an error - for unknown methods that crashes the process). - """ - return True - - def _get_published_diagnostics_wait_timeout(self, pull_diagnostics_failed: bool) -> float: - """ - Gets the timeout for waiting on published diagnostics after a diagnostics request. - """ - return 2.5 - - def _accept_published_diagnostics(self, diagnostics: list[ls_types.Diagnostic]) -> bool: - """ - Determines whether a published diagnostics payload should satisfy the current wait. - """ - return bool(diagnostics) - - def _wait_for_relevant_published_diagnostics( - self, - uri: str, - after_generation: int, - timeout: float, - allow_cached: bool = True, - ) -> list[ls_types.Diagnostic] | None: - """ - Waits for a published diagnostics payload that is relevant for the current request. - """ - deadline = monotonic() + timeout - current_after_generation = after_generation - - while True: - remaining_timeout = deadline - monotonic() - if remaining_timeout <= 0: - break - - diagnostics = self._wait_for_published_diagnostics( - uri=uri, - after_generation=current_after_generation, - timeout=remaining_timeout, - ) - if diagnostics is None: - break - if self._accept_published_diagnostics(diagnostics): - return diagnostics - current_after_generation = self._get_published_diagnostics_generation(uri) - - if allow_cached: - diagnostics = self._get_cached_published_diagnostics(uri) - if diagnostics is not None and self._accept_published_diagnostics(diagnostics): - return diagnostics - - return None - - def request_text_document_diagnostics( - self, - relative_file_path: str, - start_line: int = 0, - end_line: int = -1, - min_severity: int = 4, - ) -> list[ls_types.Diagnostic]: - """ - Raise a [textDocument/diagnostic](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_diagnostic) request to the Language Server - to find diagnostics for the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file to retrieve diagnostics for - :param start_line: the first 0-based line to include in the result. - :param end_line: the last 0-based line to include in the result. `-1` means no upper bound. - :param min_severity: minimum LSP severity to include, where 1=Error, 2=Warning, 3=Information, 4=Hint. - Diagnostics with lower-or-equal numeric severity are returned. - - :return: A list of diagnostics for the file - """ - uri = self._validate_text_document_diagnostics_request(relative_file_path, start_line, end_line, min_severity) - published_uri = self._get_published_diagnostics_uri(uri) - diagnostics_before_request = self._get_published_diagnostics_generation(published_uri) - ret: list[ls_types.Diagnostic] | None = None - pull_diagnostics_failed = False - - with self.open_file(relative_file_path): - response: Any = None - # only send pull diagnostics when the server actually supports it; some servers - # (e.g. Julia's LanguageServer.jl) hard-error and crash the process on unknown methods - if self._supports_pull_diagnostics(): - try: - response = self.server.send.text_document_diagnostic( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.URI: uri, - } - } - ) - except SolidLSPException as ex: - log.debug("Falling back to published diagnostics for %s due to pull-diagnostics error: %s", relative_file_path, ex) - response = None - pull_diagnostics_failed = True - - if response is not None: - assert isinstance(response, dict), ( - f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - ) - ret = [] - for item in response["items"]: # type: ignore - new_item: ls_types.Diagnostic = { - "uri": uri, - "severity": item["severity"], - "message": item["message"], - "range": item["range"], - "code": item.get("code"), # type: ignore - } - if "source" in item: - new_item["source"] = item["source"] - ret.append(ls_types.Diagnostic(**new_item)) - - if not ret: - published_diagnostics = self._wait_for_relevant_published_diagnostics( - uri=published_uri, - after_generation=diagnostics_before_request, - timeout=self._get_published_diagnostics_wait_timeout(pull_diagnostics_failed), - ) - if published_diagnostics is not None: - ret = published_diagnostics - - if ret is None: - return [] - - return self._filter_diagnostics(ret, start_line, end_line, min_severity) - - def _get_wait_time_for_cross_file_referencing(self) -> float: - """Meant to be overridden by subclasses for LS that don't have a reliable "finished initializing" signal. - - LS may return incomplete results on calls to `request_references` (only references found in the same file), - if the LS is not fully initialized yet. - """ - return 2 - - # --- Cross-workspace / additional workspace folder support --- - - @staticmethod - def _path_contains_dots(relative_file_path: str) -> bool: - """Check if a relative path traverses outside the workspace root via '..' components.""" - return ".." in PurePath(relative_file_path).parts - - @dataclass - class PathWorkspaceStatus: - """ - Represents the status of a path with respect to the language server's workspace folders. - """ - - ls: "SolidLanguageServer" - resolve_abs_path: Path - is_in_workspace_folder: bool - workspace_root: str | None = None - """ - the workspace root the path is within/relative to, if any - """ - - @classmethod - def from_abs_resolved_path(cls, path: Path, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": - """ - :param path: an absolute, resolved Path instance - :param ls: the language server instance - """ - for workspace in ls._abs_workspace_folders_all: - if path.is_relative_to(workspace): - return SolidLanguageServer.PathWorkspaceStatus( - ls=ls, is_in_workspace_folder=True, workspace_root=workspace, resolve_abs_path=path - ) - return SolidLanguageServer.PathWorkspaceStatus(ls=ls, is_in_workspace_folder=False, resolve_abs_path=path) - - @classmethod - def from_relative_path(cls, relative_path: str, ls: "SolidLanguageServer") -> "SolidLanguageServer.PathWorkspaceStatus": - """ - :param relative_path: a relative path from the repository root - :param ls: the language server instance - """ - return cls.from_abs_resolved_path(pathlib.Path(ls.repository_root_path, relative_path).resolve(), ls) - - def check_within_workspace_or_raise(self): - if not self.is_in_workspace_folder: - raise ValueError( - f"Path {self.resolve_abs_path} is outside of configured workspaces. " - f"Configured workspaces: {self.ls._abs_workspace_folders_all}." - ) - - def _resolve_file_uri(self, relative_file_path: str) -> str: - """Construct a canonical file URI from a relative path. - - For cross-workspace paths containing '..', the path is resolved to - produce a clean URI without '..' segments. - """ - p = pathlib.Path(os.path.join(self.repository_root_path, relative_file_path)) - if self._path_contains_dots(relative_file_path): - p = p.resolve() - self.PathWorkspaceStatus.from_abs_resolved_path(p, self).check_within_workspace_or_raise() - return p.as_uri() - - def _activate_additional_workspaces(self) -> None: - """Open a representative file from each additional workspace folder to - trigger project loading in the language server. - - Many language servers only load projects on-demand when files are opened. - This method finds a source file in each additional workspace folder via - :meth:`_find_representative_source_file` and opens it, keeping it open - for the lifetime of this language server instance. - - Subclasses must override :meth:`_find_representative_source_file` to - enable this feature; the default implementation raises ``NotImplementedError``. - """ - opened_count = 0 - for additional_workspace in self._abs_workspace_folders_additional: - source_file = self._find_representative_source_file(additional_workspace) - if source_file is None: - log.warning("No source file found in additional workspace folder: %s", additional_workspace) - continue - - rel_path = os.path.relpath(source_file, self.repository_root_path) - log.info("Opening %s to trigger project loading for %s", rel_path, os.path.basename(additional_workspace)) - - abs_path = pathlib.Path(source_file).resolve() - uri = abs_path.as_uri() - language_id = self._get_language_id_for_file(rel_path) - - self._signal_expect_indexing() - fb = LSPFileBuffer( - abs_path=abs_path, - uri=uri, - encoding=self._encoding, - version=0, - language_id=language_id, - ref_count=1, - language_server=self, - open_in_ls=True, - ) - self.open_file_buffers[uri] = fb - opened_count += 1 - - if opened_count > 0: - log.info("Waiting for %d additional workspace(s) to index...", opened_count) - self._wait_for_additional_workspace_indexing() - - def _find_representative_source_file(self, directory: str) -> str | None: - """Find a source file suitable for triggering project loading in the given directory. - - Must be overridden by subclasses that support ``additional_workspace_folders``. - Return ``None`` if no suitable file is found. - - :param directory: Absolute path to the workspace folder. - :return: Absolute path to a source file, or None. - """ - raise NotImplementedError( - f"{type(self).__name__} does not yet support additional_workspace_folders. " - f"Override _find_representative_source_file() to enable this feature." - ) - - def _signal_expect_indexing(self) -> None: - """Signal that new files are about to be opened and async indexing should be awaited. - - Override in subclasses that track indexing progress (e.g. via $/progress). - Default implementation is a no-op. - """ - - def _wait_for_additional_workspace_indexing(self) -> None: - """Wait for additional workspace indexing to complete. - - Override in subclasses that track indexing progress. - Default implementation is a no-op. - """ - - def set_request_timeout(self, timeout: float | None) -> None: - """ - :param timeout: the timeout, in seconds, for requests to the language server. - """ - self.server.set_request_timeout(timeout) - - def get_ignore_spec(self) -> pathspec.PathSpec: - """ - Returns the pathspec matcher for the paths that were configured to be ignored through - the language server configuration. - - This is a subset of the full language-specific ignore spec that determines - which files are relevant for the language server. - - This matcher is useful for operations outside of the language server, - such as when searching for relevant non-language files in the project. - """ - return self._ignore_spec - - def get_source_fn_matcher(self) -> FilenameMatcher: - """ - :return: the source filename matcher for this language server, which must positively match all files that - are understood by this language server or are discovered as containing sources indirectly, e.g. via references - """ - # By default, use the matcher of the language - return self.language.get_source_fn_matcher() - - def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool: - """ - Determine if a path should be ignored based on file type - and ignore patterns. - - Missing paths are classified without raising, since language servers may report - locations of generated files that are not present on disk (e.g. compiled classes - under build output). A missing path is treated as a file if its name has a suffix - and as a directory otherwise; directory-only ignore patterns (e.g. ``build/``) - match only existing directories. - - :param relative_path: Relative path to check - :param ignore_unsupported_files: whether files that are not supported source files should be ignored - - :return: True if the path should be ignored, False otherwise - """ - abs_path = os.path.join(self.repository_root_path, relative_path) - rel_path = Path(relative_path) - - # determine whether the path is a file: from the filesystem if the path exists, lexically otherwise - exists = os.path.exists(abs_path) - if not exists: - log.debug("Path %s does not exist; the ignore check is performed on the path itself", abs_path) - is_file = os.path.isfile(abs_path) if exists else bool(rel_path.suffix) - - # check each directory component against always-ignored dirnames - dir_parts = rel_path.parts - if is_file: - dir_parts = dir_parts[:-1] - for part in dir_parts: - if not part: # Skip empty parts (e.g., from leading '/') - continue - if self.is_ignored_dirname(part): - return True - - # apply the unsupported-extension rule to files - if is_file and ignore_unsupported_files: - fn_matcher = self.get_source_fn_matcher() - if not fn_matcher.is_relevant_filename(abs_path): - return True - - return match_path(relative_path, self.get_ignore_spec(), root_path=self.repository_root_path) - - @contextmanager - def start_server_context(self) -> Iterator["SolidLanguageServer"]: - self.start() - try: - yield self - finally: - self.stop() - - @abstractmethod - def _start_server(self) -> None: - """ - Starts the low-level language server interface (i.e. self.server). - - This method must ultimately call `self.server.start()` and may perform additional setup before or after starting the server, - such as waiting for initialization to complete or activating additional workspaces. - """ - - def _get_language_id_for_file(self, relative_file_path: str) -> str: - """ - Determines the language identifier to pass to the language server for the given file, - particularly `textDocument/didOpen` requests. - - Override this method in subclasses to return file-specific language identifiers. - The default implementation returns the main identifier passed at construction (self.language_id). - """ - return self.language_id - - @contextmanager - def open_file(self, relative_file_path: str, open_in_ls: bool = True) -> Iterator[LSPFileBuffer]: - """ - Open a file in the Language Server. This is required before making any requests to the Language Server. - - :param relative_file_path: The relative path of the file to open. - :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. - Set this to False to read the local file buffer without notifying the LS; the file can - be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. - """ - if not self.server_started: - log.error("open_file called before Language Server started") - raise SolidLSPException("Language Server not started") - - absolute_file_path = Path(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - absolute_file_path = absolute_file_path.resolve() - uri = absolute_file_path.as_uri() - - if uri in self.open_file_buffers: - fb = self.open_file_buffers[uri] - assert fb.uri == uri - assert fb.ref_count >= 1 - - fb.ref_count += 1 - if open_in_ls: - fb.ensure_open_in_ls() - else: - version = 0 - language_id = self._get_language_id_for_file(relative_file_path) - fb = LSPFileBuffer( - abs_path=absolute_file_path, - uri=uri, - encoding=self._encoding, - version=version, - language_id=language_id, - ref_count=1, - language_server=self, - open_in_ls=open_in_ls, - ) - self.open_file_buffers[uri] = fb - - try: - yield fb - finally: - fb.ref_count -= 1 - if fb.ref_count == 0: - fb.close() - del self.open_file_buffers[uri] - - @contextmanager - def _open_file_context( - self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None, open_in_ls: bool = True - ) -> Iterator[LSPFileBuffer]: - """ - Internal context manager to open a file, optionally reusing an existing file buffer. - - :param relative_file_path: the relative path of the file to open. - :param file_buffer: an optional existing file buffer to reuse. - :param open_in_ls: whether to open the file in the language server, sending the didOpen notification. - Set this to False to read the local file buffer without notifying the LS; the file can - be opened in the LS later by calling the `ensure_open_in_ls` method on the returned LSPFileBuffer. - """ - if file_buffer is not None: - expected_uri = self._resolve_file_uri(relative_file_path) - assert file_buffer.uri == expected_uri, f"Inconsistency between provided {file_buffer.uri=} and {expected_uri=}" - if open_in_ls: - file_buffer.ensure_open_in_ls() - yield file_buffer - else: - with self.open_file(relative_file_path, open_in_ls=open_in_ls) as fb: - yield fb - - def insert_text_at_position(self, relative_file_path: str, line: int, column: int, text_to_be_inserted: str) -> ls_types.Position: - """ - Insert text at the given line and column in the given file and return - the updated cursor position after inserting the text. - - :param relative_file_path: The relative path of the file to open. - :param line: The line number at which text should be inserted. - :param column: The column number at which text should be inserted. - :param text_to_be_inserted: The text to insert. - """ - if not self.server_started: - log.error("insert_text_at_position called before Language Server started") - raise SolidLSPException("Language Server not started") - - uri = self._resolve_file_uri(relative_file_path) - - # Ensure the file is open - assert uri in self.open_file_buffers - - file_buffer = self.open_file_buffers[uri] - file_buffer.version += 1 - - new_contents, new_l, new_c = TextUtils.insert_text_at_position(file_buffer.contents, line, column, text_to_be_inserted) - file_buffer.contents = new_contents - self.server.notify.did_change_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.VERSION: file_buffer.version, - LSPConstants.URI: file_buffer.uri, - }, - LSPConstants.CONTENT_CHANGES: [ - { - LSPConstants.RANGE: { - "start": {"line": line, "character": column}, - "end": {"line": line, "character": column}, - }, - "text": text_to_be_inserted, - } - ], - } - ) - return ls_types.Position(line=new_l, character=new_c) - - def delete_text_between_positions( - self, - relative_file_path: str, - start: ls_types.Position, - end: ls_types.Position, - ) -> str: - """ - Delete text between the given start and end positions in the given file and return the deleted text. - """ - if not self.server_started: - log.error("delete_text_between_positions called before Language Server started") - raise SolidLSPException("Language Server not started") - - uri = self._resolve_file_uri(relative_file_path) - - # Ensure the file is open - assert uri in self.open_file_buffers - - file_buffer = self.open_file_buffers[uri] - file_buffer.version += 1 - new_contents, deleted_text = TextUtils.delete_text_between_positions( - file_buffer.contents, start_line=start["line"], start_col=start["character"], end_line=end["line"], end_col=end["character"] - ) - file_buffer.contents = new_contents - self.server.notify.did_change_text_document( - { # ty: ignore[invalid-argument-type] # dict built from LSPConstants keys; shape matches the TypedDict - LSPConstants.TEXT_DOCUMENT: { - LSPConstants.VERSION: file_buffer.version, - LSPConstants.URI: file_buffer.uri, - }, - LSPConstants.CONTENT_CHANGES: [{LSPConstants.RANGE: {"start": start, "end": end}, "text": ""}], - } - ) - return deleted_text - - def _send_definition_request(self, definition_params: DefinitionParams) -> Definition | list[LocationLink] | None: - return self.server.send.definition(definition_params) - - class SymbolLocationRequest(ABC): - def __init__( - self, - language_server: "SolidLanguageServer", - relative_file_path: str, - line: int, - column: int, - *, - request_name: str, - ) -> None: - self.language_server = language_server - self.relative_file_path = relative_file_path - self.line = line - self.column = column - self.request_name = request_name - self.skip_ignored_paths = True - - def execute(self) -> list[ls_types.Location]: - self._ensure_server_started() - - t0 = perf_counter() if _debug_enabled else None - # arm indexing tracking before didOpen so that the subsequent wait can - # observe the indexing progress triggered by opening the file - self.language_server._pre_open_for_cross_file_references() - with self.language_server.open_file(self.relative_file_path): - self.language_server._wait_for_cross_file_references_if_needed() - try: - response = self.send_request() - except Exception as e: - mapped_exception = self.map_exception(e) - if mapped_exception is not None: - raise mapped_exception from e - raise - - result = self.normalize_response(response) - if t0 is not None: - self.log_perf_result(t0, result) - return result - - def _ensure_server_started(self) -> None: - if not self.language_server.server_started: - log.error("%s called before language server started", self.request_name) - raise SolidLSPException("Language Server not started") - - @abstractmethod - def send_request(self) -> object | None: - pass - - def map_exception(self, error: Exception) -> Exception | None: - if isinstance(error, LSPError) and getattr(error, "code", None) == -32603: - return RuntimeError( - f"LSP internal error (-32603) when requesting {self.request_name} for " - f"{self.relative_file_path}:{self.line}:{self.column}. " - "This often occurs when requesting a symbol in a way the language server cannot resolve." - ) - return None - - @abstractmethod - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - pass - - def convert_location_item(self, item: dict[str, object], *, allow_location_links: bool = False) -> ls_types.Location | None: - if LSPConstants.URI in item and LSPConstants.RANGE in item: - uri = cast(str, item[LSPConstants.URI]) - range_d = cast(ls_types.Range, item[LSPConstants.RANGE]) - elif ( - allow_location_links - and LSPConstants.TARGET_URI in item - and LSPConstants.TARGET_RANGE in item - and LSPConstants.TARGET_SELECTION_RANGE in item - ): - uri = cast(str, item[LSPConstants.TARGET_URI]) - range_d = cast(ls_types.Range, item[LSPConstants.TARGET_SELECTION_RANGE]) - else: - raise AssertionError(f"Unexpected response from Language Server: {item}") - - abs_path = PathUtils.uri_to_path(uri) - rel_path_str = PathUtils.get_relative_path(abs_path, self.language_server.repository_root_path) - - if rel_path_str is None: - log.warning( - "Found a %s in a path outside the repository, probably the LS is parsing things in installed packages or in the standardlib! " - "Path: %s. This is a bug but we currently simply skip these locations.", - self.request_name, - abs_path, - ) - return None - - # skip locations of generated files that are not present on disk - # (language servers may report e.g. compiled classes under build output) - if not os.path.exists(abs_path): - log.info("%s found symbol at non-existent path: %s", self.request_name, abs_path) - return None - - if self.skip_ignored_paths and self.language_server.is_ignored_path(rel_path_str): - log.info("%s found symbol in ignored path: %s", self.request_name, rel_path_str) - return None - - return ls_types.Location(uri=uri, range=range_d, absolutePath=str(abs_path), relativePath=rel_path_str) - - def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: - return - - class DefinitionLocationRequest(SymbolLocationRequest): - def __init__( - self, - language_server: "SolidLanguageServer", - relative_file_path: str, - line: int, - column: int, - *, - request_name: str = "request_definition", - ) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name=request_name, - ) - - def send_request(self) -> object | None: - return self.language_server._send_definition_request( - self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column) - ) - - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - if response is None: - log.warning( - "Language server returned None for %s request at %s:%s:%s", - self.request_name, - self.relative_file_path, - self.line, - self.column, - ) - return [] - - ret: list[ls_types.Location] = [] - if isinstance(response, list): - for item in response: - assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" - if location := self.convert_location_item(cast(dict[str, object], item), allow_location_links=True): - ret.append(location) - return ret - - if isinstance(response, dict): - if location := self.convert_location_item(cast(dict[str, object], response), allow_location_links=True): - ret.append(location) - return ret - - assert False, f"Unexpected response from Language Server: {response}" - - def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server - for the symbol at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: the list of locations where the symbol is defined - """ - request = self.DefinitionLocationRequest(self, relative_file_path, line, column) - return request.execute() - - def _send_implementation_request(self, implementation_params: ImplementationParams) -> Definition | list[LocationLink] | None: - return self.server.send.implementation(implementation_params) - - def _create_text_document_position_params(self, relative_file_path: str, line: int, column: int) -> DefinitionParams: - return cast( - DefinitionParams, - { - LSPConstants.TEXT_DOCUMENT: {LSPConstants.URI: self._resolve_file_uri(relative_file_path)}, - LSPConstants.POSITION: { - LSPConstants.LINE: line, - LSPConstants.CHARACTER: column, - }, - }, - ) - - def _pre_open_for_cross_file_references(self) -> None: - """Called just before open_file() for requests that may need cross-file indexing. - - Override to pre-arm async indexing tracking before sending didOpen (e.g. clear - a threading.Event so a subsequent wait for indexing blocks correctly). - The base implementation is a no-op. - """ - - def _wait_for_cross_file_references_if_needed(self) -> None: - if not self._has_waited_for_cross_file_references: - # Some LS require waiting for a while before they can return accurate cross-file results. - # The waiting has to happen after at least one file was opened in the LS. - sleep(self._get_wait_time_for_cross_file_referencing()) - self._has_waited_for_cross_file_references = True - - class ImplementationLocationRequest(DefinitionLocationRequest): - def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name="request_implementation", - ) - - def send_request(self) -> object | None: - return self.language_server._send_implementation_request( - self.language_server._create_text_document_position_params(self.relative_file_path, self.line, self.column), - ) - - def request_implementation(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/implementation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_implementation) - request to the Language Server for the symbol at the given line and column in the given file. - - :param relative_file_path: The relative path of the file that has the symbol for which implementations should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - :return: the list of locations where the symbol is implemented - """ - request = self.ImplementationLocationRequest(self, relative_file_path, line, column) - return request.execute() - - # Some LS cause problems with this, so the call is isolated from the rest to allow overriding in subclasses - def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: - return self.server.send.references( - { - "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, - "position": {"line": line, "character": column}, - "context": {"includeDeclaration": False}, - } - ) - - class ReferencesLocationRequest(SymbolLocationRequest): - def __init__(self, language_server: "SolidLanguageServer", relative_file_path: str, line: int, column: int) -> None: - super().__init__( - language_server, - relative_file_path, - line, - column, - request_name="request_references", - ) - - def send_request(self) -> object | None: - return self.language_server._send_references_request(self.relative_file_path, line=self.line, column=self.column) - - def normalize_response(self, response: object | None) -> list[ls_types.Location]: - if response is None: - return [] - - assert isinstance(response, list), f"Unexpected response from Language Server (expected list, got {type(response)}): {response}" - ret: list[ls_types.Location] = [] - for item in response: - assert isinstance(item, dict), f"Unexpected response from Language Server (expected dict, got {type(item)}): {item}" - if location := self.convert_location_item(cast(dict[str, object], item)): - ret.append(location) - return ret - - def log_perf_result(self, t0: float, result: list[ls_types.Location]) -> None: - elapsed_ms = (perf_counter() - t0) * 1000 - if not result: - log.debug("perf: request_references path=%s elapsed_ms=%.2f count=0", self.relative_file_path, elapsed_ms) - return - - unique_files = len({r["relativePath"] for r in result}) - log.debug( - "perf: request_references path=%s elapsed_ms=%.2f count=%d unique_files=%d", - self.relative_file_path, - elapsed_ms, - len(result), - unique_files, - ) - - def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: - """ - Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server - to find references to the symbol at the given line and column in the given file. Wait for the response and return the result. - Filters out references located in ignored directories. - - :param relative_file_path: The relative path of the file that has the symbol for which references should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: A list of locations where the symbol is referenced (excluding ignored directories) - """ - request = self.ReferencesLocationRequest(self, relative_file_path, line, column) - return request.execute() - - def retrieve_full_file_content(self, file_path: str) -> str: - """ - Retrieve the full content of the given file. - """ - if os.path.isabs(file_path): - file_path = os.path.relpath(file_path, self.repository_root_path) - with self.open_file(file_path) as file_data: - return file_data.contents - - def retrieve_content_around_line( - self, relative_file_path: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0 - ) -> MatchedConsecutiveLines: - """ - Retrieve the content of the given file around the given line. - - :param relative_file_path: The relative path of the file to retrieve the content from - :param line: The line number to retrieve the content around - :param context_lines_before: The number of lines to retrieve before the given line - :param context_lines_after: The number of lines to retrieve after the given line - - :return MatchedConsecutiveLines: A container with the desired lines. - """ - with self.open_file(relative_file_path) as file_data: - file_contents = file_data.contents - return MatchedConsecutiveLines.from_file_contents( - file_contents, - line=line, - context_lines_before=context_lines_before, - context_lines_after=context_lines_after, - source_file_path=relative_file_path, - ) - - def request_completions( - self, relative_file_path: str, line: int, column: int, allow_incomplete: bool = False - ) -> list[ls_types.CompletionItem]: - """ - Raise a [textDocument/completion](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_completion) request to the Language Server - to find completions at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the symbol for which completions should be looked up - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return: A list of completions - """ - with self.open_file(relative_file_path): - open_file_buffer = self.open_file_buffers[self._resolve_file_uri(relative_file_path)] - completion_params: LSPTypes.CompletionParams = { - "position": {"line": line, "character": column}, - "textDocument": {"uri": open_file_buffer.uri}, - "context": {"triggerKind": LSPTypes.CompletionTriggerKind.Invoked}, - } - response: list[LSPTypes.CompletionItem] | LSPTypes.CompletionList | None = None - - for _ in range(30): - response = self.server.send.completion(completion_params) - if isinstance(response, list): - response = {"items": response, "isIncomplete": False} - if response is None or not response["isIncomplete"]: - break - - # TODO: Understand how to appropriately handle `isIncomplete` - if response is None or (response["isIncomplete"] and not allow_incomplete): - return [] - - if "items" in response: - response = response["items"] - - response = cast(list[LSPTypes.CompletionItem], response) - - # TODO: Handle the case when the completion is a keyword - items = [item for item in response if item["kind"] != LSPTypes.CompletionItemKind.Keyword] - - completions_list: list[ls_types.CompletionItem] = [] - - for item in items: - assert "insertText" in item or "textEdit" in item - assert "kind" in item - completion_item = {} - if "detail" in item: - completion_item["detail"] = item["detail"] - - if "label" in item: - completion_item["completionText"] = item["label"] - completion_item["kind"] = item["kind"] - elif "insertText" in item: - completion_item["completionText"] = item["insertText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "newText" in item["textEdit"]: - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "range" in item["textEdit"]: - new_dot_lineno, new_dot_colno = ( - completion_params["position"]["line"], - completion_params["position"]["character"], - ) - assert all( - ( - item["textEdit"]["range"]["start"]["line"] == new_dot_lineno, - item["textEdit"]["range"]["start"]["character"] == new_dot_colno, - item["textEdit"]["range"]["start"]["line"] == item["textEdit"]["range"]["end"]["line"], - item["textEdit"]["range"]["start"]["character"] == item["textEdit"]["range"]["end"]["character"], - ) - ) - - completion_item["completionText"] = item["textEdit"]["newText"] - completion_item["kind"] = item["kind"] - elif "textEdit" in item and "insert" in item["textEdit"]: - assert False - else: - assert False - - completion_item = ls_types.CompletionItem(**completion_item) # type: ignore - completions_list.append(completion_item) - - return [json.loads(json_repr) for json_repr in set(json.dumps(item, sort_keys=True) for item in completions_list)] - - def _request_document_symbols( - self, relative_file_path: str, file_data: LSPFileBuffer | None - ) -> list[SymbolInformation] | list[DocumentSymbol] | None: - """ - Sends a [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) - request to the language server to find symbols in the given file - or returns a cached result if available. - The returned symbols are considered "raw document symbols" (in contrast to processed symbols returned by `request_document_symbols`). - - NOTE: This method can be overridden in subclasses to post-process the raw results. - When doing so after the initial implementation, be sure to update the init parameter `cache_version_raw_document_symbols` - to a different version (add 1) to ensure that all caches are invalidated appropriately. - IMPORTANT: Since rebuilding the raw document symbol cache from the language server results - is potentially expensive, prefer overriding the `request_document_symbols` method - if the post-processing can also be done on the processed/high-level symbols. - - :param relative_file_path: the relative path of the file that has the symbols. - :param file_data: the file data buffer, if already opened. If None, the file will be opened in this method. - :return: the list of root symbols in the file. - """ - - def get_cached_raw_document_symbols(cache_key: str, fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: - file_hash_and_result = self._raw_document_symbols_cache.get(cache_key) - if file_hash_and_result is None: - log.debug("No cache hit for raw document symbols in %s", relative_file_path) - log.debug("perf: raw_document_symbols_cache MISS path=%s", relative_file_path) - return None - - file_hash, result = file_hash_and_result - if file_hash == fd.content_hash: - log.debug("Returning cached raw document symbols for %s", relative_file_path) - log.debug("perf: raw_document_symbols_cache HIT path=%s", relative_file_path) - return result - - log.debug("Document content for %s has changed (raw symbol cache is not up-to-date)", relative_file_path) - log.debug("perf: raw_document_symbols_cache STALE path=%s", relative_file_path) - return None - - def get_raw_document_symbols(fd: LSPFileBuffer) -> list[SymbolInformation] | list[DocumentSymbol] | None: - # check for cached result - cache_key = relative_file_path - response = get_cached_raw_document_symbols(cache_key, fd) - if response is not None: - return response - - # no cached result, query language server - log.debug(f"Requesting document symbols for {relative_file_path} from the Language Server") - response = self.server.send.document_symbol({"textDocument": {"uri": self._resolve_file_uri(relative_file_path)}}) - - # Only cache non-empty results. An empty or None response can occur when the language server - # has not yet finished indexing or building the project (e.g. Lean 4 before `lake build`), - # and caching it would permanently serve stale data even after the project is ready. - if response: - self._raw_document_symbols_cache[cache_key] = (fd.content_hash, response) - self._raw_document_symbols_cache_is_modified = True - - return response - - with self._open_file_context(relative_file_path, file_buffer=file_data) as fd: - return get_raw_document_symbols(fd) - - def _normalize_symbol_name(self, symbol: RawDocumentSymbol, relative_file_path: str) -> str: - """ - Normalizes the name of the given symbol, e.g. by removing parameter lists from method symbols. - - Override this method in subclasses to implement language-specific normalization logic. - NOTE: When changing the override of this method after the initial LS implementation, - be sure to also override `_document_symbols_cache_fingerprint` in order to ensure that - the caches are invalidated appropriately. - - :param symbol: the symbol - :param relative_file_path: the relative path of the file the symbol is located in - :return: the normalized name of the symbol - """ - # the default implementation does not change the name - return symbol["name"] - - def request_document_symbols(self, relative_file_path: str, file_buffer: LSPFileBuffer | None = None) -> DocumentSymbols: - """ - Retrieves the collection of symbols in the given file. - - NOTE: This method can be overridden in subclasses to post-process the results. - When doing so after the initial LS implementation, be sure to also override `_document_symbols_cache_fingerprint` - to ensure that the caches are invalidated appropriately. - DO NOT override this method to modify symbol names; override `_normalize_symbol_name` instead. - - :param relative_file_path: The relative path of the file that has the symbols - :param file_buffer: an optional file buffer if the file is already opened. - :return: the collection of symbols in the file. - All contained symbols will have a location, children, and a parent attribute, - where the parent attribute is None for root symbols. - Note that this is slightly different from the call to request_full_symbol_tree, - where the parent attribute will be the file symbol which in turn may have a package symbol as parent. - If you need a symbol tree that contains file symbols as well, you should use `request_full_symbol_tree` instead. - """ - with self._open_file_context(relative_file_path, file_buffer, open_in_ls=False) as file_data: - # check if the desired result is cached - cache_key = relative_file_path - file_hash_and_result = self._document_symbols_cache.get(cache_key) - if file_hash_and_result is None: - log.debug("No cache hit for document symbols in %s", relative_file_path) - log.debug("perf: document_symbols_cache MISS path=%s", relative_file_path) - else: - file_hash, document_symbols = file_hash_and_result - if file_hash == file_data.content_hash: - log.debug("Returning cached document symbols for %s", relative_file_path) - log.debug("perf: document_symbols_cache HIT path=%s", relative_file_path) - return document_symbols - - log.debug("Cached document symbol content for %s has changed", relative_file_path) - log.debug("perf: document_symbols_cache STALE path=%s", relative_file_path) - - # no cached result: request the root symbols from the language server - root_symbols = self._request_document_symbols(relative_file_path, file_data) - - if root_symbols is None: - log.warning( - f"Received None response from the Language Server for document symbols in {relative_file_path}. " - f"This means the language server can't understand this file (possibly due to syntax errors). It may also be due to a bug or misconfiguration of the LS. " - f"Returning empty list", - ) - return DocumentSymbols([]) - - assert isinstance(root_symbols, list), f"Unexpected response from Language Server: {root_symbols}" - log.debug("Received %d root symbols for %s from the language server", len(root_symbols), relative_file_path) - - body_factory = SymbolBodyFactory(file_data) - - def convert_to_unified_symbol(original_symbol_dict: RawDocumentSymbol) -> ls_types.UnifiedSymbolInformation: - """ - Converts the given symbol dictionary to the unified representation, ensuring - that all required fields are present (except 'children' which is handled separately). - - :param original_symbol_dict: the item to augment - :return: the augmented item (new object) - """ - # noinspection PyInvalidCast - item = cast(ls_types.UnifiedSymbolInformation, dict(original_symbol_dict)) - absolute_path = os.path.join(self.repository_root_path, relative_file_path) - - # handle missing location and path entries - if "location" not in item: - uri = pathlib.Path(absolute_path).as_uri() - assert "range" in item - tree_location = ls_types.Location( - uri=uri, - range=item["range"], - absolutePath=absolute_path, - relativePath=relative_file_path, - ) - item["location"] = tree_location - location = item["location"] - if "absolutePath" not in location: - location["absolutePath"] = absolute_path - if "relativePath" not in location: - location["relativePath"] = relative_file_path - - item["body"] = self.create_symbol_body(item, factory=body_factory) - - # handle missing selectionRange - if "selectionRange" not in item: - if "range" in item: - item["selectionRange"] = item["range"] - else: - item["selectionRange"] = item["location"]["range"] - - return item - - def convert_symbols_with_common_parent( - symbols: list[DocumentSymbol] | list[SymbolInformation], - parent: ls_types.UnifiedSymbolInformation | None, - ) -> list[ls_types.UnifiedSymbolInformation]: - """ - Converts the given symbols into UnifiedSymbolInformation with proper parent-child relationships, - adding overload indices for symbols with the same name under the same parent. - """ - # apply name normalization and count occurrences of each symbol name - total_name_counts: dict[str, int] = defaultdict(lambda: 0) - for symbol in symbols: - name = self._normalize_symbol_name(symbol, relative_file_path=relative_file_path) - symbol["name"] = name - total_name_counts[name] += 1 - - # convert symbols to the unified representation and - # * add overload indices where necessary - # * ensure that the "parent" field is set correctly - name_counts: dict[str, int] = defaultdict(lambda: 0) - unified_symbols = [] - for symbol in symbols: - usymbol = convert_to_unified_symbol(symbol) - if total_name_counts[usymbol["name"]] > 1: - usymbol["overload_idx"] = name_counts[usymbol["name"]] - name_counts[usymbol["name"]] += 1 - usymbol["parent"] = parent - if "children" in usymbol: - usymbol["children"] = convert_symbols_with_common_parent(usymbol["children"], usymbol) # type: ignore - else: - usymbol["children"] = [] - unified_symbols.append(usymbol) - return unified_symbols - - unified_root_symbols = convert_symbols_with_common_parent(root_symbols, None) - document_symbols = DocumentSymbols(unified_root_symbols) - - # update cache - log.debug("Updating cached document symbols for %s", relative_file_path) - self._document_symbols_cache[cache_key] = (file_data.content_hash, document_symbols) - self._document_symbols_cache_is_modified = True - - return document_symbols - - def request_full_symbol_tree(self, within_relative_path: str | None = None) -> list[ls_types.UnifiedSymbolInformation]: - """ - Will go through all files in the project or within a relative path and build a tree of symbols. - Note: this may be slow the first time it is called, especially if `within_relative_path` is not used to restrict the search. - - For each file, a symbol of kind File (2) will be created. For directories, a symbol of kind Package (4) will be created. - All symbols will have a children attribute, thereby representing the tree structure of all symbols in the project - that are within the repository. - All symbols except the root packages will have a parent attribute. - Will ignore directories starting with '.', language-specific defaults - and user-configured directories (e.g. from .gitignore). - - :param within_relative_path: pass a relative path to only consider symbols within this path. - If a file is passed, only the symbols within this file will be considered. - If a directory is passed, all files within this directory will be considered. - :return: A list of root symbols representing the top-level packages/modules in the project. - """ - - # Helper function to recursively process directories - def process_directory(abs_dir_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_dir_path = os.path.realpath(abs_dir_path) - - rel_dir_path: str | None - try: - rel_dir_path = str(Path(abs_dir_path).relative_to(self.repository_root_path)) - except ValueError: # not relative to repository root - rel_dir_path = None - if rel_dir_path and self.is_ignored_path(rel_dir_path): - log.debug("Skipping directory: %s (because it should be ignored)", rel_dir_path) - return [] - - result = [] - try: - contained_dir_or_file_names = os.listdir(abs_dir_path) - except OSError: - return [] - - # Create package symbol for directory - package_symbol = ls_types.UnifiedSymbolInformation( - name=os.path.basename(abs_dir_path), - kind=ls_types.SymbolKind.Package, - location=ls_types.Location( - uri=str(pathlib.Path(abs_dir_path).as_uri()), - range={"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 0}}, - absolutePath=str(abs_dir_path), - relativePath=str(Path(abs_dir_path).resolve().relative_to(self.repository_root_path)), - ), - children=[], - ) - result.append(package_symbol) - - for contained_dir_or_file_name in contained_dir_or_file_names: - contained_dir_or_file_abs_path = os.path.join(abs_dir_path, contained_dir_or_file_name) - - # obtain relative path - try: - contained_dir_or_file_rel_path = str( - Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path) - ) - except ValueError as e: - # Typically happens when the path is not under the repository root (e.g., symlink pointing outside) - log.warning( - "Skipping path %s; likely outside of the repository root %s [cause: %s]", - contained_dir_or_file_abs_path, - self.repository_root_path, - e, - ) - continue - - if self.is_ignored_path(contained_dir_or_file_rel_path): - log.debug("Skipping item: %s (because it should be ignored)", contained_dir_or_file_rel_path) - continue - - if os.path.isdir(contained_dir_or_file_abs_path): - child_symbols = process_directory(contained_dir_or_file_abs_path) - package_symbol["children"].extend(child_symbols) - for child in child_symbols: - child["parent"] = package_symbol - - elif os.path.isfile(contained_dir_or_file_abs_path): - with self._open_file_context(contained_dir_or_file_rel_path, open_in_ls=False) as file_data: - document_symbols = self.request_document_symbols(contained_dir_or_file_rel_path, file_data) - file_root_nodes = document_symbols.root_symbols - - # Create file symbol, link with children - file_range = self._get_range_from_file_content(file_data.contents) - file_symbol = ls_types.UnifiedSymbolInformation( - name=os.path.splitext(contained_dir_or_file_name)[0], - kind=ls_types.SymbolKind.File, - range=file_range, - selectionRange=file_range, - location=ls_types.Location( - uri=str(pathlib.Path(contained_dir_or_file_abs_path).as_uri()), - range=file_range, - absolutePath=str(contained_dir_or_file_abs_path), - relativePath=str(Path(contained_dir_or_file_abs_path).resolve().relative_to(self.repository_root_path)), - ), - children=file_root_nodes, - parent=package_symbol, - ) - for child in file_root_nodes: - child["parent"] = file_symbol - - # Link file symbol with package - package_symbol["children"].append(file_symbol) - - # TODO: Not sure if this is actually still needed given recent changes to relative path handling - def fix_relative_path(nodes: list[ls_types.UnifiedSymbolInformation]) -> None: - for node in nodes: - if "location" in node and "relativePath" in node["location"]: - path = Path(node["location"]["relativePath"]) # type: ignore - if path.is_absolute(): - try: - path = path.relative_to(self.repository_root_path) - node["location"]["relativePath"] = str(path) - except Exception: - pass - if "children" in node: - fix_relative_path(node["children"]) - - fix_relative_path(file_root_nodes) - - return result - - if within_relative_path: - within_abs_path = os.path.join(self.repository_root_path, within_relative_path) - if not os.path.exists(within_abs_path): - raise FileNotFoundError(f"File or directory not found: {within_abs_path}") - if self.is_ignored_path(within_relative_path): - raise ValueError(f"Explicitly requested symbols in '{within_relative_path}' while the path is ignored") - if os.path.isfile(within_abs_path): - root_nodes = self.request_document_symbols(within_relative_path).root_symbols - return root_nodes - else: - self.PathWorkspaceStatus.from_abs_resolved_path(Path(within_abs_path).resolve(), self).check_within_workspace_or_raise() - return process_directory(within_abs_path) - else: - full_result = [] - for root in self.config.get_absolute_workspace_folders(self.repository_root_path): - full_result.extend(process_directory(root)) - return full_result - - @staticmethod - def _get_range_from_file_content(file_content: str) -> ls_types.Range: - """ - Get the range for the given file. - """ - lines = file_content.split("\n") - end_line = len(lines) - end_column = len(lines[-1]) - return ls_types.Range(start=ls_types.Position(line=0, character=0), end=ls_types.Position(line=end_line, character=end_column)) - - def request_dir_overview(self, relative_dir_path: str) -> dict[str, list[UnifiedSymbolInformation]]: - """ - :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. - """ - symbol_tree = self.request_full_symbol_tree(relative_dir_path) - # Initialize result dictionary - result: dict[str, list[UnifiedSymbolInformation]] = defaultdict(list) - - # Helper function to process a symbol and its children - def process_symbol(symbol: ls_types.UnifiedSymbolInformation) -> None: - if symbol["kind"] == ls_types.SymbolKind.File: - # For file symbols, process their children (top-level symbols) - for child in symbol["children"]: - # Handle cross-platform path resolution (fixes Docker/macOS path issues) - absolute_path = Path(child["location"]["absolutePath"]).resolve() - repository_root = Path(self.repository_root_path).resolve() - - # Try pathlib first, fallback to alternative approach if paths are incompatible - try: - path = absolute_path.relative_to(repository_root) - except ValueError: - # If paths are from different roots (e.g., /workspaces vs /Users), - # use the relativePath from location if available, or extract from absolutePath - if "relativePath" in child["location"] and child["location"]["relativePath"]: - path = Path(child["location"]["relativePath"]) - else: - # Extract relative path by finding common structure - # Example: /workspaces/.../test_repo/file.py -> test_repo/file.py - path_parts = absolute_path.parts - - # Find the last common part or use a fallback - if "test_repo" in path_parts: - test_repo_idx = path_parts.index("test_repo") - path = Path(*path_parts[test_repo_idx:]) - else: - # Last resort: use filename only - path = Path(absolute_path.name) - result[str(path)].append(child) - # For package/directory symbols, process their children - for child in symbol["children"]: - process_symbol(child) - - # Process each root symbol - for root in symbol_tree: - process_symbol(root) - return result - - def request_document_overview(self, relative_file_path: str) -> list[UnifiedSymbolInformation]: - """ - :return: the top-level symbols in the given file. - """ - return self.request_document_symbols(relative_file_path).root_symbols - - def request_overview(self, within_relative_path: str) -> dict[str, list[UnifiedSymbolInformation]]: - """ - An overview of all symbols in the given file or directory. - Raises a ValueError if a path to an ignored file is passed. - - :param within_relative_path: the relative path to the file or directory to get the overview of. - :return: A mapping of all relative paths analyzed to lists of top-level symbols in the corresponding file. - """ - abs_path = (Path(self.repository_root_path) / within_relative_path).resolve() - if not abs_path.exists(): - raise FileNotFoundError(f"File or directory not found: {abs_path}") - - if abs_path.is_file(): - if self.is_ignored_path(within_relative_path): - raise ValueError(f"The explicitly passed file {within_relative_path} is ignored, not returning overview.") - symbols_overview = self.request_document_overview(within_relative_path) - return {within_relative_path: symbols_overview} - else: - return self.request_dir_overview(within_relative_path) - - def request_hover( - self, relative_file_path: str, line: int, column: int, file_buffer: LSPFileBuffer | None = None - ) -> ls_types.Hover | None: - """ - Raise a [textDocument/hover](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_hover) request to the Language Server - to find the hover information at the given line and column in the given file. Wait for the response and return the result. - - :param relative_file_path: The relative path of the file that has the hover information - :param line: The line number of the symbol - :param column: The column number of the symbol - :param file_buffer: The file buffer to use for the request. If not provided, the file will be read from disk. - Can be used for optimizing number of file reads in downstream code - """ - with self._open_file_context(relative_file_path, file_buffer=file_buffer) as fb: - return self._request_hover(fb, line, column) - - def _request_hover(self, file_buffer: LSPFileBuffer, line: int, column: int) -> ls_types.Hover | None: - """ - Performs the actual hover request. - """ - response = self.server.send.hover( - { - "textDocument": {"uri": file_buffer.uri}, - "position": { - "line": line, - "character": column, - }, - } - ) - - if response is None: - return None - - assert isinstance(response, dict) - contents = response.get("contents") - if not contents: - return None - if isinstance(contents, dict) and not contents.get("value"): - return None - return ls_types.Hover(**response) # type: ignore - - def request_signature_help(self, relative_file_path: str, line: int, column: int) -> ls_types.SignatureHelp | None: - """ - Raise a [textDocument/signatureHelp](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_signatureHelp) - request to the Language Server to find the signature help at the given line and column in the given file. - Note: contrary to `hover`, this only returns something on the position of a *call* and not on a symbol definition. - This means for Serena's purposes, this method is not particularly useful. The result is also fairly verbose (but well structured). - - :param relative_file_path: The relative path of the file that has the signature help - :param line: The line number of the symbol - :param column: The column number of the symbol - - :return None - """ - with self.open_file(relative_file_path): - response = self.server.send.signature_help( - { - "textDocument": {"uri": self._resolve_file_uri(relative_file_path)}, - "position": { - "line": line, - "character": column, - }, - } - ) - - if response is None: - return None - - assert isinstance(response, dict) - - return ls_types.SignatureHelp(**response) # type: ignore - - def create_symbol_body( - self, - symbol: ls_types.UnifiedSymbolInformation, - factory: SymbolBodyFactory | None = None, - ) -> SymbolBody: - if factory is None: - assert "relativePath" in symbol["location"] - with self._open_file_context(symbol["location"]["relativePath"]) as f: # type: ignore - factory = SymbolBodyFactory(f) - - return factory.create_symbol_body(symbol) - - 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]: - """ - Finds all symbols that reference the symbol at the given location. - This is similar to request_references but filters to only include symbols - (functions, methods, classes, etc.) that reference the target symbol. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_imports: whether to also include imports as references. - Unfortunately, the LSP does not have an import type, so the references corresponding to imports - will not be easily distinguishable from definitions. - :param include_self: whether to include the references that is the "input symbol" itself. - Only has an effect if the relative_file_path, line and column point to a symbol, for example a definition. - :param include_body: whether to include the body of the symbols in the result. - :param include_file_symbols: whether to include references that are file symbols. This - is often a fallback mechanism for when the reference cannot be resolved to a symbol. - :return: List of objects containing the symbol and the location of the reference. - """ - if not self.server_started: - log.error("request_referencing_symbols called before Language Server started") - raise SolidLSPException("Language Server not started") - - # First, get all references to the symbol - references = self.request_references(relative_file_path, line, column) - if not references: - return [] - - debug_enabled = log.isEnabledFor(logging.DEBUG) - t0_loop = perf_counter() if debug_enabled else 0.0 - # For each reference, find the containing symbol - result = [] - incoming_symbol = None - for ref in references: - ref_path = ref["relativePath"] - assert ref_path is not None - ref_line = ref["range"]["start"]["line"] - ref_col = ref["range"]["start"]["character"] - - with self.open_file(ref_path) as file_data: - body_factory = SymbolBodyFactory(file_data) - - # Get the containing symbol for this reference - containing_symbol = self.request_containing_symbol( - ref_path, ref_line, ref_col, include_body=include_body, body_factory=body_factory - ) - if containing_symbol is None: - # TODO: HORRIBLE HACK! I don't know how to do it better for now... - # THIS IS BOUND TO BREAK IN MANY CASES! IT IS ALSO SPECIFIC TO PYTHON! - # Background: - # When a variable is used to change something, like - # - # instance = MyClass() - # instance.status = "new status" - # - # we can't find the containing symbol for the reference to `status` - # since there is no container on the line of the reference - # The hack is to try to find a variable symbol in the containing module - # by using the text of the reference to find the variable name (In a very heuristic way) - # and then look for a symbol with that name and kind Variable - ref_text = file_data.contents.split("\n")[ref_line] - if "." in ref_text: - containing_symbol_name = ref_text.split(".")[0] - document_symbols = self.request_document_symbols(ref_path) - for symbol in document_symbols.iter_symbols(): - if symbol["name"] == containing_symbol_name and symbol["kind"] == ls_types.SymbolKind.Variable: - containing_symbol = copy(symbol) - containing_symbol["location"] = ref - containing_symbol["range"] = ref["range"] - break - - # We failed retrieving the symbol, falling back to creating a file symbol - if containing_symbol is None and include_file_symbols: - log.warning(f"Could not find containing symbol for {ref_path}:{ref_line}:{ref_col}. Returning file symbol instead") - fileRange = self._get_range_from_file_content(file_data.contents) - ref_abs_path = os.path.join(self.repository_root_path, ref_path) - if self._path_contains_dots(ref_path): - ref_abs_path = str(pathlib.Path(ref_abs_path).resolve()) - location = ls_types.Location( - uri=self._resolve_file_uri(ref_path), - range=fileRange, - absolutePath=ref_abs_path, - relativePath=ref_path, - ) - name = os.path.splitext(os.path.basename(ref_path))[0] - - containing_symbol = ls_types.UnifiedSymbolInformation( - kind=ls_types.SymbolKind.File, - range=fileRange, - selectionRange=fileRange, - location=location, - name=name, - children=[], - ) - - if include_body: - containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) - - if containing_symbol is None or (not include_file_symbols and containing_symbol["kind"] == ls_types.SymbolKind.File): - continue - - assert "location" in containing_symbol - assert "selectionRange" in containing_symbol - - # Checking for self-reference - if ( - containing_symbol["location"]["relativePath"] == relative_file_path - and containing_symbol["selectionRange"]["start"]["line"] == ref_line - and containing_symbol["selectionRange"]["start"]["character"] == ref_col - ): - incoming_symbol = containing_symbol - if include_self: - result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) - continue - log.debug(f"Found self-reference for {incoming_symbol['name']}, skipping it since {include_self=}") - continue - - # checking whether reference is an import - # This is neither really safe nor elegant, but if we don't do it, - # there is no way to distinguish between definitions and imports as import is not a symbol-type - # and we get the type referenced symbol resulting from imports... - if ( - not include_imports - and incoming_symbol is not None - and containing_symbol["name"] == incoming_symbol["name"] - and containing_symbol["kind"] == incoming_symbol["kind"] - ): - log.debug( - f"Found import of referenced symbol {incoming_symbol['name']}" - f"in {containing_symbol['location']['relativePath']}, skipping" - ) - continue - - result.append(ReferenceInSymbol(symbol=containing_symbol, line=ref_line, character=ref_col)) - - if debug_enabled: - loop_elapsed_ms = (perf_counter() - t0_loop) * 1000 - unique_files = len({r.symbol["location"]["relativePath"] for r in result}) - log.debug( - "perf: request_referencing_symbols path=%s loop_elapsed_ms=%.2f ref_count=%d result_count=%d unique_files=%d", - relative_file_path, - loop_elapsed_ms, - len(references), - len(result), - unique_files, - ) - - return result - - def request_containing_symbol( - self, - relative_file_path: str, - line: int, - column: int | None = None, - strict: bool = False, - include_body: bool = False, - body_factory: SymbolBodyFactory | None = None, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the first symbol containing the position for the given file. - For Python, container symbols are considered to be those with kinds corresponding to - functions, methods, or classes (typically: Function (12), Method (6), Class (5)). - - The method operates as follows: - - Request the document symbols for the file. - - Filter symbols to those that start at or before the given line. - - From these, first look for symbols whose range contains the (line, column). - - If one or more symbols contain the position, return the one with the greatest starting position - (i.e. the innermost container). - - If none (strictly) contain the position, return the symbol with the greatest starting position - among those above the given line. - - If no container candidates are found, return None. - - :param relative_file_path: The relative path to the Python file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column (also called character). If not passed, the lookup will be based - only on the line. - :param strict: If True, the position must be strictly within the range of the symbol. - Setting to True is useful for example for finding the parent of a symbol, as with strict=False, - and the line pointing to a symbol itself, the containing symbol will be the symbol itself - (and not the parent). - :param include_body: Whether to include the body of the symbol in the result. - :return: The container symbol (if found) or None. - """ - # checking if the line is empty, unfortunately ugly and duplicating code, but I don't want to refactor - with self.open_file(relative_file_path): - absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - absolute_file_path = str(pathlib.Path(absolute_file_path).resolve()) - content = FileUtils.read_file(absolute_file_path, self._encoding) - if content.split("\n")[line].strip() == "": - log.error(f"Passing empty lines to request_container_symbol is currently not supported, {relative_file_path=}, {line=}") - return None - - document_symbols = self.request_document_symbols(relative_file_path) - - # make jedi and pyright api compatible - # the former has no location, the later has no range - # we will just always add location of the desired format to all symbols - for symbol in document_symbols.iter_symbols(): - if "location" not in symbol: - range = symbol["range"] - location = ls_types.Location( - uri=f"file:/{absolute_file_path}", - range=range, - absolutePath=absolute_file_path, - relativePath=relative_file_path, - ) - symbol["location"] = location - else: - location = symbol["location"] - assert "range" in location - location["absolutePath"] = absolute_file_path - location["relativePath"] = relative_file_path - location["uri"] = Path(absolute_file_path).as_uri() - - # Allowed container kinds, currently only for Python - container_symbol_kinds = {ls_types.SymbolKind.Method, ls_types.SymbolKind.Function, ls_types.SymbolKind.Class} - - def is_position_in_range(line: int, range_d: ls_types.Range) -> bool: - start = range_d["start"] - end = range_d["end"] - - column_condition = True - if strict: - line_condition = end["line"] >= line > start["line"] - if column is not None and line == start["line"]: - column_condition = column > start["character"] - else: - line_condition = end["line"] >= line >= start["line"] - if column is not None and line == start["line"]: - column_condition = column >= start["character"] - return line_condition and column_condition - - # Only consider containers that are not one-liners (otherwise we may get imports) - candidate_containers = [ - s - for s in document_symbols.iter_symbols() - if s["kind"] in container_symbol_kinds and s["location"]["range"]["start"]["line"] != s["location"]["range"]["end"]["line"] - ] - var_containers = [s for s in document_symbols.iter_symbols() if s["kind"] == ls_types.SymbolKind.Variable] - candidate_containers.extend(var_containers) - - if not candidate_containers: - return None - - # From the candidates, find those whose range contains the given position. - containing_symbols = [] - for symbol in candidate_containers: - s_range = symbol["location"]["range"] - if not is_position_in_range(line, s_range): - continue - containing_symbols.append(symbol) - - if containing_symbols: - # Return the one with the greatest starting position (i.e. the innermost container). - containing_symbol = max(containing_symbols, key=lambda s: s["location"]["range"]["start"]["line"]) - if include_body: - containing_symbol["body"] = self.create_symbol_body(containing_symbol, factory=body_factory) - return containing_symbol - else: - return None - - def request_container_of_symbol( - self, symbol: ls_types.UnifiedSymbolInformation, include_body: bool = False - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the container of the given symbol if there is one. If the parent attribute is present, the parent is returned - without further searching. - - :param symbol: The symbol to find the container of. - :param include_body: whether to include the body of the symbol in the result. - :return: The container of the given symbol or None if no container is found. - """ - if "parent" in symbol: - return symbol["parent"] - assert "location" in symbol, f"Symbol {symbol} has no location and no parent attribute" - return self.request_containing_symbol( - symbol["location"]["relativePath"], # type: ignore - symbol["location"]["range"]["start"]["line"], - symbol["location"]["range"]["start"]["character"], - strict=True, - include_body=include_body, - ) - - def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: - """ - Select the preferred definition from a list of definitions. - - When multiple definitions are returned (e.g., both source and type definitions), - this method determines which one to use. The base implementation simply returns - the first definition. - - Subclasses can override this method to implement language-specific preferences. - For example, TypeScript/Vue servers may prefer source files over .d.ts type - definition files. - - :param definitions: A non-empty list of definition locations. - :return: The preferred definition location. - """ - return definitions[0] - - def _get_document_symbols_with_locations(self, relative_file_path: str) -> list[ls_types.UnifiedSymbolInformation]: - abs_path = os.path.join(self.repository_root_path, relative_file_path) - if self._path_contains_dots(relative_file_path): - abs_path = str(pathlib.Path(abs_path).resolve()) - document_symbols = self.request_document_symbols(relative_file_path) - symbols = list(document_symbols.iter_symbols()) - - # Make SymbolInformation and DocumentSymbol shapes consistent by ensuring every - # symbol exposes a normalized location/range in the current workspace. - for symbol in symbols: - location = symbol["location"] - location["absolutePath"] = abs_path - location["relativePath"] = relative_file_path - location["uri"] = self._resolve_file_uri(relative_file_path) - return symbols - - @staticmethod - def _position_matches_range(range_d: ls_types.Range, line: int, column: int | None = None) -> bool: - start = range_d["start"] - end = range_d["end"] - if not (start["line"] <= line <= end["line"]): - return False - if column is None: - return True - if line == start["line"] and column < start["character"]: - return False - if line == end["line"] and column > end["character"]: - return False - return True - - @staticmethod - def _symbol_match_sort_key(symbol: ls_types.UnifiedSymbolInformation, match_priority: int) -> tuple[int, int, int, int, int]: - location = symbol["location"] - symbol_range = location["range"] - start = symbol_range["start"] - end = symbol_range["end"] - line_span = end["line"] - start["line"] - character_span = end["character"] - start["character"] if line_span == 0 else end["character"] - return match_priority, line_span, character_span, start["line"], start["character"] - - def _request_symbol_at_location( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - body_factory: SymbolBodyFactory | None = None, - ) -> ls_types.UnifiedSymbolInformation | None: - candidates: list[tuple[tuple[int, int, int, int, int], ls_types.UnifiedSymbolInformation]] = [] - for symbol in self._get_document_symbols_with_locations(relative_file_path): - location = symbol["location"] - symbol_range = location["range"] - selection_range = symbol.get("selectionRange") or symbol_range - - match_priority: int | None = None - if self._position_matches_range(selection_range, line, column): - match_priority = 0 - elif self._position_matches_range(symbol_range, line, column): - match_priority = 1 - else: - selection_start = selection_range["start"] - symbol_start = symbol_range["start"] - if (selection_start["line"], selection_start["character"]) == (line, column): - match_priority = 2 - elif (symbol_start["line"], symbol_start["character"]) == (line, column): - match_priority = 3 - elif selection_start["line"] == line and column <= selection_start["character"]: - match_priority = 4 - elif symbol_start["line"] == line and column <= symbol_start["character"]: - match_priority = 5 - - if match_priority is None: - continue - candidates.append((self._symbol_match_sort_key(symbol, match_priority), symbol)) - - if not candidates: - return None - - candidates.sort(key=lambda item: item[0]) - best_symbol = candidates[0][1] - if include_body: - best_symbol["body"] = self.create_symbol_body(best_symbol, factory=body_factory) - return best_symbol - - @staticmethod - def _iter_symbol_descendants(symbol: ls_types.UnifiedSymbolInformation) -> Iterator[ls_types.UnifiedSymbolInformation]: - """Yield descendant symbols in depth-first order.""" - for child in symbol.get("children", []): - yield child - yield from SolidLanguageServer._iter_symbol_descendants(child) - - def _refine_implementing_symbol( - self, - target_symbol: ls_types.UnifiedSymbolInformation | None, - implementing_symbol: ls_types.UnifiedSymbolInformation, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation: - """Resolve member-level implementation symbols when the LS returns a containing type.""" - if target_symbol is None: - return implementing_symbol - - target_kind = target_symbol["kind"] - if target_kind not in (ls_types.SymbolKind.Method, ls_types.SymbolKind.Function): - return implementing_symbol - - if implementing_symbol["kind"] == target_kind and implementing_symbol.get("name") == target_symbol.get("name"): - return implementing_symbol - - candidate_descendants: list[ls_types.UnifiedSymbolInformation] = [] - for descendant in self._iter_symbol_descendants(implementing_symbol): - if descendant.get("name") != target_symbol.get("name"): - continue - if descendant["kind"] != target_kind: - continue - candidate_descendants.append(descendant) - - if not candidate_descendants: - return implementing_symbol - - refined_symbol = min( - candidate_descendants, - key=lambda symbol: self._symbol_match_sort_key(symbol, match_priority=0), - ) - if include_body: - refined_symbol["body"] = self.create_symbol_body(refined_symbol) - return refined_symbol - - def request_symbol_at_location( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the symbol at the given position, preferring exact identifier matches and otherwise - falling back to the innermost symbol whose body contains the position. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbol in the result. - :return: The symbol at the given location, or None if no symbol could be resolved. - """ - if not self.server_started: - log.error("request_symbol_at_location called before language server started") - raise SolidLSPException("Language Server not started") - return self._request_symbol_at_location(relative_file_path, line, column, include_body=include_body) - - def request_defining_symbol( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> ls_types.UnifiedSymbolInformation | None: - """ - Finds the symbol that defines the symbol at the given location. - - This method first finds the definition of the symbol at the given position, - then retrieves the full symbol information for that definition. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbol in the result. - :return: The symbol information for the definition, or None if not found. - """ - if not self.server_started: - log.error("request_defining_symbol called before language server started") - raise SolidLSPException("Language Server not started") - - # Get the definition location(s) - definitions = self.request_definition(relative_file_path, line, column) - if not definitions: - return None - - # Select the preferred definition (subclasses can override _get_preferred_definition) - definition = self._get_preferred_definition(definitions) - def_path = definition["relativePath"] - if def_path is None: - return None - def_line = definition["range"]["start"]["line"] - def_col = definition["range"]["start"]["character"] - - return self._request_symbol_at_location( - def_path, - def_line, - def_col, - include_body=include_body, - ) - - def request_implementing_symbols( - self, - relative_file_path: str, - line: int, - column: int, - include_body: bool = False, - ) -> list[ls_types.UnifiedSymbolInformation]: - """ - Finds the symbols that implement the symbol at the given location. - - This method first finds implementation locations for the symbol at the given position, - then retrieves the full symbol information for each implementation and de-duplicates - results that map to the same containing symbol. - - :param relative_file_path: The relative path to the file. - :param line: The 0-indexed line number. - :param column: The 0-indexed column number. - :param include_body: whether to include the body of the symbols in the result. - :return: The symbol information for each implementation. - """ - if not self.server_started: - log.error("request_implementing_symbols called before language server started") - raise SolidLSPException("Language Server not started") - - target_symbol = self._request_symbol_at_location(relative_file_path, line, column, include_body=False) - implementation_locations = self.request_implementation(relative_file_path, line, column) - if not implementation_locations: - return [] - - result: list[ls_types.UnifiedSymbolInformation] = [] - seen_keys: set[tuple[str, int, int, int]] = set() - for implementation in implementation_locations: - implementation_path = implementation["relativePath"] - assert implementation_path is not None - implementation_line = implementation["range"]["start"]["line"] - implementation_col = implementation["range"]["start"]["character"] - implementing_symbol = self._request_symbol_at_location( - implementation_path, - implementation_line, - implementation_col, - include_body=include_body, - body_factory=None, - ) - if implementing_symbol is None: - continue - implementing_symbol = self._refine_implementing_symbol(target_symbol, implementing_symbol, include_body=include_body) - if "location" not in implementing_symbol: - continue - symbol_location = implementing_symbol["location"] - symbol_key = ( - cast(str, symbol_location["relativePath"]), - symbol_location["range"]["start"]["line"], - symbol_location["range"]["start"]["character"], - implementing_symbol["kind"], - ) - if symbol_key in seen_keys: - continue - seen_keys.add(symbol_key) - result.append(implementing_symbol) - - return result - - def _document_symbols_cache_fingerprint(self) -> Hashable | None: - """ - Returns a fingerprint of any language server-specific aspects that result in changes - to the high-level document symbol information. - - Language servers must implement this method/change the return value - * whenever they change the `request_document_symbols` implementation to modify the returned content - * are reconfigured in a way that affects the returned contents (e.g. context-specific configuration - such as build flags or environment variables); configuration options can, in such cases, be - hashed together to produce a single fingerprint value. - - Whenever the value changes, the document symbols cache will be invalidated and re-populated. - - The value must be hashable and safe for inclusion in cache version tuples. - E.g. use an integer, a string or a tuple of integers/strings. - - For example, if there is a single aspect being considered, use an integer to reflect the version - of this aspect (incrementing it whenever the implementation changes). - If multiple versioned aspects exist, use a tuple of versions, etc. - """ - return None - - def _document_symbols_cache_version(self) -> Hashable: - """ - Return the version for the document symbols cache. - - Incorporates cache context fingerprint if provided by the language server. - """ - fingerprint = self._document_symbols_cache_fingerprint() - if fingerprint is not None: - return (self.DOCUMENT_SYMBOL_CACHE_VERSION, fingerprint) - return self.DOCUMENT_SYMBOL_CACHE_VERSION - - def _save_raw_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME - - if not self._raw_document_symbols_cache_is_modified: - log.debug("No changes to raw document symbols cache, skipping save") - return - - log.info("Saving updated raw document symbols cache to %s", cache_file) - try: - save_cache(str(cache_file), self._raw_document_symbols_cache_version(), self._raw_document_symbols_cache) - self._raw_document_symbols_cache_is_modified = False - except Exception as e: - log.error( - "Failed to save raw document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", - cache_file, - e, - ) - - def _raw_document_symbols_cache_version(self) -> tuple[Hashable, ...]: - base_version: tuple[Hashable, ...] = (self.RAW_DOCUMENT_SYMBOLS_CACHE_VERSION, self._ls_specific_raw_document_symbols_cache_version) - fingerprint = self._document_symbols_cache_fingerprint() - if fingerprint is not None: - return (*base_version, fingerprint) - return base_version - - def _load_raw_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME - - if not cache_file.exists(): - # check for legacy cache to load to migrate - legacy_cache_file = self.cache_dir / self.RAW_DOCUMENT_SYMBOL_CACHE_FILENAME_LEGACY_FALLBACK - if legacy_cache_file.exists(): - try: - legacy_cache: dict[ - str, tuple[str, tuple[list[ls_types.UnifiedSymbolInformation], list[ls_types.UnifiedSymbolInformation]]] - ] = load_pickle(legacy_cache_file) - log.info("Migrating legacy document symbols cache with %d entries", len(legacy_cache)) - num_symbols_migrated = 0 - migrated_cache = {} - for cache_key, (file_hash, (all_symbols, root_symbols)) in legacy_cache.items(): - if cache_key.endswith("-True"): # include_body=True - new_cache_key = cache_key[:-5] - migrated_cache[new_cache_key] = (file_hash, root_symbols) - num_symbols_migrated += len(all_symbols) - log.info("Migrated %d document symbols from legacy cache", num_symbols_migrated) - self._raw_document_symbols_cache = migrated_cache - self._raw_document_symbols_cache_is_modified = True - self._save_raw_document_symbols_cache() - legacy_cache_file.unlink() - return - except Exception as e: - log.error("Error during cache migration: %s", e) - return - - # load existing cache (if any) - if cache_file.exists(): - log.info("Loading document symbols cache from %s", cache_file) - try: - saved_cache = load_cache(str(cache_file), self._raw_document_symbols_cache_version()) - if saved_cache is not None: - self._raw_document_symbols_cache = saved_cache - log.info(f"Loaded {len(self._raw_document_symbols_cache)} entries from raw document symbols cache.") - except Exception as e: - # cache can become corrupt, so just skip loading it - log.warning( - "Failed to load raw document symbols cache from %s (%s); Ignoring cache.", - cache_file, - e, - ) - - def _save_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME - - if not self._document_symbols_cache_is_modified: - log.debug("No changes to document symbols cache, skipping save") - return - - log.info("Saving updated document symbols cache to %s", cache_file) - try: - save_cache(str(cache_file), self._document_symbols_cache_version(), self._document_symbols_cache) - self._document_symbols_cache_is_modified = False - except Exception as e: - log.error( - "Failed to save document symbols cache to %s: %s. Note: this may have resulted in a corrupted cache file.", - cache_file, - e, - ) - - def _load_document_symbols_cache(self) -> None: - cache_file = self.cache_dir / self.DOCUMENT_SYMBOL_CACHE_FILENAME - if cache_file.exists(): - log.info("Loading document symbols cache from %s", cache_file) - try: - saved_cache = load_cache(str(cache_file), self._document_symbols_cache_version()) - if saved_cache is not None: - self._document_symbols_cache = saved_cache - log.info(f"Loaded {len(self._document_symbols_cache)} entries from document symbols cache.") - except Exception as e: - # cache can become corrupt, so just skip loading it - log.warning( - "Failed to load document symbols cache from %s (%s); Ignoring cache.", - cache_file, - e, - ) - - def save_cache(self) -> None: - self._save_raw_document_symbols_cache() - self._save_document_symbols_cache() - - def request_workspace_symbol(self, query: str) -> list[ls_types.UnifiedSymbolInformation] | None: - """ - Raise a [workspace/symbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#workspace_symbol) request to the Language Server - to find symbols across the whole workspace. Wait for the response and return the result. - - :param query: The query string to filter symbols by - - :return: A list of matching symbols - """ - response = self.server.send.workspace_symbol({"query": query}) - if response is None: - return None - - assert isinstance(response, list) - - ret: list[ls_types.UnifiedSymbolInformation] = [] - for item in response: - assert isinstance(item, dict) - - assert LSPConstants.NAME in item - assert LSPConstants.KIND in item - assert LSPConstants.LOCATION in item - - ret.append(ls_types.UnifiedSymbolInformation(**item)) # type: ignore - - return ret - - def request_rename_symbol_edit( - self, - relative_file_path: str, - line: int, - column: int, - new_name: str, - ) -> ls_types.WorkspaceEdit | None: - """ - Retrieve a WorkspaceEdit for renaming the symbol at the given location to the new name. - Does not apply the edit, just retrieves it. In order to actually rename the symbol, call apply_workspace_edit. - - :param relative_file_path: The relative path to the file containing the symbol - :param line: The 0-indexed line number of the symbol - :param column: The 0-indexed column number of the symbol - :param new_name: The new name for the symbol - :return: A WorkspaceEdit containing the changes needed to rename the symbol, or None if rename is not supported - """ - params = RenameParams( - textDocument=ls_types.TextDocumentIdentifier(uri=self._resolve_file_uri(relative_file_path)), - position=ls_types.Position(line=line, character=column), - newName=new_name, - ) - - with self.open_file(relative_file_path): - return self.server.send.rename(params) - - def apply_text_edits_to_file(self, relative_path: str, edits: list[ls_types.TextEdit]) -> None: - """ - Apply a list of text edits to a file. - - :param relative_path: The relative path of the file to edit - :param edits: List of TextEdit dictionaries to apply - """ - with self.open_file(relative_path): - # Sort edits by position (latest first) to avoid position shifts - sorted_edits = sorted(edits, key=lambda e: (e["range"]["start"]["line"], e["range"]["start"]["character"]), reverse=True) - - for edit in sorted_edits: - start_pos = ls_types.Position(line=edit["range"]["start"]["line"], character=edit["range"]["start"]["character"]) - end_pos = ls_types.Position(line=edit["range"]["end"]["line"], character=edit["range"]["end"]["character"]) - - # Delete the old text and insert the new text - self.delete_text_between_positions(relative_path, start_pos, end_pos) - self.insert_text_at_position(relative_path, start_pos["line"], start_pos["character"], edit["newText"]) - - def start(self) -> "SolidLanguageServer": - """ - Starts the language server process and connects to it. Call shutdown when ready. - - :return: self for method chaining - """ - log.info(f"Starting language server with language {self.language_server.language} for {self.language_server.repository_root_path}") - self.server_started = True - self._start_server() - return self - - def stop(self, shutdown_timeout: float = 2.0) -> None: - """ - Stops the language server process. - This function never raises an exception (any exceptions during shutdown are logged). - - :param shutdown_timeout: time, in seconds, to wait for the server to shutdown gracefully before killing it - """ - try: - self.server.stop(timeout=shutdown_timeout) - except Exception as e: - log.warning(f"Exception while shutting down language server: {e}") - finally: - self.server_started = False - - @property - def language_server(self) -> Self: - return self - - @property - def handler(self) -> LanguageServerInterface: - """Access the underlying language server handler. - - Useful for advanced operations like sending custom commands - or registering notification handlers. - """ - return self.server - - def is_running(self) -> bool: - return self.server.is_running() - - def _create_initialize_params_builder(self) -> InitializeParamsBuilder: - return DefaultInitializeParamsBuilder(self) - - @abstractmethod - def _create_base_initialize_params(self) -> dict | InitializeParams: - """ - Subclasses should override this method to provide server-specific InitializeParams settings, - which provide the basis for the InitializeParams object sent to the language server during initialization. - - The returned dictionary is passed to the builder constructed by _create_initialize_params_builder() - in order to create the final InitializeParams object. - - The default builder implementation already sets the following keys, so implementations of this method - should not set them: - - - processId - - rootPath - - rootUri - - clientInfo - - workspaceFolders - - :return: the base InitializeParams settings - """ - - def _create_initialize_params(self) -> InitializeParams: - """ - Create the InitializeParams object to send to the language server during initialization. - """ - return self._create_initialize_params_builder().with_base_options(self._create_base_initialize_params()).build() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index f296fee6..8731204d 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -8,7 +8,7 @@ import shutil import threading from abc import ABC, abstractmethod from collections import defaultdict -from collections.abc import Callable, Hashable, Iterator, Sequence +from collections.abc import Callable, Hashable, Iterator from contextlib import contextmanager from copy import copy from dataclasses import dataclass @@ -17,12 +17,19 @@ from time import monotonic, perf_counter, sleep from typing import Any, Self, Union, cast import pathspec +from sensai.util.helper import mark_used from sensai.util.pickle import getstate, load_pickle from sensai.util.string import ToStringMixin from serena.util.file_system import match_path from serena.util.text_utils import MatchedConsecutiveLines from solidlsp import ls_types +from solidlsp.dependency_provider import ( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderBaseCommand, + LanguageServerDependencyProviderSinglePath, + LanguageServerDependencyProviderUvx, +) from solidlsp.initialize_params import DefaultInitializeParamsBuilder, InitializeParamsBuilder from solidlsp.ls_config import FilenameMatcher, Language, LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException @@ -59,6 +66,14 @@ The `DocumentSymbol` is the preferred type, but the legacy type `SymbolInformati log = logging.getLogger(__name__) +# backward compatibility (support old imports of these classes from this module) +mark_used( + LanguageServerDependencyProvider, + LanguageServerDependencyProviderBaseCommand, + LanguageServerDependencyProviderUvx, + LanguageServerDependencyProviderSinglePath, +) + _debug_enabled = log.isEnabledFor(logging.DEBUG) """Serves as a flag that triggers additional computation when debug logging is enabled.""" @@ -272,243 +287,6 @@ class DocumentSymbols: return self._all_symbols, self.root_symbols -class LanguageServerDependencyProvider(ABC): - """ - Prepares dependencies for a language server (if any), ultimately enabling the launch command to be constructed - and optionally providing environment variables that are necessary for the execution. - """ - - def __init__(self, custom_settings: SolidLSPSettings.CustomLSSettings, ls_resources_dir: str): - """ - :param custom_settings: the (user-provided) language server-specific settings - :param ls_resources_dir: the directory in which data for the language-server shall be stored - (this is already specific to the concrete language server, i.e. no further subdirectory is needed) - """ - self._custom_settings = custom_settings - self._ls_resources_dir = ls_resources_dir - - @abstractmethod - def create_launch_command(self) -> list[str]: - """ - Creates the launch command for this language server, potentially downloading and installing dependencies - beforehand. - - :return: the launch command as a list containing the executable and its arguments - """ - - def create_launch_command_env(self) -> dict[str, str]: - """ - Provides environment variables to be set when executing the launch command. - - This method is intended to be overridden by subclasses that need to set variables. - - :return: a mapping for variable names to values - """ - return {} - - -class LanguageServerDependencyProviderBaseCommand(LanguageServerDependencyProvider, ABC): - """ - Special case of a dependency provider, where the launch command is constructed from a base command. - - The user can configure aspects of the launch command in LS-specific settings: - - * ``ls_base_cmd`: overrides the base command - * ``ls_path``: overrides the path of the language server's core dependency (e.g. its executable or a JAR file), - from which the base command is formed, bypassing Serena's managed installation - * ``ls_args``: overrides the arguments that are added to the base command in order to form the launch command - * ``ls_extra_args``: additional arguments to append to the launch command - """ - - @abstractmethod - def _create_default_base_command(self) -> list[str]: - """ - Obtains the default base command for this language server, potentially downloading and installing dependencies - beforehand. - - Note: The user can override the base command, so this will only be run if no custom base command is provided. - - :return: the base command as a list containing the executable and its arguments - """ - - @abstractmethod - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - """ - Adds any additional arguments to the base command to create the final launch command. - - :param base_command: the base command - :return: the extended command - """ - - def create_launch_command(self) -> list[str]: - # obtain base command - base_command = self._custom_settings.get("ls_base_cmd", None) - if base_command is not None and not isinstance(base_command, list): - log.warning("The 'ls_base_cmd' setting should be a list of strings. Ignoring the provided value: %s", base_command) - base_command = None - if base_command is None: - ls_path = self._custom_settings.get("ls_path", None) - if ls_path is not None: - base_command = [ls_path] - else: - # default case: base command is constructed by the provider implementation - base_command = self._create_default_base_command() - - # create launch command from base command - ls_args = self._custom_settings.get("ls_args") - if ls_args is not None: - if not isinstance(ls_args, list): - log.warning("The 'ls_args' setting should be a list of strings. Ignoring the provided value: %s", ls_args) - ls_args = None - if ls_args is not None: - cmd = list(base_command) + ls_args - else: - cmd = self._create_launch_command_from_base_command(list(base_command)) - - # add user-provided extra arguments (if any) - ls_extra_args = self._custom_settings.get("ls_extra_args", []) - if ls_extra_args: - if not isinstance(ls_extra_args, list): - log.warning("The 'ls_extra_args' setting should be a list of strings. Ignoring the provided value: %s", ls_extra_args) - else: - cmd = cmd + ls_extra_args - - return cmd - - -class LanguageServerDependencyProviderSinglePath(LanguageServerDependencyProviderBaseCommand, ABC): - """ - Special case of a dependency provider, where there is a single core dependency which provides - the basis for the launch command. - - The core dependency's path can be overridden by the user in LS-specific settings (SerenaConfig) - via the key "ls_path". If the user provides the key, the specified path is used directly. - Otherwise, the provider implementation is called to get or install the core dependency. - - Note: The inheritance from the BaseCommand class serves to allow user overrides to be handled - centrally, but implementations of this class do not necessarily follow the principle that - the base command is strictly extended. - Yet incompatibility arises only if (a) the user overrides the base command with a command that - has arguments, and (b) the implementation of this class does not construct the launch command - by appending arguments to the core dependency's path. - """ - - @abstractmethod - def _get_or_install_core_dependency(self) -> str: - """ - Gets the language server's core path, potentially installing dependencies beforehand. - - :return: the core dependency's path (e.g. executable, jar, etc.) - """ - - @abstractmethod - def _create_launch_command(self, core_path: str) -> list[str]: - """ - :param core_path: path to the core dependency - :return: the launch command as a list containing the executable and its arguments - """ - - def _create_default_base_command(self) -> list[str]: - # We treat the core path as the only element of the base command, - # noting that the construction of the launch command from the base command - # is not necessarily to append arguments only. - core_path = self._get_or_install_core_dependency() - return [core_path] - - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - core_path = base_command[0] - cmd = self._create_launch_command(core_path) - if len(base_command) == 1: - # This is the regular case, where the base command consists only of the core - # dependency's path, and the launch command is constructed from it. - return cmd - else: - # In this case, the user has overridden the base command via LS-specific settings - # with a command that has arguments and therefore does not map cleanly to - # the single path assumption. However, in special cases where the full command - # was constructed by appending arguments to the core dependency's path, - # we simply assume that the provided base command can be substituted. - if cmd[0] == core_path: - return base_command + cmd[1:] - else: - raise ValueError("Language server base launch command with arguments unsupported") - - -class LanguageServerDependencyProviderUvx(LanguageServerDependencyProviderBaseCommand): - """ - Dependency provider for language servers distributed as a PyPI package, run on demand via ``uvx`` / ``uv x``. - - The pinned package version can be overridden by the user via the LS-specific setting given by - ``version_setting_key``. Alternatively, the LS-specific setting "ls_path" can be set to the path of an - already-installed language server executable, in which case it is launched directly, bypassing uv entirely. - """ - - DEFAULT_UVX_PYTHON_VERSION = "3.13" - - def __init__( - self, - custom_settings: "SolidLSPSettings.CustomLSSettings", - ls_resources_dir: str, - *, - package: str, - entrypoint: str, - default_version: str, - version_setting_key: str, - extra_args: Sequence[str] = (), - ): - """ - :param package: the PyPI package name (e.g. ``"pyright"``) - :param entrypoint: the console script provided by the package (e.g. ``"pyright-langserver"``) - :param default_version: the package version to pin unless overridden - :param version_setting_key: the LS-specific setting key through which the user can override the version - :param extra_args: arguments appended after the entrypoint (e.g. ``("--stdio",)``) - """ - super().__init__(custom_settings, ls_resources_dir) - self._package = package - self._entrypoint = entrypoint - self._default_version = default_version - self._version_setting_key = version_setting_key - self._extra_args = tuple(extra_args) - - @staticmethod - def _build_uvx_base_command( - package: str, - version: str, - entrypoint: str, - python_version: str = DEFAULT_UVX_PYTHON_VERSION, - ) -> list[str]: - """Build a command that runs a pinned PyPI package's console script on demand via ``uvx`` / ``uv x``. - - Resolution order: - 1. Prefer ``uvx`` (env var ``UVX`` or PATH lookup). - 2. Fall back to ``uv x`` if only ``uv`` is on PATH. - 3. Raise ``RuntimeError`` if neither is available. - - :param package: PyPI package name (e.g. ``"pyright"``). - :param version: Pinned package version. - :param entrypoint: Console script provided by the package (e.g. ``"pyright-langserver"``). - :param python_version: Python interpreter version passed via ``-p`` (uv will fetch it if missing). - """ - base_args = ["-p", python_version, "--from", f"{package}=={version}", entrypoint] - - uvx_path = os.environ.get("UVX") or shutil.which("uvx") - if uvx_path is not None: - return [uvx_path, *base_args] - - uv_path = shutil.which("uv") - if uv_path is not None: - return [uv_path, "x", *base_args] - - raise RuntimeError("Could not find 'uvx' or 'uv' in PATH. Install uv (https://docs.astral.sh/uv/).") - - def _create_default_base_command(self): - version = self._custom_settings.get(self._version_setting_key, self._default_version) - return self._build_uvx_base_command(self._package, version, self._entrypoint) - - def _create_launch_command_from_base_command(self, base_command: list[str]) -> list[str]: - return base_command + list(self._extra_args) - - class SolidLanguageServer(ABC): """ High-level abstraction for language server interaction, which wraps the underlying low-level LSP interface From 29575785eba28463938663ec63957b2b0b9d157b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 20:07:50 +0200 Subject: [PATCH 51/57] Clean up LanguageServerInterface creation --- .../language_servers/godot_language_server.py | 10 ++---- src/solidlsp/ls.py | 34 +++++++++++-------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/solidlsp/language_servers/godot_language_server.py b/src/solidlsp/language_servers/godot_language_server.py index d0ea8c3f..fe3df8b5 100644 --- a/src/solidlsp/language_servers/godot_language_server.py +++ b/src/solidlsp/language_servers/godot_language_server.py @@ -13,7 +13,7 @@ from collections.abc import Callable from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_process import LanguageServerInterface, TCPConnectionInfo, TCPLanguageServer -from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo, StringDict +from solidlsp.lsp_protocol_handler.server import StringDict from solidlsp.settings import SolidLSPSettings log = logging.getLogger(__name__) @@ -47,7 +47,7 @@ class GodotLanguageServer(SolidLanguageServer): self._configured_request_timeout: float | None = None # Dummy ProcessLaunchInfo — _create_language_server_interface() ignores it - super().__init__(config, repository_root_path, ProcessLaunchInfo(cmd=""), "gdscript", solidlsp_settings) + super().__init__(config, repository_root_path, None, "gdscript", solidlsp_settings) def set_request_timeout(self, timeout: float | None) -> None: """Cap the timeout at the value configured in ls_specific_settings, if set.""" @@ -73,11 +73,7 @@ class GodotLanguageServer(SolidLanguageServer): pass return None - def _create_language_server_interface( - self, - process_launch_info: ProcessLaunchInfo, - logging_fn: Callable[[str, str, StringDict | str], None] | None, - ) -> LanguageServerInterface: + def _create_language_server_interface(self, logging_fn: Callable[[str, str, StringDict | str], None] | None) -> LanguageServerInterface: settings = self._custom_settings or {} port = settings.get("port", DEFAULT_GODOT_LS_PORT) request_timeout = settings.get("request_timeout", DEFAULT_GODOT_REQUEST_TIMEOUT) diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index f296fee6..e38888ba 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -733,14 +733,10 @@ class SolidLanguageServer(ABC): else: logging_fn = None - # create the LanguageServerHandler, which provides the functionality to start the language server and communicate with it, - # preparing the launch command beforehand + # create the low-level server interface, potentially installing dependencies and launching a subprocess + self._process_launch_info: ProcessLaunchInfo | None = process_launch_info self._dependency_provider: LanguageServerDependencyProvider | None = None - if process_launch_info is 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 = self._create_language_server_interface(process_launch_info, logging_fn) + self.server = self._create_language_server_interface(logging_fn) """ the low-level language server interface """ @@ -786,15 +782,23 @@ class SolidLanguageServer(ABC): f"{self.__class__.__name__} must implement _create_dependency_provider() or pass process_launch_info to __init__()" ) + def _get_dependency_provider(self) -> LanguageServerDependencyProvider: + if self._dependency_provider is None: + self._dependency_provider = self._create_dependency_provider() + return self._dependency_provider + def _create_process_launch_info(self) -> ProcessLaunchInfo: - assert self._dependency_provider is not None - cmd = self._dependency_provider.create_launch_command() - env = self._dependency_provider.create_launch_command_env() + dependency_provider = self._get_dependency_provider() + cmd = dependency_provider.create_launch_command() + env = dependency_provider.create_launch_command_env() return ProcessLaunchInfo(cmd=cmd, cwd=self.repository_root_path, env=env) - def _create_language_server_interface( - self, process_launch_info: ProcessLaunchInfo, logging_fn: Callable[[str, str, StringDict | str], None] | None - ) -> LanguageServerInterface: + def _get_process_launch_info(self) -> ProcessLaunchInfo: + if self._process_launch_info is None: + self._process_launch_info = self._create_process_launch_info() + return self._process_launch_info + + def _create_language_server_interface(self, logging_fn: Callable[[str, str, StringDict | str], None] | None) -> LanguageServerInterface: """ Creates the low-level language server interface for LSP communication. @@ -803,10 +807,12 @@ class SolidLanguageServer(ABC): The default implementation creates a StdioLanguageServer, but subclasses can override this method to create a different type of interface if needed. - :param process_launch_info: process launch information :param logging_fn: the trace logging function :return: the interface """ + language_id = self.language_id + process_launch_info = self._get_process_launch_info() + log.debug(f"Creating language server instance with {language_id=} and {process_launch_info}") return StdioLanguageServer( process_launch_info, language=self.language, From 9208a3ae877d4371ff3df6d6c5c2df8019155a8a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 16:25:54 +0200 Subject: [PATCH 52/57] Improve line/column computations, adhering to LSP standard * Add helper class TextStepper to facilitate computations * Reimplement TextUtils methods based on TextStepper * Adhere to LSP definition of newlines being defined by either "\n", "\r\n" or "\r" on its own. Previously, only "\n" was considered. * Add util method TextUtils.split_lines, allowing usages of Python's splitlines to be replaced --- CHANGELOG.md | 1 + src/solidlsp/ls_utils.py | 206 ++++++++++++++++++++++++++----- test/solidlsp/test_text_utils.py | 32 +++++ 3 files changed, 205 insertions(+), 34 deletions(-) create mode 100644 test/solidlsp/test_text_utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4952c3bf..92adb837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Status of the `main` branch. Changes prior to the next official version change w - During project creation, language composition percentages are now computed relative to the total number of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage computation. + - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks) * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0f47a3e7..321827bf 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -31,6 +31,114 @@ class InvalidTextLocationError(Exception): pass +class TextStepper: + def __init__(self, chars: str): + self._chars = chars + self._len = len(chars) + self.line = 0 + """ + the current 0-based line index + """ + self.col = 0 + """ + the current 0-based column index + """ + self.idx = 0 + """ + the current 0-based index in the full text. + The index specifies the next character to be processed, i.e. if this is + the length of the text, then the end of the text has been reached. + It specifies a location in the same way as a cursor insertion position: + cursor at the very beginning (idx=0) means insert before the first character. + """ + self._is_newline = False + """ + whether the last step was a line break + """ + self._prev_line_start_idx = 0 + """ + start index of the last fully processed line (inclusive) + """ + self._prev_line_end_idx = 0 + """ + end of the last fully processed line (exclusive), excluding newline characters + """ + self._line_start_idx = 0 + """ + start of the current, not yet completed line (inclusive) + """ + + def _get_char(self, idx: int) -> str | None: + if idx < 0 or idx >= self._len: + return None + return self._chars[idx] + + def step(self) -> bool: + """ + Processes the next character/newline sequence in the text + + :return: True if processing was possible, False if the end of the text was reached + """ + if self.idx >= self._len: + return False + + # process next character/newline sequence + # Note: LSP defines that a newline is given by either "\n", "\r\n", or "\r" on its own + # Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocuments + idx_before = self.idx + c = self._chars[self.idx] + self.idx += 1 + is_newline = False + if c == "\n": + is_newline = True + elif c == "\r": + is_newline = True + if self._get_char(self.idx) == "\n": + self.idx += 1 # skip an additional character + + self._is_newline = is_newline + if is_newline: + self.line += 1 + self.col = 0 + self._prev_line_end_idx = idx_before + self._prev_line_start_idx = self._line_start_idx + self._line_start_idx = self.idx + else: + self.col += 1 + return True + + def process_all(self): + """ + Processes all characters in the text, updating the line and column numbers accordingly. + """ + while self.step(): + pass + + def _get_last_line(self, with_end: bool) -> str: + """ + Returns the last line processed, optionally including the newline character(s) at the end + """ + start_idx = self._prev_line_start_idx + end_idx = self._prev_line_end_idx if not with_end else self._line_start_idx + return self._chars[start_idx:end_idx] + + def process_all_gather_lines(self, with_ends: bool) -> list[str]: + """ + Processes all characters in the text and returns a list of lines + + :param with_ends: whether to include the newline character(s) at the end of each line + :return: the list of lines + """ + lines = [] + while self.step(): + if self._is_newline: + lines.append(self._get_last_line(with_end=with_ends)) + # add the last line (which was not followed by a newline), even if empty + last_line = self._chars[self._line_start_idx :] + lines.append(last_line) + return lines + + class TextUtils: """ Utilities for text operations. @@ -39,54 +147,68 @@ class TextUtils: @staticmethod def get_line_col_from_index(text: str, index: int) -> tuple[int, int]: """ - Returns the zero-indexed line and column number of the given index in the given text + :param text: the text in which the index is to be located + :param index: the 0-based index in the text + :return: a tuple (0-based line number, 0-based column number) corresponding to the index in the text """ - l = 0 - c = 0 - idx = 0 - while idx < index: - if text[idx] == "\n": - l += 1 - c = 0 - else: - c += 1 - idx += 1 + text_stepper = TextStepper(text) + while text_stepper.idx < index: + if not text_stepper.step(): + raise InvalidTextLocationError + return text_stepper.line, text_stepper.col - return l, c + @classmethod + def get_line_from_index(cls, text: str, index: int) -> int: + """ + :param text: the text in which the index is to be located + :param index: the 0-based index in the text + :return: the 0-based line number corresponding to the index in the text + """ + return cls.get_line_col_from_index(text, index)[0] @staticmethod def get_index_from_line_col(text: str, line: int, col: int) -> int: """ - Returns the index of the given zero-indexed line and column number in the given text + :param text: the text in which the coordinates are to be located + :param line: the 0-based line number + :param col: the 0-based column number + :return: the corresponding 0-based index in the text """ - idx = 0 - while line > 0: - if idx >= len(text): + text_stepper = TextStepper(text) + while text_stepper.line < line: + if not text_stepper.step(): raise InvalidTextLocationError - if text[idx] == "\n": - line -= 1 - idx += 1 - idx += col + idx = text_stepper.idx + col return idx @staticmethod def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: """ - Utility function to get the position of the cursor after inserting text at a given line and column. + :param l: the 0-based line number before the edit + :param c: the 0-based column number before the edit + :param text_to_be_inserted: the text that was inserted at the given position + :return: the updated 0-based line and column numbers after the edit (end of insertion) """ - num_newlines_in_gen_text = text_to_be_inserted.count("\n") - if num_newlines_in_gen_text > 0: - l += num_newlines_in_gen_text - c = len(text_to_be_inserted.split("\n")[-1]) + text_stepper = TextStepper(text_to_be_inserted) + text_stepper.process_all() + if text_stepper.line > 0: + l += text_stepper.line + c = text_stepper.col else: - c += len(text_to_be_inserted) - return (l, c) + c += text_stepper.col + return l, c @staticmethod def delete_text_between_positions(text: str, start_line: int, start_col: int, end_line: int, end_col: int) -> tuple[str, str]: """ Deletes the text between the given start and end positions. - Returns the modified text and the deleted text. + + :param text: the original text + :param start_line: the 0-based line number of the start position + :param start_col: the 0-based column number of the start position + :param end_line: the 0-based line number of the end position + :param end_col: the 0-based column number of the end position + :return: a tuple containing the modified text and the deleted text """ del_start_idx = TextUtils.get_index_from_line_col(text, start_line, start_col) del_end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) @@ -98,13 +220,21 @@ class TextUtils: @staticmethod def insert_text_at_position(text: str, line: int, col: int, text_to_be_inserted: str) -> tuple[str, int, int]: """ - Inserts the given text at the given line and column. - Returns the modified text and the new line and column. + Inserts the given text at the given position and returns the + + :param text: the original text + :param line: the 0-based line number where the text should be inserted + :param col: the 0-based column number where the text should be inserted + :param text_to_be_inserted: the text to be inserted + :return: a tuple containing the modified text, the updated line number, and the updated column number + (position after the inserted text) """ try: change_index = TextUtils.get_index_from_line_col(text, line, col) except InvalidTextLocationError: - num_lines_in_text = text.count("\n") + 1 + text_stepper = TextStepper(text) + text_stepper.process_all() + num_lines_in_text = text_stepper.line + 1 max_line = num_lines_in_text - 1 if line == max_line + 1 and col == 0: # trying to insert at new line after full text # insert at end, adding missing newline @@ -125,14 +255,22 @@ class TextUtils: end_idx = TextUtils.get_index_from_line_col(text, end_line, end_col) return text[start_idx:end_idx] - @staticmethod - def get_text_in_lines_range(text: str, start_line: int, end_line: int) -> str: + @classmethod + def get_text_in_lines_range(cls, text: str, start_line: int, end_line: int) -> str: """ Returns the text encompassed by the given start and end lines (inclusive). """ - lines = text.splitlines(keepends=True) + lines = cls.split_lines(text, with_ends=True) return "".join(lines[start_line : end_line + 1]) + @staticmethod + def split_lines(text: str, with_ends: bool = False) -> list[str]: + """ + Splits the given text into lines, optionally including the newline character(s) at the end of each line. + """ + text_stepper = TextStepper(text) + return text_stepper.process_all_gather_lines(with_ends=with_ends) + class PathUtils: """ diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py new file mode 100644 index 00000000..fa937242 --- /dev/null +++ b/test/solidlsp/test_text_utils.py @@ -0,0 +1,32 @@ +from solidlsp.ls_utils import TextUtils + + +class TestTextUtils: + LINE = "012" + TEXT = LINE + "\n" + LINE + "\r\n" + LINE + "\r" + LINE + + def test_split_lines(self): + lines = TextUtils.split_lines(self.TEXT, with_ends=False) + assert len(lines) == 4 + for line in lines: + assert line == self.LINE + + def test_split_lines_with_ends(self): + lines = TextUtils.split_lines(self.TEXT, with_ends=True) + assert len(lines) == 4 + for i, line in enumerate(lines): + assert line[: len(self.LINE)] == self.LINE + for i, ending in enumerate(["\n", "\r\n", "\r", ""]): + assert lines[i][len(self.LINE) :] == ending + + def test_line_col_from_idx(self): + assert TextUtils.get_line_col_from_index(self.TEXT, 0) == (0, 0) + assert TextUtils.get_line_col_from_index(self.TEXT, 1) == (0, 1) + assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 1) == (1, 1) + assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 3 + 2 + 1) == (2, 1) + + def test_idx_from_line_col(self): + assert TextUtils.get_index_from_line_col(self.TEXT, 0, 0) == 0 + assert TextUtils.get_index_from_line_col(self.TEXT, 0, 1) == 1 + assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1 + assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1 From 97c9b8c05de975b5bb064d5102667850fcfa711a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 16:39:59 +0200 Subject: [PATCH 53/57] Apply TextUtils, ensuring consistent line numbering * in search_text * in MatchedConsecutiveLines.from_file_contents --- CHANGELOG.md | 4 +++- src/serena/util/text_utils.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92adb837..6c0005ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,9 @@ Status of the `main` branch. Changes prior to the next official version change w - During project creation, language composition percentages are now computed relative to the total number of recognised source files instead of all files, i.e. unrecognised files are ignored in the percentage computation. - - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks) + - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered + - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; + affects `search_text` used by `search_for_pattern` tool (reported in #1684) * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 992c2437..8deb9856 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -100,7 +100,7 @@ class MatchedConsecutiveLines: def from_file_contents( cls, file_contents: str, line: int, context_lines_before: int = 0, context_lines_after: int = 0, source_file_path: str | None = None ) -> Self: - line_contents = file_contents.split("\n") + line_contents = TextUtils.split_lines(file_contents) start_lineno = max(0, line - context_lines_before) end_lineno = min(len(line_contents) - 1, line + context_lines_after) text_lines: list[TextLine] = [] @@ -169,7 +169,7 @@ def search_text( raise ValueError("Pass either content or source_file_path") matches = [] - lines = content.splitlines() + lines = TextUtils.split_lines(content) total_lines = len(lines) # Convert pattern to a compiled regex if it's a string @@ -185,8 +185,8 @@ def search_text( end_pos = match.end() # Find the line numbers for the start and end positions - start_line_num = content[:start_pos].count("\n") - end_line_num = content[:end_pos].count("\n") + start_line_num = TextUtils.get_line_from_index(content, start_pos) + end_line_num = TextUtils.get_line_from_index(content, end_pos) # Calculate the range of lines to include in the context context_start = max(0, start_line_num - context_lines_before) From d4e99dc00309b6489b422295844b5a104a5efa0e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Tue, 14 Jul 2026 23:09:49 +0200 Subject: [PATCH 54/57] Improve performance of TextStepper, stepping line by line instead of character by character, delegating the scan for line breaks to native string search (~15-100x speedup for a full pass over typical source files) --- src/solidlsp/ls_utils.py | 100 +++++++++++++++++++------------ test/solidlsp/test_text_utils.py | 2 + 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 321827bf..0f8ef140 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -51,19 +51,19 @@ class TextStepper: It specifies a location in the same way as a cursor insertion position: cursor at the very beginning (idx=0) means insert before the first character. """ - self._is_newline = False + self.is_newline = False """ - whether the last step was a line break + whether the last step processed a full line ending in a line break """ - self._prev_line_start_idx = 0 + self.prev_line_start_idx = 0 """ start index of the last fully processed line (inclusive) """ - self._prev_line_end_idx = 0 + self.prev_line_end_idx = 0 """ end of the last fully processed line (exclusive), excluding newline characters """ - self._line_start_idx = 0 + self.line_start_idx = 0 """ start of the current, not yet completed line (inclusive) """ @@ -73,53 +73,61 @@ class TextStepper: return None return self._chars[idx] - def step(self) -> bool: + def step_line(self) -> bool: """ - Processes the next character/newline sequence in the text + Processes the next line in the text, advancing past the next newline sequence or, + if no further newline is present, to the end of the text. - :return: True if processing was possible, False if the end of the text was reached + :return: True if processing was possible, False if the end of the text had already been reached + (idx not advanced) """ if self.idx >= self._len: return False - # process next character/newline sequence + # find the next newline sequence # Note: LSP defines that a newline is given by either "\n", "\r\n", or "\r" on its own # Reference: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocuments - idx_before = self.idx - c = self._chars[self.idx] - self.idx += 1 - is_newline = False - if c == "\n": - is_newline = True - elif c == "\r": - is_newline = True - if self._get_char(self.idx) == "\n": - self.idx += 1 # skip an additional character + # The search for "\r" is bounded by the position of the next "\n". + lf_idx = self._chars.find("\n", self.idx) + cr_idx = self._chars.find("\r", self.idx, lf_idx if lf_idx != -1 else self._len) + if cr_idx != -1: + newline_start_idx = cr_idx + newline_end_idx = cr_idx + 2 if self._get_char(cr_idx + 1) == "\n" else cr_idx + 1 + elif lf_idx != -1: + newline_start_idx = lf_idx + newline_end_idx = lf_idx + 1 + else: + newline_start_idx = None + newline_end_idx = None - self._is_newline = is_newline - if is_newline: + # advance past the newline sequence or, in its absence, consume the trailing line + if newline_start_idx is not None and newline_end_idx is not None: + self.idx = newline_end_idx self.line += 1 self.col = 0 - self._prev_line_end_idx = idx_before - self._prev_line_start_idx = self._line_start_idx - self._line_start_idx = self.idx + self.is_newline = True + self.prev_line_start_idx = self.line_start_idx + self.prev_line_end_idx = newline_start_idx + self.line_start_idx = newline_end_idx else: - self.col += 1 + self.idx = self._len + self.col = self._len - self.line_start_idx + self.is_newline = False return True def process_all(self): """ Processes all characters in the text, updating the line and column numbers accordingly. """ - while self.step(): + while self.step_line(): pass def _get_last_line(self, with_end: bool) -> str: """ Returns the last line processed, optionally including the newline character(s) at the end """ - start_idx = self._prev_line_start_idx - end_idx = self._prev_line_end_idx if not with_end else self._line_start_idx + start_idx = self.prev_line_start_idx + end_idx = self.prev_line_end_idx if not with_end else self.line_start_idx return self._chars[start_idx:end_idx] def process_all_gather_lines(self, with_ends: bool) -> list[str]: @@ -130,12 +138,14 @@ class TextStepper: :return: the list of lines """ lines = [] - while self.step(): - if self._is_newline: + while self.step_line(): + if self.is_newline: lines.append(self._get_last_line(with_end=with_ends)) + # add the last line (which was not followed by a newline), even if empty - last_line = self._chars[self._line_start_idx :] + last_line = self._chars[self.line_start_idx :] lines.append(last_line) + return lines @@ -151,10 +161,25 @@ class TextUtils: :param index: the 0-based index in the text :return: a tuple (0-based line number, 0-based column number) corresponding to the index in the text """ + # step over full lines; once the line containing the index has been processed, compute + # the position from the difference to the respective line's start index text_stepper = TextStepper(text) - while text_stepper.idx < index: - if not text_stepper.step(): - raise InvalidTextLocationError + while text_stepper.step_line(): + if text_stepper.idx > index: + if text_stepper.is_newline: + # position was stepped over as part of the previous line + if index > text_stepper.prev_line_end_idx: + # edge case: the index points into a multi-character newline sequence ("\r\n"); + # map this to the beginning of the following line + return text_stepper.line, 0 + return text_stepper.line - 1, index - text_stepper.prev_line_start_idx + else: + # position was stepped over as part of the current line (which was not followed by a newline) + return text_stepper.line, index - text_stepper.line_start_idx + + # handle the case where the end of the text was reached without stepping past the index + if index > text_stepper.idx: + raise InvalidTextLocationError return text_stepper.line, text_stepper.col @classmethod @@ -174,12 +199,13 @@ class TextUtils: :param col: the 0-based column number :return: the corresponding 0-based index in the text """ + # step over full lines until the requested line is reached text_stepper = TextStepper(text) while text_stepper.line < line: - if not text_stepper.step(): + if not text_stepper.step_line(): raise InvalidTextLocationError - idx = text_stepper.idx + col - return idx + + return text_stepper.line_start_idx + col @staticmethod def _get_updated_position_from_line_and_column_and_edit(l: int, c: int, text_to_be_inserted: str) -> tuple[int, int]: diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index fa937242..4204f7e9 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -20,6 +20,8 @@ class TestTextUtils: assert lines[i][len(self.LINE) :] == ending def test_line_col_from_idx(self): + assert TextUtils.get_line_col_from_index(self.LINE, 0) == (0, 0) + assert TextUtils.get_line_col_from_index(self.LINE, 1) == (0, 1) assert TextUtils.get_line_col_from_index(self.TEXT, 0) == (0, 0) assert TextUtils.get_line_col_from_index(self.TEXT, 1) == (0, 1) assert TextUtils.get_line_col_from_index(self.TEXT, 3 + 1 + 1) == (1, 1) From c80d76abe9f3619ad18e7e83e937f03da0cb3a4b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 15 Jul 2026 18:45:18 +0200 Subject: [PATCH 55/57] Fix edge case in insert_text_at_position when inserting in line beyond actual content (insertion coordinates were not adjusted to true end coordinates) --- src/solidlsp/ls_utils.py | 5 ++++- test/solidlsp/test_text_utils.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 0f8ef140..774d3741 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -263,9 +263,12 @@ class TextUtils: num_lines_in_text = text_stepper.line + 1 max_line = num_lines_in_text - 1 if line == max_line + 1 and col == 0: # trying to insert at new line after full text - # insert at end, adding missing newline + # insert at end, adding missing newline and adjusting insertion position + # to the actual end coordinates of the text change_index = len(text) text_to_be_inserted = "\n" + text_to_be_inserted + line = text_stepper.line + col = text_stepper.col else: raise new_text = text[:change_index] + text_to_be_inserted + text[change_index:] diff --git a/test/solidlsp/test_text_utils.py b/test/solidlsp/test_text_utils.py index 4204f7e9..062125c4 100644 --- a/test/solidlsp/test_text_utils.py +++ b/test/solidlsp/test_text_utils.py @@ -32,3 +32,19 @@ class TestTextUtils: assert TextUtils.get_index_from_line_col(self.TEXT, 0, 1) == 1 assert TextUtils.get_index_from_line_col(self.TEXT, 1, 1) == 3 + 1 + 1 assert TextUtils.get_index_from_line_col(self.TEXT, 2, 1) == 3 + 1 + 3 + 2 + 1 + + def test_insert_text_at_index(self): + insertion = "XXX" + new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 0, 1, insertion) + assert (l, c) == (0, 1 + len(insertion)) + assert new_text.startswith("0XXX12") + + def test_insert_text_in_next_line_beyond_content(self): + """ + Test inserting text at a line index 1 beyond the actual number of lines. + This case is specifically handled as an edge case in the implementation. + """ + insertion = "XXX" + new_text, l, c = TextUtils.insert_text_at_position(self.TEXT, 4, 0, insertion) + assert (l, c) == (4, len(insertion)) + assert new_text == self.TEXT + "\n" + insertion From ba5ade0d4e7929c54f0c42361302b4f28d329e20 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 15 Jul 2026 18:50:07 +0200 Subject: [PATCH 56/57] Add location information when raising InvalidTextLocationError --- src/solidlsp/ls_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/solidlsp/ls_utils.py b/src/solidlsp/ls_utils.py index 774d3741..eafc3b37 100644 --- a/src/solidlsp/ls_utils.py +++ b/src/solidlsp/ls_utils.py @@ -179,7 +179,7 @@ class TextUtils: # handle the case where the end of the text was reached without stepping past the index if index > text_stepper.idx: - raise InvalidTextLocationError + raise InvalidTextLocationError(f"{index=}") return text_stepper.line, text_stepper.col @classmethod @@ -203,7 +203,7 @@ class TextUtils: text_stepper = TextStepper(text) while text_stepper.line < line: if not text_stepper.step_line(): - raise InvalidTextLocationError + raise InvalidTextLocationError("{line=}, {col=}") return text_stepper.line_start_idx + col From e79f170d30da6b97363ed8e25dd9d2ec593da57e Mon Sep 17 00:00:00 2001 From: zerone0x <39543393+zerone0x@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:04:07 +0000 Subject: [PATCH 57/57] fix: reject malformed brace globs Fixes #1690 Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 1 + src/serena/util/text_utils.py | 12 ++++++++---- test/serena/test_text_utils.py | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67618fb..edeb6bcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Fix: Use LSP-compliant line splitting ("\n", "\r\n" and "\r" can define line breaks); previously only "\n" considered - Fix: Apply consistent line splitting across tools, uniformly applying the LSP splitting semantics; affects `search_text` used by `search_for_pattern` tool (reported in #1684) + - Fix: glob pattern expansion in `expand_braces` did not terminate with empty or unbalanced braces #1690 * CLI: - Fix `--project-from-cwd` hijacking git worktrees nested under a Serena project. `find_project_root` diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 8deb9856..e9aebf76 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -216,18 +216,22 @@ def expand_braces(pattern: str) -> list[str]: Handles multiple brace sets as well. """ patterns = [pattern] - while any("{" in p for p in patterns): + while any("{" in p or "}" in p for p in patterns): new_patterns = [] for p in patterns: - match = re.search(r"\{([^{}]+)\}", p) + match = re.search(r"\{([^{}]*)\}", p) if match: + options_expr = match.group(1) + if not options_expr: + raise ValueError(f"Invalid glob brace expression in {pattern!r}: empty braces are not allowed") + prefix = p[: match.start()] suffix = p[match.end() :] - options = match.group(1).split(",") + options = options_expr.split(",") for option in options: new_patterns.append(f"{prefix}{option}{suffix}") else: - new_patterns.append(p) + raise ValueError(f"Invalid glob brace expression in {pattern!r}: unmatched brace") patterns = new_patterns return patterns diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 936779fc..ce63450d 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -606,6 +606,21 @@ class TestExpandBraces: assert sorted(expand_braces(pattern)) == sorted(expected) + @pytest.mark.parametrize( + "pattern", + [ + "src/{}.py", + "src/{utils,models", + "src/utils,models}.py", + ], + ) + def test_expand_braces_rejects_malformed_braces(self, pattern): + """Malformed brace globs should fail instead of looping forever.""" + from serena.util.text_utils import expand_braces + + with pytest.raises(ValueError, match="Invalid glob brace expression"): + expand_braces(pattern) + class TestMultiFileContentReplacer: FILES = [