From 5102a38fbe81bf3b10a24c0766454d98e2777a89 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 6 Jul 2025 14:06:16 +0200 Subject: [PATCH 1/6] Don't include relative_path in symbol's children dict representation --- src/serena/symbol.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 0f48bb5..8d68467 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -189,8 +189,11 @@ class SymbolLocation: if self.relative_path is not None: self.relative_path = self.relative_path.replace("/", os.path.sep) - def to_dict(self) -> dict[str, Any]: - return asdict(self) + def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]: + result = asdict(self) + if not include_relative_path: + result.pop("relative_path", None) + return result def has_position_in_file(self) -> bool: return self.relative_path is not None and self.line is not None and self.column is not None @@ -426,7 +429,8 @@ class Symbol(ToStringMixin): return result def to_dict( - self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False + self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False, + include_relative_path=True, ) -> dict[str, Any]: """ Converts the symbol to a dictionary. @@ -439,6 +443,8 @@ class Symbol(ToStringMixin): Note that the body of the children is part of the body of the parent symbol, so there is usually no need to set this to True unless you want process the output and pass the children without passing the parent body to the LM. + :param include_relative_path: whether to include the relative path of the symbol in the location + entry. Relative paths of the symbol's children are always excluded. :return: a dictionary representation of the symbol """ result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} @@ -447,7 +453,7 @@ class Symbol(ToStringMixin): result["kind"] = self.kind if location: - result["location"] = self.location.to_dict() + result["location"] = self.location.to_dict(include_relative_path=include_relative_path) body_start_line, body_end_line = self.get_body_line_numbers() result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} @@ -466,6 +472,8 @@ class Symbol(ToStringMixin): depth=depth - 1, include_body=include_children_body, include_children_body=include_children_body, + # all children have the same relative path as the parent + include_relative_path=False, ) ) return children From a24dcea427eacb705b035580a29856a3030037a3 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 6 Jul 2025 14:06:22 +0200 Subject: [PATCH 2/6] Formatting, removed unused vars and imports --- src/serena/symbol.py | 7 +- .../language_servers/elixir_tools/__init__.py | 2 +- .../elixir_tools/elixir_tools.py | 60 +++++++------- test/solidlsp/elixir/__init__.py | 12 ++- test/solidlsp/elixir/conftest.py | 73 +++++++++-------- test/solidlsp/elixir/test_elixir_basic.py | 39 ++++------ .../elixir/test_elixir_ignored_dirs.py | 31 ++++---- .../elixir/test_elixir_integration.py | 41 +++++----- .../elixir/test_elixir_symbol_retrieval.py | 78 +++++++++---------- 9 files changed, 166 insertions(+), 177 deletions(-) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index 8d68467..4fa9067 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -429,7 +429,12 @@ class Symbol(ToStringMixin): return result def to_dict( - self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False, + self, + kind: bool = False, + location: bool = False, + depth: int = 0, + include_body: bool = False, + include_children_body: bool = False, include_relative_path=True, ) -> dict[str, Any]: """ diff --git a/src/solidlsp/language_servers/elixir_tools/__init__.py b/src/solidlsp/language_servers/elixir_tools/__init__.py index 0519ecb..8b13789 100644 --- a/src/solidlsp/language_servers/elixir_tools/__init__.py +++ b/src/solidlsp/language_servers/elixir_tools/__init__.py @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py index 62d7df6..9667472 100644 --- a/src/solidlsp/language_servers/elixir_tools/elixir_tools.py +++ b/src/solidlsp/language_servers/elixir_tools/elixir_tools.py @@ -35,24 +35,27 @@ class ElixirTools(SolidLanguageServer): def _is_next_ls_internal_file(self, abs_path: str) -> bool: """Check if an absolute path is a Next LS internal file that should be ignored.""" - return any(pattern in abs_path for pattern in [ - ".burrito", # Next LS runtime directory - "next_ls_erts-", # Next LS Erlang runtime - "_next_ls_private_", # Next LS private files - "/priv/monkey/", # Next LS monkey patching directory - ]) + return any( + pattern in abs_path + for pattern in [ + ".burrito", # Next LS runtime directory + "next_ls_erts-", # Next LS Erlang runtime + "_next_ls_private_", # Next LS private files + "/priv/monkey/", # Next LS monkey patching directory + ] + ) @override def _send_references_request(self, relative_file_path: str, line: int, column: int): """Override to filter out Next LS internal files from references.""" from solidlsp.ls_utils import PathUtils - + # Get the raw response from the parent implementation raw_response = super()._send_references_request(relative_file_path, line, column) - + if raw_response is None: return None - + # Filter out Next LS internal files filtered_response = [] for item in raw_response: @@ -62,7 +65,7 @@ class ElixirTools(SolidLanguageServer): self.logger.log(f"Filtering out Next LS internal file: {abs_path}", logging.DEBUG) continue filtered_response.append(item) - + return filtered_response @staticmethod @@ -87,7 +90,7 @@ class ElixirTools(SolidLanguageServer): raise RuntimeError( "Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH." ) - + logger.log(f"Found Elixir: {elixir_version}", logging.INFO) platformId = PlatformUtils.get_platform_id() @@ -101,17 +104,17 @@ class ElixirTools(SolidLanguageServer): # Map platform IDs to runtime dependency keys platform_mapping = { "linux-x64": "linux-x64", - "osx-x64": "darwin-x64", + "osx-x64": "darwin-x64", "osx-arm64": "darwin-arm64", "darwin-x64": "darwin-x64", - "darwin-arm64": "darwin-arm64", - "win-x64": "win-x64" + "darwin-arm64": "darwin-arm64", + "win-x64": "win-x64", } platform_key = platform_mapping.get(platformId.value) if not platform_key: raise RuntimeError(f"Unsupported platform for Next LS: {platformId.value}") - + # Check for Windows and provide a helpful error message if platformId.value.startswith("win"): raise RuntimeError( @@ -122,18 +125,18 @@ class ElixirTools(SolidLanguageServer): dependency = runtimeDependencies["next_ls"][platform_key] next_ls_dir = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"])) os.makedirs(next_ls_dir, exist_ok=True) - + executable_path = str(PurePath(next_ls_dir, dependency["executable_name"])) binary_path = str(PurePath(next_ls_dir, dependency["binary_name"])) if not os.path.exists(executable_path): logger.log(f"Downloading Next LS binary from {dependency['url']}", logging.INFO) FileUtils.download_file(logger, dependency["url"], binary_path) - + # Make the binary executable on Unix-like systems if not platformId.value.startswith("win"): os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH) - + # Create a symlink or copy with the expected name if binary_path != executable_path: if os.path.exists(executable_path): @@ -141,13 +144,14 @@ class ElixirTools(SolidLanguageServer): if platformId.value.startswith("win"): # On Windows, copy the file import shutil + shutil.copy2(binary_path, executable_path) else: # On Unix-like systems, create a symlink os.symlink(os.path.basename(binary_path), executable_path) assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}" - + logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO) return executable_path @@ -163,7 +167,7 @@ class ElixirTools(SolidLanguageServer): ) self.server_ready = threading.Event() self.request_id = 0 - + # Set generous timeout for Next LS which can be slow to initialize and respond self.set_request_timeout(180.0) # 60 seconds for all environments @@ -201,7 +205,7 @@ class ElixirTools(SolidLanguageServer): """Handle window/logMessage notifications from Next LS""" message_text = msg.get("message", "") self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO) - + # Check for the specific Next LS readiness signal # Based on Next LS source: "Runtime for folder #{name} is ready..." if "Runtime for folder" in message_text and "is ready..." in message_text: @@ -214,11 +218,11 @@ class ElixirTools(SolidLanguageServer): def check_server_ready(params): """ Handle $/progress notifications from Next LS. - Keep as fallback for error detection, but primary readiness detection + Keep as fallback for error detection, but primary readiness detection is now done via window/logMessage handler. """ value = params.get("value", {}) - + # Check for initialization completion progress (fallback signal) if value.get("kind") == "end": message = value.get("message", "") @@ -255,10 +259,10 @@ class ElixirTools(SolidLanguageServer): # Verify server capabilities - be more lenient with Next LS self.logger.log(f"Next LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO) - + # Next LS may not provide all capabilities immediately, so we check for basic ones assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}" - + # Some capabilities might be optional or provided later if "completionProvider" not in init_response["capabilities"]: self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING) @@ -272,10 +276,10 @@ class ElixirTools(SolidLanguageServer): # This is the authoritative signal that Next LS is truly ready for requests ready_timeout = 180.0 self.logger.log(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...", logging.INFO) - + if self.server_ready.wait(timeout=ready_timeout): self.logger.log("Next LS is ready and available for requests", logging.INFO) - + # Add a small settling period to ensure background indexing is complete # Next LS often continues compilation/indexing in background after ready signal settling_time = 120.0 @@ -285,4 +289,4 @@ class ElixirTools(SolidLanguageServer): else: error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself." self.logger.log(error_msg, logging.ERROR) - raise RuntimeError(error_msg) \ No newline at end of file + raise RuntimeError(error_msg) diff --git a/test/solidlsp/elixir/__init__.py b/test/solidlsp/elixir/__init__.py index 184fc8e..4d7e886 100644 --- a/test/solidlsp/elixir/__init__.py +++ b/test/solidlsp/elixir/__init__.py @@ -1,25 +1,23 @@ import platform -from pathlib import Path def _test_nextls_available() -> str: """Test if Next LS is available and return error reason if not.""" - # Check if we're on Windows (Next LS doesn't support Windows) if platform.system() == "Windows": return "Next LS does not support Windows" - + # Try to import and check Elixir availability try: from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools - + # Check if Elixir is installed elixir_version = ElixirTools._get_elixir_version() if not elixir_version: return "Elixir is not installed or not in PATH" - + return "" # No error, Next LS should be available - + except ImportError as e: return f"Failed to import ElixirTools: {e}" except Exception as e: @@ -27,4 +25,4 @@ def _test_nextls_available() -> str: NEXTLS_UNAVAILABLE_REASON = _test_nextls_available() -NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON) \ No newline at end of file +NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON) diff --git a/test/solidlsp/elixir/conftest.py b/test/solidlsp/elixir/conftest.py index 6dc31cf..3452376 100644 --- a/test/solidlsp/elixir/conftest.py +++ b/test/solidlsp/elixir/conftest.py @@ -1,137 +1,142 @@ """ Elixir-specific test configuration and fixtures. """ + import os import subprocess -import pytest import time from pathlib import Path +import pytest + def ensure_elixir_test_repo_compiled(repo_path: str) -> None: """Ensure the Elixir test repository dependencies are installed and project is compiled. - + Next LS requires the project to be fully compiled and indexed before providing complete references and symbol resolution. This function: 1. Installs dependencies via 'mix deps.get' 2. Compiles the project via 'mix compile' - + This is essential in CI environments where dependencies aren't pre-installed. - + Args: repo_path: Path to the Elixir project root directory + """ # Check if this looks like an Elixir project mix_file = os.path.join(repo_path, "mix.exs") if not os.path.exists(mix_file): return - + # Check if already compiled (optimization for repeated runs) build_path = os.path.join(repo_path, "_build") deps_path = os.path.join(repo_path, "deps") - + if os.path.exists(build_path) and os.path.exists(deps_path): print(f"Elixir test repository already compiled in {repo_path}") return - + try: - print(f"Installing dependencies and compiling Elixir test repository for optimal Next LS performance...") - + print("Installing dependencies and compiling Elixir test repository for optimal Next LS performance...") + # First, install dependencies with increased timeout for CI print("=" * 60) print("Step 1/2: Installing Elixir dependencies...") print("=" * 60) start_time = time.time() - + deps_result = subprocess.run( ["mix", "deps.get"], cwd=repo_path, capture_output=True, text=True, - timeout=180 # 3 minutes for dependency installation (CI can be slow) + timeout=180, + check=False, # 3 minutes for dependency installation (CI can be slow) ) - + deps_duration = time.time() - start_time print(f"Dependencies installation completed in {deps_duration:.2f} seconds") - + # Always log the output for transparency if deps_result.stdout.strip(): print("Dependencies stdout:") print("-" * 40) print(deps_result.stdout) print("-" * 40) - + if deps_result.stderr.strip(): print("Dependencies stderr:") print("-" * 40) print(deps_result.stderr) print("-" * 40) - + if deps_result.returncode != 0: print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}") # Continue anyway - some projects might not have dependencies else: print("✓ Dependencies installed successfully") - + # Then compile the project with increased timeout for CI print("=" * 60) print("Step 2/2: Compiling Elixir project...") print("=" * 60) start_time = time.time() - + compile_result = subprocess.run( ["mix", "compile"], cwd=repo_path, capture_output=True, text=True, - timeout=300 # 5 minutes for compilation (Credo compilation can be slow in CI) + timeout=300, + check=False, # 5 minutes for compilation (Credo compilation can be slow in CI) ) - + compile_duration = time.time() - start_time print(f"Compilation completed in {compile_duration:.2f} seconds") - + # Always log the output for transparency if compile_result.stdout.strip(): print("Compilation stdout:") print("-" * 40) print(compile_result.stdout) print("-" * 40) - + if compile_result.stderr.strip(): print("Compilation stderr:") print("-" * 40) print(compile_result.stderr) print("-" * 40) - + if compile_result.returncode == 0: print(f"✓ Elixir test repository compiled successfully in {repo_path}") else: print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}") # Still continue - warnings are often non-fatal - + print("=" * 60) print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds") print("=" * 60) - + except subprocess.TimeoutExpired as e: print("=" * 60) print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds") print(f"Command: {' '.join(e.cmd)}") print("This may indicate slow CI environment - Next LS may still work but with reduced functionality") - + # Try to get partial output if available - if hasattr(e, 'stdout') and e.stdout: + if hasattr(e, "stdout") and e.stdout: print("Partial stdout before timeout:") print("-" * 40) print(e.stdout) print("-" * 40) - if hasattr(e, 'stderr') and e.stderr: + if hasattr(e, "stderr") and e.stderr: print("Partial stderr before timeout:") print("-" * 40) print(e.stderr) print("-" * 40) print("=" * 60) - + except FileNotFoundError: print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled") print("Please ensure Elixir is installed and available in PATH") @@ -142,14 +147,14 @@ def ensure_elixir_test_repo_compiled(repo_path: str) -> None: @pytest.fixture(scope="session", autouse=True) def setup_elixir_test_environment(): """Automatically prepare Elixir test environment for all Elixir tests. - + This fixture runs once per test session and automatically: 1. Installs dependencies via 'mix deps.get' 2. Compiles the Elixir test repository via 'mix compile' - - It uses autouse=True so it runs automatically without needing to be explicitly + + It uses autouse=True so it runs automatically without needing to be explicitly requested by tests. This ensures Next LS has a fully prepared project to work with. - + Uses generous timeouts (3-5 minutes) to accommodate slow CI environments. All output is logged for transparency and debugging. """ @@ -162,8 +167,8 @@ def setup_elixir_test_environment(): @pytest.fixture(scope="session") def elixir_test_repo_path(setup_elixir_test_environment): """Get the path to the prepared Elixir test repository. - + This fixture depends on setup_elixir_test_environment to ensure dependencies are installed and compilation has completed before returning the path. """ - return setup_elixir_test_environment \ No newline at end of file + return setup_elixir_test_environment diff --git a/test/solidlsp/elixir/test_elixir_basic.py b/test/solidlsp/elixir/test_elixir_basic.py index a95b41e..1039140 100644 --- a/test/solidlsp/elixir/test_elixir_basic.py +++ b/test/solidlsp/elixir/test_elixir_basic.py @@ -6,19 +6,16 @@ like request_references using the test repository. """ import os + import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language -from solidlsp.ls_utils import SymbolUtils from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module -pytestmark = [ - pytest.mark.elixir, - pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}") -] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] class TestElixirBasic: @@ -29,7 +26,7 @@ class TestElixirBasic: """Test finding references to a function definition.""" file_path = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(file_path) - + # Find the User module's 'new' function user_new_symbol = None for symbol in symbols[0]: # Top level symbols @@ -39,22 +36,18 @@ class TestElixirBasic: user_new_symbol = child break break - + if not user_new_symbol or "selectionRange" not in user_new_symbol: pytest.skip("User.new function or its selectionRange not found") sel_start = user_new_symbol["selectionRange"]["start"] - references = language_server.request_references( - file_path, sel_start["line"], sel_start["character"] - ) + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert references is not None assert len(references) > 0 - + # Should find at least one reference (the definition itself) - found_definition = any( - ref["uri"].endswith("models.ex") for ref in references - ) + found_definition = any(ref["uri"].endswith("models.ex") for ref in references) assert found_definition, "Should find the function definition" @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) @@ -62,7 +55,7 @@ class TestElixirBasic: """Test finding references to create_user function.""" file_path = os.path.join("lib", "services.ex") symbols = language_server.request_document_symbols(file_path) - + # Find the UserService module's 'create_user' function create_user_symbol = None for symbol in symbols[0]: # Top level symbols @@ -72,14 +65,12 @@ class TestElixirBasic: create_user_symbol = child break break - + if not create_user_symbol or "selectionRange" not in create_user_symbol: pytest.skip("UserService.create_user function or its selectionRange not found") sel_start = create_user_symbol["selectionRange"]["start"] - references = language_server.request_references( - file_path, sel_start["line"], sel_start["character"] - ) + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) assert references is not None assert len(references) > 0 @@ -89,7 +80,7 @@ class TestElixirBasic: """Test finding symbols that reference a specific function.""" file_path = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(file_path) - + # Find the User module's 'new' function user_new_symbol = None for symbol in symbols[0]: # Top level symbols @@ -99,14 +90,12 @@ class TestElixirBasic: user_new_symbol = child break break - + if not user_new_symbol or "selectionRange" not in user_new_symbol: pytest.skip("User.new function or its selectionRange not found") sel_start = user_new_symbol["selectionRange"]["start"] - referencing_symbols = language_server.request_referencing_symbols( - file_path, sel_start["line"], sel_start["character"] - ) + referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) assert referencing_symbols is not None @@ -120,4 +109,4 @@ class TestElixirBasic: # Test multiple symbol requests in succession for _ in range(3): symbols = language_server.request_document_symbols("lib/services.ex") - assert symbols is not None \ No newline at end of file + assert symbols is not None diff --git a/test/solidlsp/elixir/test_elixir_ignored_dirs.py b/test/solidlsp/elixir/test_elixir_ignored_dirs.py index 4e11f65..95a6f5f 100644 --- a/test/solidlsp/elixir/test_elixir_ignored_dirs.py +++ b/test/solidlsp/elixir/test_elixir_ignored_dirs.py @@ -10,10 +10,7 @@ from test.conftest import create_ls from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module -pytestmark = [ - pytest.mark.elixir, - pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}") -] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] @pytest.fixture(scope="module") @@ -34,7 +31,7 @@ def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): root = ls_with_ignored_dirs.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} - + # Should have lib and test directories, but not scripts or ignored_dir expected_dirs = {"lib", "test"} assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" @@ -47,7 +44,7 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): """Tests that find_references ignores the configured directory.""" # Location of User struct, which is referenced in scripts and ignored_dir definition_file = "lib/models.ex" - + # Find the User struct definition symbols = ls_with_ignored_dirs.request_document_symbols(definition_file) user_symbol = None @@ -55,7 +52,7 @@ def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer): user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break - + if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User symbol not found for reference testing") @@ -73,13 +70,13 @@ def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ELIXIR) ls.start() - + try: # Same as in the above tests root = ls.request_full_symbol_tree()[0] root_children = root["children"] children_names = {child["name"] for child in root_children} - + # Should have lib and test directories, but not scripts or ignored_dir expected_dirs = {"lib", "test"} assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}" @@ -88,7 +85,7 @@ def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: # Test that the refs and symbols with glob patterns are ignored definition_file = "lib/models.ex" - + # Find the User struct definition symbols = ls.request_document_symbols(definition_file) user_symbol = None @@ -96,11 +93,11 @@ def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None: user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break - + if user_symbol and "selectionRange" in user_symbol: sel_start = user_symbol["selectionRange"]["start"] references = ls.request_references(definition_file, sel_start["line"], sel_start["character"]) - + # Assert that scripts and ignored_dir do not appear in references assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)" assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)" @@ -117,7 +114,7 @@ def test_default_ignored_directories(language_server: SolidLanguageServer): assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored" assert language_server.is_ignored_dirname("cover"), "cover should be ignored" assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored" - + # Test that important directories are not ignored assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored" assert not language_server.is_ignored_dirname("test"), "test should not be ignored" @@ -129,17 +126,17 @@ def test_default_ignored_directories(language_server: SolidLanguageServer): def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer): """Test that symbol tree excludes build and dependency directories.""" symbol_tree = language_server.request_full_symbol_tree() - + if symbol_tree: root = symbol_tree[0] children_names = {child["name"] for child in root.get("children", [])} - + # Build and dependency directories should not appear ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"} found_ignored = ignored_dirs.intersection(children_names) assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}" - + # Important directories should appear important_dirs = {"lib", "test"} found_important = important_dirs.intersection(children_names) - assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" \ No newline at end of file + assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}" diff --git a/test/solidlsp/elixir/test_elixir_integration.py b/test/solidlsp/elixir/test_elixir_integration.py index 2377f0e..25c86a8 100644 --- a/test/solidlsp/elixir/test_elixir_integration.py +++ b/test/solidlsp/elixir/test_elixir_integration.py @@ -5,20 +5,18 @@ These tests verify that the language server works correctly with a real Elixir p and can perform advanced operations like cross-file symbol resolution. """ -import pytest import os from pathlib import Path +import pytest + from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module -pytestmark = [ - pytest.mark.elixir, - pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}") -] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] class TestElixirIntegration: @@ -33,7 +31,7 @@ class TestElixirIntegration: def test_elixir_repo_structure(self, elixir_test_repo_path): """Test that the Elixir test repository has the expected structure.""" repo_path = Path(elixir_test_repo_path) - + # Check that key files exist assert (repo_path / "mix.exs").exists(), "mix.exs should exist" assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist" @@ -49,8 +47,7 @@ class TestElixirIntegration: """Test that symbols can be resolved across different files.""" # Test that User struct from models.ex can be found when referenced in services.ex services_file = os.path.join("lib", "services.ex") - models_file = os.path.join("lib", "models.ex") - + # Find where User is referenced in services.ex content = language_server.retrieve_full_file_content(services_file) lines = content.split("\n") @@ -59,13 +56,13 @@ class TestElixirIntegration: if "alias TestRepo.Models.{User" in line: user_reference_line = i break - + if user_reference_line is None: pytest.skip("Could not find User reference in services.ex") # Try to find the definition defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30) - + if defining_symbol and "location" in defining_symbol: # Should point to models.ex assert "models.ex" in defining_symbol["location"]["uri"] @@ -76,14 +73,14 @@ class TestElixirIntegration: # Search for all function definitions function_pattern = r"def\s+\w+\s*[\(\s]" function_matches = language_server.search_files_for_pattern(function_pattern) - + # Should find functions across multiple files if function_matches: files_with_functions = set() for match in function_matches: if match.source_file_path: files_with_functions.add(os.path.basename(match.source_file_path)) - + # Should find functions in multiple files expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"} found_files = expected_files.intersection(files_with_functions) @@ -92,7 +89,7 @@ class TestElixirIntegration: # Search for struct definitions struct_pattern = r"defstruct\s+\[" struct_matches = language_server.search_files_for_pattern(struct_pattern) - + if struct_matches: # Should find structs primarily in models.ex models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path] @@ -103,7 +100,7 @@ class TestElixirIntegration: """Test that the language server understands Elixir module hierarchy.""" models_file = os.path.join("lib", "models.ex") symbols = language_server.request_document_symbols(models_file) - + if symbols: # Flatten symbol structure all_symbols = [] @@ -112,9 +109,9 @@ class TestElixirIntegration: all_symbols.extend(symbol_group) else: all_symbols.append(symbol_group) - + symbol_names = [s.get("name", "") for s in all_symbols] - + # Should understand nested module structure expected_modules = ["TestRepo.Models", "User", "Item", "Order"] found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)] @@ -124,7 +121,7 @@ class TestElixirIntegration: """Test that the Elixir language recognizes the correct file extensions.""" language = Language.ELIXIR matcher = language.get_source_fn_matcher() - + # Test Elixir file extensions assert matcher.is_relevant_filename("lib/test_repo.ex") assert matcher.is_relevant_filename("test/test_repo_test.exs") @@ -132,7 +129,7 @@ class TestElixirIntegration: assert matcher.is_relevant_filename("mix.exs") assert matcher.is_relevant_filename("lib/models.ex") assert matcher.is_relevant_filename("lib/services.ex") - + # Test non-Elixir files assert not matcher.is_relevant_filename("README.md") assert not matcher.is_relevant_filename("lib/test_repo.py") @@ -142,12 +139,10 @@ class TestElixirIntegration: @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_protocol_and_implementation_understanding(self, language_server: SolidLanguageServer): """Test that the language server understands Elixir protocols and implementations.""" - models_file = os.path.join("lib", "models.ex") - # Search for protocol definitions protocol_pattern = r"defprotocol\s+\w+" protocol_matches = language_server.search_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex") - + if protocol_matches: # Should find the Serializable protocol serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)] @@ -156,7 +151,7 @@ class TestElixirIntegration: # Search for protocol implementations impl_pattern = r"defimpl\s+\w+" impl_matches = language_server.search_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex") - + if impl_matches: # Should find multiple implementations - assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" \ No newline at end of file + assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}" diff --git a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py index ed36b9e..a6c31b6 100644 --- a/test/solidlsp/elixir/test_elixir_symbol_retrieval.py +++ b/test/solidlsp/elixir/test_elixir_symbol_retrieval.py @@ -8,6 +8,7 @@ These tests focus on the following methods: """ import os + import pytest from solidlsp import SolidLanguageServer @@ -17,10 +18,7 @@ from solidlsp.ls_types import SymbolKind from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON # These marks will be applied to all tests in this module -pytestmark = [ - pytest.mark.elixir, - pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}") -] +pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")] class TestElixirLanguageServerSymbols: @@ -31,7 +29,7 @@ class TestElixirLanguageServerSymbols: """Test request_containing_symbol for a function.""" # Test for a position inside the create_user function file_path = os.path.join("lib", "services.ex") - + # Find the create_user function in the file content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") @@ -40,7 +38,7 @@ class TestElixirLanguageServerSymbols: if "def create_user(" in line: create_user_line = i + 2 # Go inside the function body break - + if create_user_line is None: pytest.skip("Could not find create_user function") @@ -59,7 +57,7 @@ class TestElixirLanguageServerSymbols: """Test request_containing_symbol for a module.""" # Test for a position inside the UserService module but outside any function file_path = os.path.join("lib", "services.ex") - + # Find the UserService module definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") @@ -68,7 +66,7 @@ class TestElixirLanguageServerSymbols: if "defmodule UserService do" in line: user_service_line = i + 1 # Go inside the module break - + if user_service_line is None: pytest.skip("Could not find UserService module") @@ -84,7 +82,7 @@ class TestElixirLanguageServerSymbols: """Test request_containing_symbol with nested scopes.""" # Test for a position inside a function which is inside a module file_path = os.path.join("lib", "services.ex") - + # Find a function inside UserService content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") @@ -93,7 +91,7 @@ class TestElixirLanguageServerSymbols: if "def create_user(" in line: function_body_line = i + 3 # Go deeper into the function body break - + if function_body_line is None: pytest.skip("Could not find function body") @@ -116,29 +114,27 @@ class TestElixirLanguageServerSymbols: # This is acceptable behavior for module-level positions assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol) - - @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None: """Test request_referencing_symbols for a struct.""" # Test referencing symbols for User struct file_path = os.path.join("lib", "models.ex") - + symbols = language_server.request_document_symbols(file_path) user_symbol = None for symbol_group in symbols: user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None) if user_symbol: break - + if not user_symbol or "selectionRange" not in user_symbol: pytest.skip("User symbol or its selectionRange not found") - + sel_start = user_symbol["selectionRange"]["start"] ref_symbols = [ ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"]) ] - + if ref_symbols: services_references = [ symbol @@ -175,7 +171,7 @@ class TestElixirLanguageServerSymbols: if "User.new(" in line: user_new_call_line = i break - + if user_new_call_line is None: pytest.skip("Could not find User.new call") @@ -199,7 +195,7 @@ class TestElixirLanguageServerSymbols: if "alias TestRepo.Models.{User" in line: user_usage_line = i break - + if user_usage_line is None: pytest.skip("Could not find User struct usage") @@ -210,9 +206,9 @@ class TestElixirLanguageServerSymbols: @pytest.mark.xfail( reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. " - "Occurs in CI environments but may pass locally. " - "See https://github.com/elixir-tools/next-ls/issues/543", - strict=False + "Occurs in CI environments but may pass locally. " + "See https://github.com/elixir-tools/next-ls/issues/543", + strict=False, ) @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True) def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None: @@ -229,7 +225,7 @@ class TestElixirLanguageServerSymbols: def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None: """Test integration between different symbol methods.""" file_path = os.path.join("lib", "models.ex") - + # Find User struct definition content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") @@ -238,20 +234,20 @@ class TestElixirLanguageServerSymbols: if "defmodule User do" in line: user_struct_line = i break - + if user_struct_line is None: pytest.skip("Could not find User struct") # Test containing symbol containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10) - + if containing: # Test that we can find references to this symbol if "location" in containing and "range" in containing["location"]: start_pos = containing["location"]["range"]["start"] - refs = [ref.symbol for ref in language_server.request_referencing_symbols( - file_path, start_pos["line"], start_pos["character"] - )] + refs = [ + ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"]) + ] # We should find some references or none (both are valid outcomes) assert isinstance(refs, list) @@ -259,21 +255,21 @@ class TestElixirLanguageServerSymbols: def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: """Test that symbol tree structure is correctly built.""" symbol_tree = language_server.request_full_symbol_tree() - + # Should get a tree structure assert len(symbol_tree) > 0 - + # Should have our test repository structure root = symbol_tree[0] assert "children" in root - + # Look for lib directory lib_dir = None for child in root["children"]: if child["name"] == "lib": lib_dir = child break - + if lib_dir: # Next LS returns module names instead of file names (e.g., 'services' instead of 'services.ex') file_names = [child["name"] for child in lib_dir.get("children", [])] @@ -285,14 +281,14 @@ class TestElixirLanguageServerSymbols: def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None: """Test request_dir_overview functionality.""" lib_overview = language_server.request_dir_overview("lib") - + # Should get an overview of the lib directory assert lib_overview is not None # Next LS returns keys like 'lib/services.ex' instead of just 'lib' - overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, 'keys') else [] - lib_files = [key for key in overview_keys if key.startswith('lib/')] + overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else [] + lib_files = [key for key in overview_keys if key.startswith("lib/")] assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}" - + # Should contain information about our modules overview_text = str(lib_overview).lower() expected_terms = ["models", "services", "user", "item"] @@ -309,10 +305,10 @@ class TestElixirLanguageServerSymbols: # # # file_path = os.path.join("lib", "models.ex") # doc_overview = language_server.request_document_overview(file_path) - # + # # # Should get an overview of the models.ex file # assert doc_overview is not None - # + # # # Should contain information about our structs and functions # overview_text = str(doc_overview).lower() # expected_terms = ["user", "item", "order", "struct", "defmodule"] @@ -323,7 +319,7 @@ class TestElixirLanguageServerSymbols: def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None: """Test containing symbol for module attributes.""" file_path = os.path.join("lib", "models.ex") - + # Find a module attribute like @type or @doc content = language_server.retrieve_full_file_content(file_path) lines = content.split("\n") @@ -332,15 +328,15 @@ class TestElixirLanguageServerSymbols: if line.strip().startswith("@type") or line.strip().startswith("@doc"): attribute_line = i break - + if attribute_line is None: pytest.skip("Could not find module attribute") containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5) - + if containing_symbol: # Should be contained within a module assert "name" in containing_symbol # The containing symbol should be a module expected_names = ["User", "Item", "Order", "TestRepo.Models"] - assert any(name in containing_symbol["name"] for name in expected_names) \ No newline at end of file + assert any(name in containing_symbol["name"] for name in expected_names) From 1df9732390ff57524d5263de6fa69bd3ae033625 Mon Sep 17 00:00:00 2001 From: Yen <5915590+antigenius0910@users.noreply.github.com> Date: Sun, 6 Jul 2025 10:15:54 -0500 Subject: [PATCH 3/6] feature/terraform language server support (#277) Adds terraform support --- .github/workflows/pytest.yml | 35 ++- .vscode/settings.json | 3 +- pyproject.toml | 1 + .../terraform_ls/initialize_params.json | 46 ++++ .../terraform_ls/runtime_dependencies.json | 37 +++ .../terraform_ls/terraform_ls.py | 249 ++++++++++++++++++ src/solidlsp/ls.py | 5 + src/solidlsp/ls_config.py | 3 + .../repos/terraform/test_repo/data.tf | 28 ++ .../repos/terraform/test_repo/main.tf | 126 +++++++++ .../repos/terraform/test_repo/outputs.tf | 46 ++++ .../repos/terraform/test_repo/variables.tf | 61 +++++ .../terraform/test_terraform_basic.py | 55 ++++ 13 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 src/solidlsp/language_servers/terraform_ls/initialize_params.json create mode 100644 src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json create mode 100644 src/solidlsp/language_servers/terraform_ls/terraform_ls.py create mode 100644 test/resources/repos/terraform/test_repo/data.tf create mode 100644 test/resources/repos/terraform/test_repo/main.tf create mode 100644 test/resources/repos/terraform/test_repo/outputs.tf create mode 100644 test/resources/repos/terraform/test_repo/variables.tf create mode 100644 test/solidlsp/terraform/test_terraform_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fe9b15c..20d0567 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,7 +14,6 @@ jobs: cpu: name: Tests on ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 if: "!contains(github.event.head_commit.message, 'ci skip')" strategy: fail-fast: false @@ -52,6 +51,40 @@ jobs: uses: DeLaGuardo/setup-clojure@13.4 with: cli: latest + - name: Install Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: "1.5.0" + terraform_wrapper: false + - name: Install terraform-ls (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Write-Host "Installing terraform-ls manually for Windows" + $ver = '0.32.7' + $zip = "terraform-ls_${ver}_windows_amd64.zip" + Invoke-WebRequest -Uri "https://releases.hashicorp.com/terraform-ls/$ver/$zip" -OutFile $zip + + $dest = "$env:USERPROFILE\terraform-ls" + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Expand-Archive $zip -DestinationPath $dest -Force + + Write-Host "terraform-ls installed to: $dest" + echo "$dest" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Install terraform-ls (Linux/macOS) + if: runner.os != 'Windows' + shell: bash + run: | + ver=0.32.7 + os=$(uname | tr '[:upper:]' '[:lower:]') + echo "Installing terraform-ls ${ver} for ${os}" + curl -sSL -o tfls.zip \ + "https://releases.hashicorp.com/terraform-ls/${ver}/terraform-ls_${ver}_${os}_amd64.zip" + mkdir -p "$HOME/bin" + unzip -q tfls.zip -d "$HOME/bin" + chmod +x "$HOME/bin/terraform-ls" + echo "$HOME/bin" >> "$GITHUB_PATH" + echo "terraform-ls installed to $HOME/bin" - name: Install uv shell: bash run: curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/.vscode/settings.json b/.vscode/settings.json index 56a7855..6d3f3ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,4 +11,5 @@ "sensai", "vibing" ], -} \ No newline at end of file +} + diff --git a/pyproject.toml b/pyproject.toml index 610dbd0..ad37014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -248,6 +248,7 @@ markers = [ "php: language server running for PHP", "csharp: language server running for C#", "elixir: language server running for Elixir", + "terraform: language server running for Terraform", "snapshot: snapshot tests for symbolic editing operations", ] diff --git a/src/solidlsp/language_servers/terraform_ls/initialize_params.json b/src/solidlsp/language_servers/terraform_ls/initialize_params.json new file mode 100644 index 0000000..40592e1 --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls/initialize_params.json @@ -0,0 +1,46 @@ +{ + "_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize", + "processId": "os.getpid()", + "locale": "en", + "rootPath": "$rootPath", + "rootUri": "$rootUri", + "capabilities": { + "textDocument": { + "synchronization": { + "didSave": true, + "dynamicRegistration": true + }, + "completion": { + "dynamicRegistration": true, + "completionItem": { + "snippetSupport": true + } + }, + "definition": { + "dynamicRegistration": true + }, + "documentSymbol": { + "dynamicRegistration": true, + "hierarchicalDocumentSymbolSupport": true, + "symbolKind": { + "valueSet": [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, + 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26 + ] + } + } + }, + "workspace": { + "workspaceFolders": true, + "didChangeConfiguration": { + "dynamicRegistration": true + } + } + }, + "workspaceFolders": [ + { + "uri": "$uri", + "name": "$name" + } + ] +} \ No newline at end of file diff --git a/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json b/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json new file mode 100644 index 0000000..873e7ec --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls/runtime_dependencies.json @@ -0,0 +1,37 @@ +{ + "_description": "Used to download the runtime dependencies for running terraform-ls. Obtained from https://releases.hashicorp.com/terraform-ls/", + "runtimeDependencies": [ + { + "id": "TerraformLS", + "description": "terraform-ls for macOS (ARM64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip", + "platformId": "osx-arm64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for macOS (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip", + "platformId": "osx-x64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for Linux (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip", + "platformId": "linux-x64", + "archiveType": "zip", + "binaryName": "terraform-ls" + }, + { + "id": "TerraformLS", + "description": "terraform-ls for Windows (x64)", + "url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip", + "platformId": "win-x64", + "archiveType": "zip", + "binaryName": "terraform-ls.exe" + } + ] +} diff --git a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py new file mode 100644 index 0000000..10868c1 --- /dev/null +++ b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py @@ -0,0 +1,249 @@ +import json +import logging +import os +import pathlib +import shutil +import stat +import subprocess +import threading + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.ls_utils import FileUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo + + +class TerraformLS(SolidLanguageServer): + """ + Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + # For Terraform projects, we should ignore: + # - .terraform: Terraform working directory with providers and modules + # - terraform.tfstate.d: Terraform workspace state directories + # - .git: Version control + # - node_modules: If the project has JavaScript components + return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d", "node_modules"] + + @staticmethod + def _get_terraform_version(logger=None): + """Get the installed Terraform version or None if not found.""" + if logger: + logger.log("Starting terraform version detection...", logging.DEBUG) + else: + logging.debug("Starting terraform version detection...") + + # 1. Try to find terraform using shutil.which (standard Python way) + terraform_cmd = shutil.which("terraform") + if terraform_cmd: + if logger: + logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + else: + logging.debug(f"Found terraform via shutil.which: {terraform_cmd}") + else: + if logger: + logger.log("terraform not found via shutil.which", logging.DEBUG) + else: + logging.debug("terraform not found via shutil.which") + + # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) + if not terraform_cmd: + terraform_cli_path = os.environ.get('TERRAFORM_CLI_PATH') + if terraform_cli_path: + if logger: + logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + else: + logging.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") + terraform_exe = os.path.join(terraform_cli_path, "terraform.exe") + if os.path.exists(terraform_exe): + terraform_cmd = terraform_exe + if logger: + logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + else: + logging.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") + else: + if logger: + logger.log(f"terraform.exe not found at {terraform_exe}", logging.DEBUG) + else: + logging.debug(f"terraform.exe not found at {terraform_exe}") + else: + if logger: + logger.log("TERRAFORM_CLI_PATH not set", logging.DEBUG) + else: + logging.debug("TERRAFORM_CLI_PATH not set") + + # 3. Try to run the terraform command if found + if terraform_cmd: + try: + if logger: + logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) + else: + logging.debug(f"Attempting to run: {terraform_cmd} version (with 15s timeout)") + result = subprocess.run( + [terraform_cmd, "version"], + capture_output=True, + text=True, + check=False, + timeout=15 # CRITICAL: 15 second timeout to prevent hangs + ) + if result.returncode == 0: + if logger: + logger.log("terraform version command succeeded", logging.DEBUG) + else: + logging.debug("terraform version command succeeded") + return result.stdout.strip() + else: + if logger: + logger.log(f"terraform version command failed with return code {result.returncode}", logging.DEBUG) + logger.log(f"stderr: {result.stderr}", logging.DEBUG) + else: + logging.debug(f"terraform version command failed with return code {result.returncode}") + logging.debug(f"stderr: {result.stderr}") + except subprocess.TimeoutExpired: + if logger: + logger.log("terraform version command timed out after 15 seconds", logging.ERROR) + else: + logging.error("terraform version command timed out after 15 seconds") + except (FileNotFoundError, OSError) as e: + if logger: + logger.log(f"Failed to run terraform command: {e}", logging.DEBUG) + else: + logging.debug(f"Failed to run terraform command: {e}") + else: + if logger: + logger.log("No terraform executable found", logging.DEBUG) + else: + logging.debug("No terraform executable found") + + return None + + + def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + """ + Setup runtime dependencies for terraform-ls. + Downloads and installs terraform-ls if not already present. + """ + # First check if Terraform is available + terraform_version = self._get_terraform_version(logger) + if not terraform_version: + raise RuntimeError( + "Terraform executable not found or failed to execute. " + "Please ensure Terraform is installed and accessible in your system's PATH.\n" + "If it's installed, check for permission issues or corrupted installation.\n" + "Download from https://www.terraform.io/downloads" + ) + + platform_id = PlatformUtils.get_platform_id() + + with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f: + d = json.load(f) + del d["_description"] + + runtime_dependencies = d["runtimeDependencies"] + runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] + assert len(runtime_dependencies) == 1, f"Expected exactly one runtime dependency for platform {platform_id.value}, found {len(runtime_dependencies)}" + dependency = runtime_dependencies[0] + + terraform_ls_dir = os.path.join(os.path.dirname(__file__), "static", "TerraformLS") + terraform_ls_executable_path = os.path.join(terraform_ls_dir, dependency["binaryName"]) + + if not os.path.exists(terraform_ls_dir): + os.makedirs(terraform_ls_dir) + + if not os.path.exists(terraform_ls_executable_path): + logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) + FileUtils.download_and_extract_archive(logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) + + assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" + + # Make the executable file executable on Unix-like systems + if platform_id.value != "win-x64": + os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD) + + return terraform_ls_executable_path + + def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str): + """ + Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + terraform_ls_executable_path = self.setup_runtime_dependencies(logger, config) + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path), + "terraform", + ) + self.server_ready = threading.Event() + self.request_id = 0 + + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Terraform Language Server. + """ + with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f: + d = json.load(f) + + 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) + + return d + + def _start_server(self): + """Start terraform-ls server process""" + + def register_capability_handler(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def do_nothing(params): + 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) + + self.logger.log("Starting terraform-ls server process", logging.INFO) + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + self.logger.log( + "Sending initialize request from LSP client to LSP server and awaiting response", + logging.INFO, + ) + init_response = self.server.send.initialize(initialize_params) + + # Verify server capabilities + assert "textDocumentSync" in init_response["capabilities"] + assert "completionProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) + self.completions_available.set() + + # terraform-ls server is typically ready immediately after initialization + self.server_ready.set() + self.server_ready.wait() diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index a6b8be8..468f11c 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -187,6 +187,11 @@ class SolidLanguageServer(ABC): ls = ElixirTools(config, logger, repository_root_path) + elif config.code_language == Language.TERRAFORM: + from solidlsp.language_servers.terraform_ls.terraform_ls import TerraformLS + + ls = TerraformLS(config, logger, repository_root_path) + else: logger.log(f"Language {config.code_language} is not supported", logging.ERROR) raise LanguageServerException(f"Language {config.code_language} is not supported") diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 3a279d8..e5df901 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -39,6 +39,7 @@ class Language(str, Enum): PHP = "php" CLOJURE = "clojure" ELIXIR = "elixir" + TERRAFORM = "terraform" def __str__(self) -> str: return self.value @@ -77,6 +78,8 @@ class Language(str, Enum): return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn case self.ELIXIR: return FilenameMatcher("*.ex", "*.exs") + case self.TERRAFORM: + return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/terraform/test_repo/data.tf b/test/resources/repos/terraform/test_repo/data.tf new file mode 100644 index 0000000..0dd2d93 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/data.tf @@ -0,0 +1,28 @@ +# Data sources for the Terraform configuration + +# Get the latest Ubuntu AMI +data "aws_ami" "ubuntu" { + most_recent = true + owners = ["099720109477"] # Canonical + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } +} + +# Get available availability zones +data "aws_availability_zones" "available" { + state = "available" +} + +# Get current AWS caller identity +data "aws_caller_identity" "current" {} + +# Get current AWS region +data "aws_region" "current" {} diff --git a/test/resources/repos/terraform/test_repo/main.tf b/test/resources/repos/terraform/test_repo/main.tf new file mode 100644 index 0000000..d186c20 --- /dev/null +++ b/test/resources/repos/terraform/test_repo/main.tf @@ -0,0 +1,126 @@ +# Main Terraform configuration +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +# EC2 Instance +resource "aws_instance" "web_server" { + ami = data.aws_ami.ubuntu.id + instance_type = var.instance_type + + vpc_security_group_ids = [aws_security_group.web_sg.id] + subnet_id = aws_subnet.public.id + + user_data = <<-EOF + #!/bin/bash + apt-get update + apt-get install -y nginx + systemctl start nginx + systemctl enable nginx + EOF + + tags = { + Name = "${var.project_name}-web-server" + Environment = var.environment + Project = var.project_name + } +} + +# S3 Bucket +resource "aws_s3_bucket" "app_bucket" { + bucket = "${var.project_name}-${var.environment}-bucket" + + tags = { + Name = "${var.project_name}-bucket" + Environment = var.environment + Project = var.project_name + } +} + +resource "aws_s3_bucket_versioning" "app_bucket_versioning" { + bucket = aws_s3_bucket.app_bucket.id + versioning_configuration { + status = "Enabled" + } +} + +# VPC +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true + + tags = { + Name = "${var.project_name}-vpc" + Environment = var.environment + Project = var.project_name + } +} + +# Internet Gateway +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = { + Name = "${var.project_name}-igw" + Environment = var.environment + Project = var.project_name + } +} + +# Public Subnet +resource "aws_subnet" "public" { + vpc_id = aws_vpc.main.id + cidr_block = "10.0.1.0/24" + availability_zone = data.aws_availability_zones.available.names[0] + map_public_ip_on_launch = true + + tags = { + Name = "${var.project_name}-public-subnet" + Environment = var.environment + Project = var.project_name + } +} + +# Security Group +resource "aws_security_group" "web_sg" { + name_prefix = "${var.project_name}-web-" + vpc_id = aws_vpc.main.id + + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.project_name}-web-sg" + Environment = var.environment + Project = var.project_name + } +} diff --git a/test/resources/repos/terraform/test_repo/outputs.tf b/test/resources/repos/terraform/test_repo/outputs.tf new file mode 100644 index 0000000..856ed6d --- /dev/null +++ b/test/resources/repos/terraform/test_repo/outputs.tf @@ -0,0 +1,46 @@ +# Output values for the Terraform configuration + +output "instance_id" { + description = "ID of the EC2 instance" + value = aws_instance.web_server.id +} + +output "instance_public_ip" { + description = "Public IP address of the EC2 instance" + value = aws_instance.web_server.public_ip +} + +output "instance_public_dns" { + description = "Public DNS name of the EC2 instance" + value = aws_instance.web_server.public_dns +} + +output "s3_bucket_name" { + description = "Name of the S3 bucket" + value = aws_s3_bucket.app_bucket.bucket +} + +output "s3_bucket_arn" { + description = "ARN of the S3 bucket" + value = aws_s3_bucket.app_bucket.arn +} + +output "vpc_id" { + description = "ID of the VPC" + value = aws_vpc.main.id +} + +output "subnet_id" { + description = "ID of the public subnet" + value = aws_subnet.public.id +} + +output "security_group_id" { + description = "ID of the security group" + value = aws_security_group.web_sg.id +} + +output "application_url" { + description = "URL to access the application" + value = "http://${aws_instance.web_server.public_dns}" +} diff --git a/test/resources/repos/terraform/test_repo/variables.tf b/test/resources/repos/terraform/test_repo/variables.tf new file mode 100644 index 0000000..92437bd --- /dev/null +++ b/test/resources/repos/terraform/test_repo/variables.tf @@ -0,0 +1,61 @@ +# Input variables for the Terraform configuration + +variable "aws_region" { + description = "AWS region for resources" + type = string + default = "us-west-2" +} + +variable "instance_type" { + description = "EC2 instance type" + type = string + default = "t3.micro" + + validation { + condition = contains([ + "t3.micro", "t3.small", "t3.medium", + "t2.micro", "t2.small", "t2.medium" + ], var.instance_type) + error_message = "Instance type must be a valid t2 or t3 instance type." + } +} + +variable "environment" { + description = "Environment name (dev, staging, prod)" + type = string + default = "dev" + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +variable "project_name" { + description = "Name of the project" + type = string + default = "terraform-test" + + validation { + condition = can(regex("^[a-z0-9-]+$", var.project_name)) + error_message = "Project name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "enable_monitoring" { + description = "Enable CloudWatch monitoring" + type = bool + default = false +} + +variable "allowed_cidr_blocks" { + description = "List of CIDR blocks allowed to access the application" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "tags" { + description = "Additional tags to apply to resources" + type = map(string) + default = {} +} diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py new file mode 100644 index 0000000..eb08aab --- /dev/null +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -0,0 +1,55 @@ +""" +Basic integration tests for the Terraform language server functionality. + +These tests validate the functionality of the language server APIs +like request_references using the test repository. +""" + +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.terraform +class TestLanguageServerBasics: + """Test basic functionality of the Terraform language server.""" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer) -> None: + """Test basic definition lookup functionality.""" + # Simple test to verify the language server is working + file_path = "main.tf" + # Just try to get document symbols - this should work without hanging + symbols = language_server.request_document_symbols(file_path) + assert len(symbols) > 0, "Should find at least some symbols in main.tf" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: + """Test request_references on an aws_instance resource.""" + # Get references to an aws_instance resource in main.tf + file_path = "main.tf" + # Find aws_instance resources + symbols = language_server.request_document_symbols(file_path) + aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None) + if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol: + raise AssertionError("aws_instance symbol or its selectionRange not found") + sel_start = aws_instance_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "aws_instance should be referenced at least once" + + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) + def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_references on a variable.""" + # Get references to a variable in variables.tf + file_path = "variables.tf" + # Find variable definitions + symbols = language_server.request_document_symbols(file_path) + var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None) + if not var_symbol or "selectionRange" not in var_symbol: + raise AssertionError("variable symbol or its selectionRange not found") + sel_start = var_symbol["selectionRange"]["start"] + references = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + assert len(references) >= 1, "variable should be referenced at least once" From 3981f021c614d36489b8ab9a923a45fd5e79c5f6 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 6 Jul 2025 17:27:48 +0200 Subject: [PATCH 4/6] Minor simplification of tf LS, formatting Also fixed call to terraform.exe on non-windows platforms --- src/solidlsp/.gitignore | 1 + .../terraform_ls/terraform_ls.py | 149 ++++++------------ .../terraform/test_terraform_basic.py | 2 - 3 files changed, 46 insertions(+), 106 deletions(-) create mode 100644 src/solidlsp/.gitignore diff --git a/src/solidlsp/.gitignore b/src/solidlsp/.gitignore new file mode 100644 index 0000000..c6d3356 --- /dev/null +++ b/src/solidlsp/.gitignore @@ -0,0 +1 @@ +language_servers/static \ No newline at end of file diff --git a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py index 10868c1..3eeea13 100644 --- a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py @@ -24,113 +24,53 @@ class TerraformLS(SolidLanguageServer): @override def is_ignored_dirname(self, dirname: str) -> bool: - # For Terraform projects, we should ignore: - # - .terraform: Terraform working directory with providers and modules - # - terraform.tfstate.d: Terraform workspace state directories - # - .git: Version control - # - node_modules: If the project has JavaScript components - return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d", "node_modules"] + return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] - @staticmethod - def _get_terraform_version(logger=None): - """Get the installed Terraform version or None if not found.""" - if logger: - logger.log("Starting terraform version detection...", logging.DEBUG) - else: - logging.debug("Starting terraform version detection...") - - # 1. Try to find terraform using shutil.which (standard Python way) + def _get_terraform_version(self) -> str: + self.logger.log("Starting terraform version detection...", logging.DEBUG) + + # 1. Try to find terraform using shutil.which terraform_cmd = shutil.which("terraform") - if terraform_cmd: - if logger: - logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) - else: - logging.debug(f"Found terraform via shutil.which: {terraform_cmd}") - else: - if logger: - logger.log("terraform not found via shutil.which", logging.DEBUG) - else: - logging.debug("terraform not found via shutil.which") - + if terraform_cmd is not None: + self.logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) if not terraform_cmd: - terraform_cli_path = os.environ.get('TERRAFORM_CLI_PATH') + terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") if terraform_cli_path: - if logger: - logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + self.logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + if os.name == "nt": + terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") else: - logging.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") - terraform_exe = os.path.join(terraform_cli_path, "terraform.exe") - if os.path.exists(terraform_exe): - terraform_cmd = terraform_exe - if logger: - logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) - else: - logging.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") - else: - if logger: - logger.log(f"terraform.exe not found at {terraform_exe}", logging.DEBUG) - else: - logging.debug(f"terraform.exe not found at {terraform_exe}") - else: - if logger: - logger.log("TERRAFORM_CLI_PATH not set", logging.DEBUG) - else: - logging.debug("TERRAFORM_CLI_PATH not set") - - # 3. Try to run the terraform command if found - if terraform_cmd: - try: - if logger: - logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) - else: - logging.debug(f"Attempting to run: {terraform_cmd} version (with 15s timeout)") - result = subprocess.run( - [terraform_cmd, "version"], - capture_output=True, - text=True, - check=False, - timeout=15 # CRITICAL: 15 second timeout to prevent hangs - ) - if result.returncode == 0: - if logger: - logger.log("terraform version command succeeded", logging.DEBUG) - else: - logging.debug("terraform version command succeeded") - return result.stdout.strip() - else: - if logger: - logger.log(f"terraform version command failed with return code {result.returncode}", logging.DEBUG) - logger.log(f"stderr: {result.stderr}", logging.DEBUG) - else: - logging.debug(f"terraform version command failed with return code {result.returncode}") - logging.debug(f"stderr: {result.stderr}") - except subprocess.TimeoutExpired: - if logger: - logger.log("terraform version command timed out after 15 seconds", logging.ERROR) - else: - logging.error("terraform version command timed out after 15 seconds") - except (FileNotFoundError, OSError) as e: - if logger: - logger.log(f"Failed to run terraform command: {e}", logging.DEBUG) - else: - logging.debug(f"Failed to run terraform command: {e}") + terraform_binary = os.path.join(terraform_cli_path, "terraform") + if os.path.exists(terraform_binary): + terraform_cmd = terraform_binary + self.logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + + if not terraform_cmd: + raise RuntimeError("Terraform executable not found. Please ensure Terraform is installed and accessible in your system's PATH.") + + self.logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) + result = subprocess.run( + [terraform_cmd, "version"], + capture_output=True, + text=True, + check=False, + timeout=15, # CRITICAL: 15 second timeout to prevent hangs + ) + if result.returncode == 0: + self.logger.log("terraform version command succeeded", logging.DEBUG) + return result.stdout.strip() else: - if logger: - logger.log("No terraform executable found", logging.DEBUG) - else: - logging.debug("No terraform executable found") - - return None + raise RuntimeError(f"terraform version command failed with return code {result.returncode}: {result.stderr}") - - def setup_runtime_dependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str: + def setup_runtime_dependencies(self) -> str: """ Setup runtime dependencies for terraform-ls. Downloads and installs terraform-ls if not already present. """ # First check if Terraform is available - terraform_version = self._get_terraform_version(logger) + terraform_version = self._get_terraform_version() if not terraform_version: raise RuntimeError( "Terraform executable not found or failed to execute. " @@ -147,21 +87,23 @@ class TerraformLS(SolidLanguageServer): runtime_dependencies = d["runtimeDependencies"] runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value] - assert len(runtime_dependencies) == 1, f"Expected exactly one runtime dependency for platform {platform_id.value}, found {len(runtime_dependencies)}" + assert ( + len(runtime_dependencies) == 1 + ), f"Expected exactly one runtime dependency for platform {platform_id.value}, found {len(runtime_dependencies)}" dependency = runtime_dependencies[0] terraform_ls_dir = os.path.join(os.path.dirname(__file__), "static", "TerraformLS") terraform_ls_executable_path = os.path.join(terraform_ls_dir, dependency["binaryName"]) - + if not os.path.exists(terraform_ls_dir): os.makedirs(terraform_ls_dir) - + if not os.path.exists(terraform_ls_executable_path): - logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) - FileUtils.download_and_extract_archive(logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) - + self.logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) + FileUtils.download_and_extract_archive(self.logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) + assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" - + # Make the executable file executable on Unix-like systems if platform_id.value != "win-x64": os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD) @@ -172,8 +114,8 @@ class TerraformLS(SolidLanguageServer): """ Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - terraform_ls_executable_path = self.setup_runtime_dependencies(logger, config) - + terraform_ls_executable_path = self.setup_runtime_dependencies() + super().__init__( config, logger, @@ -184,7 +126,6 @@ class TerraformLS(SolidLanguageServer): self.server_ready = threading.Event() self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Terraform Language Server. diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py index eb08aab..ff20ac6 100644 --- a/test/solidlsp/terraform/test_terraform_basic.py +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -5,8 +5,6 @@ These tests validate the functionality of the language server APIs like request_references using the test repository. """ -import os - import pytest from solidlsp import SolidLanguageServer From 49679c9318e1759cbde6a7bdef996cb7d8af015f Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 6 Jul 2025 17:48:19 +0200 Subject: [PATCH 5/6] TF LS: fixed method order execution Previously a method relying on self.logger being set was called before it was set --- .../terraform_ls/terraform_ls.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py index 3eeea13..b77de53 100644 --- a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py @@ -26,31 +26,32 @@ class TerraformLS(SolidLanguageServer): def is_ignored_dirname(self, dirname: str) -> bool: return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"] - def _get_terraform_version(self) -> str: - self.logger.log("Starting terraform version detection...", logging.DEBUG) + @staticmethod + def _get_terraform_version(logger: LanguageServerLogger) -> str: + logger.log("Starting terraform version detection...", logging.DEBUG) # 1. Try to find terraform using shutil.which terraform_cmd = shutil.which("terraform") if terraform_cmd is not None: - self.logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) + logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG) # 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action) if not terraform_cmd: terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH") if terraform_cli_path: - self.logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) + logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG) if os.name == "nt": terraform_binary = os.path.join(terraform_cli_path, "terraform.exe") else: terraform_binary = os.path.join(terraform_cli_path, "terraform") if os.path.exists(terraform_binary): terraform_cmd = terraform_binary - self.logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) + logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG) if not terraform_cmd: raise RuntimeError("Terraform executable not found. Please ensure Terraform is installed and accessible in your system's PATH.") - self.logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) + logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG) result = subprocess.run( [terraform_cmd, "version"], capture_output=True, @@ -59,18 +60,20 @@ class TerraformLS(SolidLanguageServer): timeout=15, # CRITICAL: 15 second timeout to prevent hangs ) if result.returncode == 0: - self.logger.log("terraform version command succeeded", logging.DEBUG) + logger.log("terraform version command succeeded", logging.DEBUG) return result.stdout.strip() else: raise RuntimeError(f"terraform version command failed with return code {result.returncode}: {result.stderr}") - def setup_runtime_dependencies(self) -> str: + # Note: needs to remain static because it's called before init is complete + @staticmethod + def _setup_runtime_dependencies(logger: LanguageServerLogger) -> str: """ Setup runtime dependencies for terraform-ls. Downloads and installs terraform-ls if not already present. """ # First check if Terraform is available - terraform_version = self._get_terraform_version() + terraform_version = TerraformLS._get_terraform_version(logger) if not terraform_version: raise RuntimeError( "Terraform executable not found or failed to execute. " @@ -99,8 +102,8 @@ class TerraformLS(SolidLanguageServer): os.makedirs(terraform_ls_dir) if not os.path.exists(terraform_ls_executable_path): - self.logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) - FileUtils.download_and_extract_archive(self.logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) + logger.info(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) + FileUtils.download_and_extract_archive(logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}" @@ -114,7 +117,7 @@ class TerraformLS(SolidLanguageServer): """ Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. """ - terraform_ls_executable_path = self.setup_runtime_dependencies() + terraform_ls_executable_path = self._setup_runtime_dependencies(logger) super().__init__( config, @@ -126,7 +129,8 @@ class TerraformLS(SolidLanguageServer): self.server_ready = threading.Event() self.request_id = 0 - def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the Terraform Language Server. """ From 1c2492c63c504add58327b964ff0045d83810989 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sun, 6 Jul 2025 17:58:13 +0200 Subject: [PATCH 6/6] Typo --- src/solidlsp/language_servers/terraform_ls/terraform_ls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py index b77de53..62f31cb 100644 --- a/src/solidlsp/language_servers/terraform_ls/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls/terraform_ls.py @@ -102,7 +102,7 @@ class TerraformLS(SolidLanguageServer): os.makedirs(terraform_ls_dir) if not os.path.exists(terraform_ls_executable_path): - logger.info(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) + logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO) FileUtils.download_and_extract_archive(logger, dependency["url"], terraform_ls_dir, dependency["archiveType"]) assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}"