Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions src/solidlsp/language_servers/ruby_lsp.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
- ruby_lsp_version: Override the pinned ruby-lsp gem version installed by
Serena when no project-local or global ruby-lsp is already available
(default: the bundled Serena version).
- vendor_include_paths: List of repository-relative paths under ``vendor/``
that should remain indexed by ruby-lsp. Example:
``["vendor/engines"]``. When set, Serena replaces the blanket
``**/vendor/**`` exclusion with exclusions for the other top-level
``vendor`` subdirectories it finds in the repository.
"""

import json
Expand Down Expand Up @@ -55,7 +60,6 @@ def __init__(self, config: LanguageServerConfig, repository_root_path: str, soli
def is_ignored_dirname(self, dirname: str) -> bool:
"""Override to ignore Ruby-specific directories that cause performance issues."""
ruby_ignored_dirs = [
"vendor", # Ruby vendor directory
".bundle", # Bundler cache
"tmp", # Temporary files
"log", # Log files
Expand All @@ -71,6 +75,15 @@ def is_ignored_dirname(self, dirname: str) -> bool:
]
return super().is_ignored_dirname(dirname) or dirname in ruby_ignored_dirs

def is_ignored_path(self, relative_path: str, ignore_unsupported_files: bool = True) -> bool:
"""Override to keep only configured vendor subtrees visible to Serena."""
normalized_path = pathlib.PurePosixPath(pathlib.Path(relative_path).as_posix())
if self._path_contains_vendor_directory(normalized_path):
if not self._is_included_vendor_path(normalized_path):
return True

return super().is_ignored_path(relative_path, ignore_unsupported_files)

@override
def _get_wait_time_for_cross_file_referencing(self) -> float:
"""Override to provide optimal wait time for ruby-lsp cross-file reference resolution.
Expand Down Expand Up @@ -298,13 +311,88 @@ def _detect_rails_project(repository_root_path: str) -> bool:

return False

def _get_vendor_include_roots(self) -> set[str]:
"""Return top-level vendor directory names that should remain indexed."""
vendor_include_paths = self._solidlsp_settings.get_ls_specific_settings(Language.RUBY).get("vendor_include_paths", [])
vendor_include_roots: set[str] = set()
if isinstance(vendor_include_paths, list):
for path in vendor_include_paths:
normalized_path = pathlib.PurePosixPath(str(path).strip("/"))
if len(normalized_path.parts) >= 2 and normalized_path.parts[0] == "vendor":
vendor_include_roots.add(normalized_path.parts[1])
else:
log.warning("Ignoring invalid ruby.vendor_include_paths entry %r; expected a path under vendor/.", path)
elif vendor_include_paths:
log.warning("Ignoring ruby.vendor_include_paths because it is not a list: %r", vendor_include_paths)
return vendor_include_roots

@staticmethod
def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]:
def _path_contains_vendor_directory(relative_path: pathlib.PurePosixPath) -> bool:
"""Return whether the path traverses through any vendor directory."""
return "vendor" in relative_path.parts

def _is_included_vendor_path(self, relative_path: pathlib.PurePosixPath) -> bool:
"""Return whether the path belongs to an allowlisted vendor subtree only."""
vendor_include_roots = self._get_vendor_include_roots()
if not vendor_include_roots:
return False

found_included_vendor_root = False
for index, part in enumerate(relative_path.parts):
if part != "vendor":
continue

if index != 0:
return False

if index + 1 >= len(relative_path.parts):
return False

if relative_path.parts[index + 1] not in vendor_include_roots:
return False

found_included_vendor_root = True

return found_included_vendor_root

def _get_vendor_exclude_patterns(self, repository_root_path: str) -> list[str]:
"""Return vendor-specific exclude patterns honoring allowlisted subtrees."""
vendor_include_roots = self._get_vendor_include_roots()
if not vendor_include_roots:
return ["**/vendor/**"]

vendor_patterns: set[str] = set()
repository_root = pathlib.Path(repository_root_path)
root_vendor_dir = repository_root / "vendor"

if root_vendor_dir.is_dir():
for child in root_vendor_dir.iterdir():
if child.name not in vendor_include_roots:
vendor_patterns.add(f"vendor/{child.name}/**")

for current_root, dirnames, _filenames in os.walk(repository_root_path):
current_root_path = pathlib.Path(current_root)
relative_root = current_root_path.relative_to(repository_root).as_posix()

if relative_root == ".":
continue

if current_root_path.name == "vendor":
relative_vendor_path = pathlib.PurePosixPath(relative_root)
if relative_vendor_path.parts == ("vendor",):
continue

if not self._is_included_vendor_path(relative_vendor_path):
vendor_patterns.add(f"{relative_vendor_path.as_posix()}/**")
dirnames[:] = []

return sorted(vendor_patterns)

def _get_ruby_exclude_patterns(self, repository_root_path: str) -> list[str]:
"""
Get Ruby and Rails-specific exclude patterns for better performance.
"""
base_patterns = [
"**/vendor/**", # Ruby vendor directory
"**/.bundle/**", # Bundler cache
"**/tmp/**", # Temporary files
"**/log/**", # Log files
Expand All @@ -316,6 +404,9 @@ def _get_ruby_exclude_patterns(repository_root_path: str) -> list[str]:
"**/public/assets/**", # Rails compiled assets
]

# keeping vendor/engines while excluding every other vendored subtree
base_patterns.extend(self._get_vendor_exclude_patterns(repository_root_path))

# Add Rails-specific patterns if this is a Rails project
if RubyLsp._detect_rails_project(repository_root_path):
base_patterns.extend(
Expand Down
67 changes: 67 additions & 0 deletions test/solidlsp/ruby/test_ruby_lsp_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from pathlib import Path

from solidlsp.language_servers.ruby_lsp import RubyLsp
from solidlsp.ls_config import Language
from solidlsp.settings import SolidLSPSettings


def _build_ruby_lsp(settings: SolidLSPSettings) -> RubyLsp:
language_server = RubyLsp.__new__(RubyLsp)
language_server._solidlsp_settings = settings
language_server.repository_root_path = ""
return language_server


def test_ruby_lsp_excludes_vendor_by_default(tmp_path: Path) -> None:
language_server = _build_ruby_lsp(SolidLSPSettings())

patterns = language_server._get_ruby_exclude_patterns(str(tmp_path))

assert "**/vendor/**" in patterns


def test_ruby_lsp_can_keep_vendor_engines_indexed(tmp_path: Path) -> None:
(tmp_path / "vendor" / "engines").mkdir(parents=True)
(tmp_path / "vendor" / "bundle").mkdir(parents=True)
(tmp_path / "vendor" / "cache").mkdir(parents=True)
(tmp_path / "vendor" / "engines" / "blog" / "vendor" / "bundle").mkdir(parents=True)

settings = SolidLSPSettings(ls_specific_settings={Language.RUBY: {"vendor_include_paths": ["vendor/engines"]}})
language_server = _build_ruby_lsp(settings)

patterns = language_server._get_ruby_exclude_patterns(str(tmp_path))

assert "**/vendor/**" not in patterns
assert "vendor/bundle/**" in patterns
assert "vendor/cache/**" in patterns
assert "vendor/engines/**" not in patterns
assert "vendor/engines/blog/vendor/**" in patterns


def test_ruby_lsp_ignores_non_allowlisted_vendor_paths(tmp_path: Path) -> None:
bundle_file = tmp_path / "vendor" / "bundle" / "tool.rb"
bundle_file.parent.mkdir(parents=True)
bundle_file.write_text("class Tool; end\n")

nested_vendor_file = tmp_path / "vendor" / "engines" / "blog" / "vendor" / "cache" / "tool.rb"
nested_vendor_file.parent.mkdir(parents=True)
nested_vendor_file.write_text("class Tool; end\n")

settings = SolidLSPSettings(ls_specific_settings={Language.RUBY: {"vendor_include_paths": ["vendor/engines"]}})
language_server = _build_ruby_lsp(settings)
language_server.repository_root_path = str(tmp_path)

assert language_server.is_ignored_path("vendor/bundle/tool.rb")
assert language_server.is_ignored_path("vendor/engines/blog/vendor/cache/tool.rb")


def test_ruby_lsp_keeps_allowlisted_vendor_engines_paths(tmp_path: Path) -> None:
engine_file = tmp_path / "vendor" / "engines" / "blog" / "app" / "models" / "post.rb"
engine_file.parent.mkdir(parents=True)
engine_file.write_text("class Post; end\n")

settings = SolidLSPSettings(ls_specific_settings={Language.RUBY: {"vendor_include_paths": ["vendor/engines"]}})
language_server = _build_ruby_lsp(settings)
language_server.repository_root_path = str(tmp_path)

assert not language_server.is_ignored_path("vendor/engines/blog/app/models/post.rb", ignore_unsupported_files=False)
Loading