diff --git a/CHANGELOG.md b/CHANGELOG.md index bb3c83976..031096364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Language support: + * **Add support for OpenTofu** via the existing Terraform language server integration, with CLI discovery for both `tofu` and `terraform` * **Add support for Lean 4** via built-in `lean --server` with cross-file reference support (requires `lean` and `lake` via [elan](https://github.com/leanprover/elan)) * **Add support for OCaml** via ocaml-lsp-server with cross-file reference support on OCaml 5.2+ (requires opam; see [setup guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md)) * **Add Phpactor as alternative PHP language server** (specify `php_phpactor` as language; requires PHP 8.1+) diff --git a/README.md b/README.md index bd3d0f190..a6c40b38e 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,12 @@ You can choose either of these backends depending on your preferences and requir ### Language Servers -Serena incorporates a powerful abstraction layer for the integration of language servers that implement the language server protocol (LSP). -The underlying language servers are typically open-source projects or at least freely available for use. +Serena incorporates a powerful abstraction layer for the integration of language servers +that implement the language server protocol (LSP). +The underlying language servers are typically open-source projects (like Serena) or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +AL, Ansible, Bash, C#, C/C++, Clojure, Crystal, Dart, Elixir, Elm, Erlang, Fortran, F#, GLSL, Go, Groovy, Haskell, HLSL, Java, JavaScript, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, Nix, OCaml, OpenTofu Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Solidity, Swift, TOML, TypeScript, WGSL, YAML, and Zig. ### The Serena JetBrains Plugin diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index c26c07f9e..0b46cce8b 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -84,6 +84,8 @@ Some languages require additional installations or setup steps, as noted. (requires nixd installation) * **OCaml** (requires opam and ocaml-lsp-server to be installed manually; see the [OCaml Setup Guide](../03-special-guides/ocaml_setup_guide_for_serena.md)) +* **OpenTofu** + (language `opentofu`; uses the Terraform language server and supports repositories that have `tofu` or `terraform` installed) * **Pascal** (uses Pascal/Lazarus, which is automatically downloaded; set `PP` and `FPCDIR` environment variables for source navigation) * **Perl** diff --git a/pyproject.toml b/pyproject.toml index 66e4cf768..48a3a384b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -312,6 +312,7 @@ markers = [ "elixir: language server running for Elixir", "elm: language server running for Elm", "terraform: language server running for Terraform", + "opentofu: language server running for OpenTofu", "swift: language server running for Swift", "bash: language server running for Bash", "r: language server running for R", diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index f6ba8cb14..ed8b0e996 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -10,7 +10,8 @@ project_name: "project_name" # matlab nix pascal perl php # php_phpactor powershell python python_jedi r # rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts +# swift terraform opentofu toml typescript +# typescript_vts # vue yaml zig # (This list may be outdated. For the current list, see values of Language enum here: # https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py diff --git a/src/solidlsp/language_servers/terraform_ls.py b/src/solidlsp/language_servers/terraform_ls.py index ec26126d1..bdecdc1af 100644 --- a/src/solidlsp/language_servers/terraform_ls.py +++ b/src/solidlsp/language_servers/terraform_ls.py @@ -21,7 +21,7 @@ class TerraformLS(SolidLanguageServer): """ - Provides Terraform specific instantiation of the LanguageServer class using terraform-ls. + Provides Terraform/OpenTofu specific instantiation of the LanguageServer class using terraform-ls. You can pass the following entries in ``ls_specific_settings["terraform"]``: - terraform_ls_version: Override the pinned terraform-ls version downloaded @@ -62,33 +62,41 @@ def _determine_log_level(line: str) -> int: @staticmethod def _ensure_tf_command_available() -> None: - log.debug("Starting terraform version detection...") + log.debug("Starting Terraform/OpenTofu CLI detection...") + + # 1. Try standard CLI names from PATH + for binary_name in ["terraform", "tofu"]: + cli_cmd = shutil.which(binary_name) + if cli_cmd is not None: + log.debug(f"Found {binary_name} via shutil.which: {cli_cmd}") + return + + terraform_cmd = None + + # 2. Fallback to explicitly configured CLI paths + for env_var_name, binary_name in [("TOFU_CLI_PATH", "tofu"), ("TERRAFORM_CLI_PATH", "terraform")]: + cli_path = os.environ.get(env_var_name) + if not cli_path: + continue + + log.debug(f"Trying {env_var_name}: {cli_path}") + if os.name == "nt": + cli_binary = os.path.join(cli_path, f"{binary_name}.exe") + else: + cli_binary = os.path.join(cli_path, binary_name) + + if os.path.exists(cli_binary): + terraform_cmd = cli_binary + log.debug(f"Found {binary_name} via {env_var_name}: {terraform_cmd}") + return - # 1. Try to find terraform using shutil.which - terraform_cmd = shutil.which("terraform") if terraform_cmd is not None: - log.debug(f"Found terraform via shutil.which: {terraform_cmd}") return - # TODO: is this needed? - # 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: - log.debug(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}") - # TODO: use binary name from runtime dependencies if we keep this code - 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 - log.debug(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}") - return - raise RuntimeError( - "Terraform executable not found, please ensure Terraform is installed." - "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli for instructions." + "Terraform/OpenTofu executable not found, please ensure Terraform or OpenTofu is installed." + "See https://developer.hashicorp.com/terraform/tutorials/aws-get-started/install-cli" + " or https://opentofu.org/docs/intro/install/ for instructions." ) @classmethod diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 4ec425c10..1135b0f43 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -49,6 +49,7 @@ class Language(str, Enum): ELIXIR = "elixir" ELM = "elm" TERRAFORM = "terraform" + OPENTOFU = "opentofu" SWIFT = "swift" BASH = "bash" CRYSTAL = "crystal" @@ -231,8 +232,8 @@ def get_source_fn_matcher(self) -> FilenameMatcher: return FilenameMatcher("*.ex", "*.exs") case self.ELM: return FilenameMatcher("*.elm") - case self.TERRAFORM: - return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate") + case self.TERRAFORM | self.OPENTOFU: + return FilenameMatcher("*.tf") case self.SWIFT: return FilenameMatcher("*.swift") case self.BASH: @@ -411,7 +412,7 @@ def get_ls_class(self) -> type["SolidLanguageServer"]: from solidlsp.language_servers.elm_language_server import ElmLanguageServer return ElmLanguageServer - case self.TERRAFORM: + case self.TERRAFORM | self.OPENTOFU: from solidlsp.language_servers.terraform_ls import TerraformLS return TerraformLS diff --git a/test/conftest.py b/test/conftest.py index 484434841..f3454780a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -39,6 +39,7 @@ class LanguageParamRequest: _LANGUAGE_REPO_ALIASES: dict[Language, Language] = { Language.CPP_CCLS: Language.CPP, + Language.OPENTOFU: Language.TERRAFORM, Language.PHP_PHPACTOR: Language.PHP, Language.PYTHON_JEDI: Language.PYTHON, Language.RUBY_SOLARGRAPH: Language.RUBY, diff --git a/test/solidlsp/opentofu/test_opentofu_basic.py b/test/solidlsp/opentofu/test_opentofu_basic.py new file mode 100644 index 000000000..91d3009f6 --- /dev/null +++ b/test/solidlsp/opentofu/test_opentofu_basic.py @@ -0,0 +1,40 @@ +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.opentofu +class TestLanguageServerBasics: + """Test basic functionality of the OpenTofu language server configuration.""" + + @pytest.mark.parametrize("language_server", [Language.OPENTOFU], indirect=True) + def test_basic_definition(self, language_server: SolidLanguageServer) -> None: + """Test basic definition lookup functionality.""" + file_path = "main.tf" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find at least some symbols in main.tf" + + @pytest.mark.parametrize("language_server", [Language.OPENTOFU], indirect=True) + def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None: + """Test request_references on an aws_instance resource.""" + file_path = "main.tf" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + 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.OPENTOFU], indirect=True) + def test_request_references_variable(self, language_server: SolidLanguageServer) -> None: + """Test request_references on a variable.""" + file_path = "variables.tf" + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + 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" diff --git a/test/solidlsp/terraform/test_terraform_basic.py b/test/solidlsp/terraform/test_terraform_basic.py index 612e3f9a4..2c5a9338b 100644 --- a/test/solidlsp/terraform/test_terraform_basic.py +++ b/test/solidlsp/terraform/test_terraform_basic.py @@ -21,6 +21,19 @@ class TestLanguageServerBasics: """Test basic functionality of the Terraform language server.""" + def test_source_matcher_excludes_tfvars_and_tfstate(self) -> None: + """Test that indexing targets only Terraform source files.""" + terraform_matcher = Language.TERRAFORM.get_source_fn_matcher() + opentofu_matcher = Language.OPENTOFU.get_source_fn_matcher() + + assert terraform_matcher.is_relevant_filename("main.tf") + assert opentofu_matcher.is_relevant_filename("main.tf") + + assert not terraform_matcher.is_relevant_filename("terraform.tfvars") + assert not terraform_matcher.is_relevant_filename("terraform.tfstate") + assert not opentofu_matcher.is_relevant_filename("terraform.tfvars") + assert not opentofu_matcher.is_relevant_filename("terraform.tfstate") + @pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True) def test_basic_definition(self, language_server: SolidLanguageServer) -> None: """Test basic definition lookup functionality."""