From 37c4898bd6781781107c910d54a1e9740570bd0f Mon Sep 17 00:00:00 2001 From: Ketema Harris Date: Sun, 26 Oct 2025 18:30:40 -0400 Subject: [PATCH] feat: Add Haskell language server support (#697) --- .github/workflows/pytest.yml | 34 ++ .gitignore | 10 +- CHANGELOG.md | 1 + README.md | 1 + pyproject.toml | 1 + .../haskell_language_server.py | 388 ++++++++++++++++++ src/solidlsp/ls_config.py | 7 + .../repos/haskell/test_repo/app/Main.hs | 26 ++ .../haskell/test_repo/haskell-test-repo.cabal | 45 ++ .../repos/haskell/test_repo/package.yaml | 25 ++ .../repos/haskell/test_repo/src/Calculator.hs | 43 ++ .../repos/haskell/test_repo/src/Helper.hs | 22 + .../repos/haskell/test_repo/stack.yaml | 5 + test/solidlsp/haskell/__init__.py | 1 + test/solidlsp/haskell/test_haskell_basic.py | 219 ++++++++++ 15 files changed, 827 insertions(+), 1 deletion(-) create mode 100644 src/solidlsp/language_servers/haskell_language_server.py create mode 100644 test/resources/repos/haskell/test_repo/app/Main.hs create mode 100644 test/resources/repos/haskell/test_repo/haskell-test-repo.cabal create mode 100644 test/resources/repos/haskell/test_repo/package.yaml create mode 100644 test/resources/repos/haskell/test_repo/src/Calculator.hs create mode 100644 test/resources/repos/haskell/test_repo/src/Helper.hs create mode 100644 test/resources/repos/haskell/test_repo/stack.yaml create mode 100644 test/solidlsp/haskell/__init__.py create mode 100644 test/solidlsp/haskell/test_haskell_basic.py diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 6261a79e..22209e48 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -154,6 +154,40 @@ jobs: - name: Install Julia LanguageServer shell: bash run: julia -e 'using Pkg; Pkg.add("LanguageServer")' + - name: Setup Haskell toolchain + if: runner.os != 'Windows' + uses: haskell/ghcup-setup@v1 + with: + ghc: '9.12.2' + cabal: '3.10.3.0' + hls: '2.11.0.0' + - name: Verify Haskell tools + if: runner.os != 'Windows' + run: | + echo "Verifying installed Haskell tools:" + which ghc && ghc --version + which cabal && cabal --version + # HLS verification - non-blocking in case of version incompatibility + if command -v haskell-language-server-wrapper &>/dev/null; then + echo "Found haskell-language-server-wrapper" + haskell-language-server-wrapper --version || echo "WARNING: HLS wrapper found but version check failed" + elif command -v haskell-language-server &>/dev/null; then + echo "Found haskell-language-server" + haskell-language-server --version || echo "WARNING: HLS found but version check failed" + else + echo "WARNING: HLS not found (may be incompatible with GHC 9.12.2)" + echo "This is not a critical error - tests will use HLS if available at runtime" + fi + shell: bash + - name: Pre-build Haskell test project for HLS + if: runner.os != 'Windows' + run: | + cd test/resources/repos/haskell/test_repo + cabal update + cabal build --only-dependencies + cabal build + echo "Haskell test project built successfully" + shell: bash - name: Install Zig uses: goto-bus-stop/setup-zig@v2 with: diff --git a/.gitignore b/.gitignore index 32386b39..f71c7714 100644 --- a/.gitignore +++ b/.gitignore @@ -248,4 +248,12 @@ tmp/ .scala-build/ .bloop/ bootstrap -test/resources/repos/scala/.bloop/ \ No newline at end of file +test/resources/repos/scala/.bloop/ + +# Haskell +.stack-work/ +*.cabal +stack.yaml.lock +dist-newstyle/ +cabal.project.local* +.ghc.environment.* \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 91963c29..d7d323eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Status of the `main` branch. Changes prior to the next official version change w * Added `RenameSymbolTool` for renaming symbols across the codebase (if LS supports this operation). * Language support: + * **Add support for Haskell** via Haskell Language Server (HLS) with automatic discovery via ghcup, stack, or system PATH; supports both Stack and Cabal projects * **Add support for Scala** via Metals language server (requires some [manual setup](docs/scala_setup_guide_for_serena.md)) * **Add support for Elm** via @elm-tooling/elm-language-server (automatically downloads if not installed; requires Elm compiler) * **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files diff --git a/README.md b/README.md index 8e33859f..f6a1faea 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ With Serena, we provide direct, out-of-the-box support for: * Scala (requires some [manual setup](docs/scala_setup_guide_for_serena.md); uses Metals LSP) * Erlang (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls), experimental, might be slow or hang) * Perl (requires installation of Perl::LanguageServer) + * Haskell (automatically locates HLS via ghcup, stack, or system PATH; supports Stack and Cabal projects) * AL * Markdown (must be explicitly specified via `--language markdown` when generating project config, primarily useful for documentation-heavy projects) diff --git a/pyproject.toml b/pyproject.toml index d6d222bc..dde69b81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -281,6 +281,7 @@ markers = [ "rego: language server running for Rego", "markdown: language server running for Markdown", "julia: Julia language server tests", + "haskell: Haskell language server tests", ] [tool.codespell] diff --git a/src/solidlsp/language_servers/haskell_language_server.py b/src/solidlsp/language_servers/haskell_language_server.py new file mode 100644 index 00000000..1db2dc6a --- /dev/null +++ b/src/solidlsp/language_servers/haskell_language_server.py @@ -0,0 +1,388 @@ +""" +Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. +""" + +import logging +import os +import pathlib +import shutil +import time + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_logger import LanguageServerLogger +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + + +class HaskellLanguageServer(SolidLanguageServer): + """ + Provides Haskell specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Haskell. + Uses Haskell Language Server (HLS) for LSP functionality. + """ + + @staticmethod + def _ensure_hls_installed(): + """Ensure haskell-language-server-wrapper is available.""" + # Try common locations + common_paths = [ + shutil.which("haskell-language-server-wrapper"), + "/opt/homebrew/bin/haskell-language-server-wrapper", + "/usr/local/bin/haskell-language-server-wrapper", + os.path.expanduser("~/.ghcup/bin/haskell-language-server-wrapper"), + os.path.expanduser("~/.cabal/bin/haskell-language-server-wrapper"), + os.path.expanduser("~/.local/bin/haskell-language-server-wrapper"), + ] + + # Check Stack programs directory + stack_programs = os.path.expanduser("~/.local/share/stack/programs") + if os.path.exists(stack_programs): + try: + for arch_dir in os.listdir(stack_programs): + arch_path = os.path.join(stack_programs, arch_dir) + if os.path.isdir(arch_path): + try: + for ghc_dir in os.listdir(arch_path): + hls_path = os.path.join(arch_path, ghc_dir, "bin", "haskell-language-server-wrapper") + if os.path.isfile(hls_path) and os.access(hls_path, os.X_OK): + common_paths.append(hls_path) + except (PermissionError, OSError): + # Skip directories we can't read + continue + except (PermissionError, OSError): + # Stack programs directory not accessible + pass + + for path in common_paths: + if path and os.path.isfile(path) and os.access(path, os.X_OK): + return path + + raise RuntimeError( + "haskell-language-server-wrapper is not installed or not in PATH.\n" + "Searched locations:\n" + "\n".join(f" - {p}" for p in common_paths if p) + "\n" + "Please install HLS via:\n" + " - GHCup: https://www.haskell.org/ghcup/\n" + " - Stack: stack install haskell-language-server\n" + " - Cabal: cabal install haskell-language-server\n" + " - Homebrew (macOS): brew install haskell-language-server" + ) + + def __init__( + self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str, solidlsp_settings: SolidLSPSettings + ): + """ + Creates a HaskellLanguageServer instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead. + """ + hls_executable_path = self._ensure_hls_installed() + logger.log(f"Using haskell-language-server at: {hls_executable_path}", logging.INFO) + + # Check if there's a haskell subdirectory with Stack/Cabal project + haskell_subdir = os.path.join(repository_root_path, "haskell") + if os.path.exists(haskell_subdir) and ( + os.path.exists(os.path.join(haskell_subdir, "stack.yaml")) or os.path.exists(os.path.join(haskell_subdir, "cabal.project")) + ): + working_dir = haskell_subdir + logger.log(f"Using Haskell project directory: {working_dir}", logging.INFO) + else: + working_dir = repository_root_path + + # Set up environment with GHCup bin in PATH + env = dict(os.environ) + ghcup_bin = os.path.expanduser("~/.ghcup/bin") + if ghcup_bin not in env.get("PATH", ""): + env["PATH"] = f"{ghcup_bin}{os.pathsep}{env.get('PATH', '')}" + + super().__init__( + config, + logger, + repository_root_path, + ProcessLaunchInfo(cmd=[hls_executable_path, "--lsp", "--cwd", working_dir], cwd=working_dir, env=env), + "haskell", + solidlsp_settings, + ) + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["dist", "dist-newstyle", ".stack-work", ".cabal-sandbox"] + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """ + Returns the initialize params for the Haskell Language Server. + """ + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "clientInfo": {"name": "Serena", "version": "0.1.0"}, + "locale": "en", + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": { + "documentChanges": True, + "resourceOperations": ["create", "rename", "delete"], + "failureHandling": "textOnlyTransactional", + "normalizesLineEndings": True, + "changeAnnotationSupport": {"groupsOnLabel": True}, + }, + "configuration": True, + "didChangeWatchedFiles": {"dynamicRegistration": True, "relativePatternSupport": True}, + "symbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "tagSupport": {"valueSet": [1]}, + "resolveSupport": {"properties": ["location.range"]}, + }, + "executeCommand": {"dynamicRegistration": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "workspaceFolders": True, + "semanticTokens": {"refreshSupport": True}, + }, + "textDocument": { + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + "codeDescriptionSupport": True, + "dataSupport": True, + }, + "synchronization": {"dynamicRegistration": True, "willSave": True, "willSaveWaitUntil": True, "didSave": True}, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + "preselectSupport": True, + "tagSupport": {"valueSet": [1]}, + "insertReplaceSupport": True, + "resolveSupport": {"properties": ["documentation", "detail", "additionalTextEdits"]}, + "insertTextModeSupport": {"valueSet": [1, 2]}, + "labelDetailsSupport": True, + }, + "insertTextMode": 2, + "completionItemKind": { + "valueSet": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25] + }, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": ["markdown", "plaintext"], + "parameterInformation": {"labelOffsetSupport": True}, + "activeParameterSupport": True, + }, + "contextSupport": True, + }, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + "hierarchicalDocumentSymbolSupport": True, + "tagSupport": {"valueSet": [1]}, + "labelSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "isPreferredSupport": True, + "disabledSupport": True, + "dataSupport": True, + "resolveSupport": {"properties": ["edit"]}, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + "honorsChangeAnnotations": False, + }, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": { + "dynamicRegistration": True, + "prepareSupport": True, + "prepareSupportDefaultBehavior": 1, + "honorsChangeAnnotations": True, + }, + "documentLink": {"dynamicRegistration": True, "tooltipSupport": True}, + "typeDefinition": {"dynamicRegistration": True, "linkSupport": True}, + "implementation": {"dynamicRegistration": True, "linkSupport": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + "foldingRangeKind": {"valueSet": ["comment", "imports", "region"]}, + }, + "declaration": {"dynamicRegistration": True, "linkSupport": True}, + "selectionRange": {"dynamicRegistration": True}, + "callHierarchy": {"dynamicRegistration": True}, + "semanticTokens": { + "dynamicRegistration": True, + "tokenTypes": [ + "namespace", + "type", + "class", + "enum", + "interface", + "struct", + "typeParameter", + "parameter", + "variable", + "property", + "enumMember", + "event", + "function", + "method", + "macro", + "keyword", + "modifier", + "comment", + "string", + "number", + "regexp", + "operator", + ], + "tokenModifiers": [ + "declaration", + "definition", + "readonly", + "static", + "deprecated", + "abstract", + "async", + "modification", + "documentation", + "defaultLibrary", + ], + "formats": ["relative"], + "requests": {"range": True, "full": {"delta": True}}, + "multilineTokenSupport": False, + "overlappingTokenSupport": False, + }, + "linkedEditingRange": {"dynamicRegistration": True}, + }, + "window": { + "showMessage": {"messageActionItem": {"additionalPropertiesSupport": True}}, + "showDocument": {"support": True}, + "workDoneProgress": True, + }, + "general": { + "staleRequestSupport": {"cancel": True, "retryOnContentModified": []}, + "regularExpressions": {"engine": "ECMAScript", "version": "ES2020"}, + "markdown": { + "parser": "marked", + "version": "1.1.0", + }, + "positionEncodings": ["utf-16"], + }, + }, + "initializationOptions": { + "haskell": { + "formattingProvider": "ormolu", + "checkProject": True, + } + }, + "trace": "verbose", + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + } + return initialize_params + + def _start_server(self): + """ + Starts the Haskell Language Server + """ + + def do_nothing(params): + return + + def window_log_message(msg): + self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO) + + def register_capability_handler(params): + """Handle dynamic capability registration from HLS""" + if "registrations" in params: + for registration in params.get("registrations", []): + method = registration.get("method", "") + self.logger.log(f"HLS registered capability: {method}", logging.INFO) + return + + def workspace_configuration_handler(params): + """Handle workspace/configuration requests from HLS""" + self.logger.log(f"HLS requesting configuration: {params}", logging.INFO) + + # Configuration matching VS Code settings and initialization options + haskell_config = { + "formattingProvider": "ormolu", + "checkProject": True, + "plugin": {"importLens": {"codeActionsOn": False, "codeLensOn": False}, "hlint": {"codeActionsOn": False}}, + } + + # HLS expects array of config items matching requested sections + if isinstance(params, dict) and "items" in params: + result = [] + for item in params["items"]: + section = item.get("section", "") + if section == "haskell": + result.append(haskell_config) + else: + result.append({}) + self.logger.log(f"Returning configuration: {result}", logging.INFO) + return result + + # Fallback: return single config + return [haskell_config] + + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", workspace_configuration_handler) + + self.logger.log("Starting Haskell Language 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) + + # Log capabilities returned by HLS + capabilities = init_response.get("capabilities", {}) + self.logger.log(f"HLS capabilities: {list(capabilities.keys())}", logging.INFO) + + self.server.notify.initialized({}) + self.completions_available.set() + + # Give HLS time to index the project + # HLS can be slow to index, especially on first run + self.logger.log("Waiting for HLS to index project...", logging.INFO) + time.sleep(5) + + self.logger.log("Haskell Language Server initialized successfully", logging.INFO) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8c667cba..741ab43e 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -58,6 +58,7 @@ class Language(str, Enum): REGO = "rego" SCALA = "scala" JULIA = "julia" + HASKELL = "haskell" # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -158,6 +159,8 @@ class Language(str, Enum): return FilenameMatcher("*.scala", "*.sbt") case self.JULIA: return FilenameMatcher("*.jl") + case self.HASKELL: + return FilenameMatcher("*.hs", "*.lhs") case _: raise ValueError(f"Unhandled language: {self}") @@ -291,6 +294,10 @@ class Language(str, Enum): from solidlsp.language_servers.julia_server import JuliaLanguageServer return JuliaLanguageServer + case self.HASKELL: + from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer + + return HaskellLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/resources/repos/haskell/test_repo/app/Main.hs b/test/resources/repos/haskell/test_repo/app/Main.hs new file mode 100644 index 00000000..3f8dddb1 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/app/Main.hs @@ -0,0 +1,26 @@ +module Main (main) where + +import Calculator +import Helper + +main :: IO () +main = do + let calc = Calculator "TestCalc" 1 + putStrLn $ "Using " ++ calcName calc ++ " version " ++ show (calcVersion calc) + + -- Test add function (cross-file reference) + let result1 = add 5 3 + putStrLn $ "5 + 3 = " ++ show result1 + + -- Test subtract (uses validateNumber from Helper) + let result2 = Calculator.subtract 10 4 + putStrLn $ "10 - 4 = " ++ show result2 + + -- Test calculate function + case calculate calc "multiply" 6 7 of + Just result -> putStrLn $ "6 * 7 = " ++ show result + Nothing -> putStrLn "Calculation failed" + + -- Test helper functions directly + putStrLn $ "Is 5 positive? " ++ show (isPositive 5) + putStrLn $ "Absolute of -10: " ++ show (absolute (-10)) diff --git a/test/resources/repos/haskell/test_repo/haskell-test-repo.cabal b/test/resources/repos/haskell/test_repo/haskell-test-repo.cabal new file mode 100644 index 00000000..809b2a9e --- /dev/null +++ b/test/resources/repos/haskell/test_repo/haskell-test-repo.cabal @@ -0,0 +1,45 @@ +cabal-version: 1.12 + +-- This file has been generated from package.yaml by hpack version 0.38.1. +-- +-- see: https://github.com/sol/hpack + +name: haskell-test-repo +version: 0.1.0.0 +homepage: https://github.com/test/haskell-test-repo#readme +bug-reports: https://github.com/test/haskell-test-repo/issues +author: Test Author +maintainer: test@example.com +license: BSD3 +build-type: Simple + +source-repository head + type: git + location: https://github.com/test/haskell-test-repo + +library + exposed-modules: + Calculator + Helper + other-modules: + Paths_haskell_test_repo + hs-source-dirs: + src + default-extensions: + OverloadedStrings + build-depends: + base >=4.7 && <5 + default-language: Haskell2010 + +executable haskell-test-repo-exe + main-is: Main.hs + other-modules: + Paths_haskell_test_repo + hs-source-dirs: + app + default-extensions: + OverloadedStrings + build-depends: + base >=4.7 && <5 + , haskell-test-repo + default-language: Haskell2010 diff --git a/test/resources/repos/haskell/test_repo/package.yaml b/test/resources/repos/haskell/test_repo/package.yaml new file mode 100644 index 00000000..f489e49b --- /dev/null +++ b/test/resources/repos/haskell/test_repo/package.yaml @@ -0,0 +1,25 @@ +name: haskell-test-repo +version: 0.1.0.0 +github: "test/haskell-test-repo" +license: BSD3 +author: "Test Author" +maintainer: "test@example.com" + +dependencies: + - base >= 4.7 && < 5 + +library: + source-dirs: src + exposed-modules: + - Calculator + - Helper + +executables: + haskell-test-repo-exe: + main: Main.hs + source-dirs: app + dependencies: + - haskell-test-repo + +default-extensions: + - OverloadedStrings diff --git a/test/resources/repos/haskell/test_repo/src/Calculator.hs b/test/resources/repos/haskell/test_repo/src/Calculator.hs new file mode 100644 index 00000000..40c1839b --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/Calculator.hs @@ -0,0 +1,43 @@ +module Calculator + ( Calculator(..) + , add + , subtract + , multiply + , divide + , calculate + ) where + +import Prelude hiding (subtract) +import Helper (validateNumber) + +-- | A simple calculator data type +data Calculator = Calculator + { calcName :: String + , calcVersion :: Int + } deriving (Show, Eq) + +-- | Add two numbers +add :: Int -> Int -> Int +add x y = validateNumber x + validateNumber y + +-- | Subtract two numbers +subtract :: Int -> Int -> Int +subtract x y = validateNumber x - validateNumber y + +-- | Multiply two numbers +multiply :: Int -> Int -> Int +multiply x y = x * y + +-- | Divide two numbers (returns Maybe to handle division by zero) +divide :: Int -> Int -> Maybe Int +divide _ 0 = Nothing +divide x y = Just (x `div` y) + +-- | Perform a calculation based on operator +calculate :: Calculator -> String -> Int -> Int -> Maybe Int +calculate calc op x y = case op of + "add" -> Just (add x y) + "subtract" -> Just (subtract x y) + "multiply" -> Just (multiply x y) + "divide" -> divide x y + _ -> Nothing diff --git a/test/resources/repos/haskell/test_repo/src/Helper.hs b/test/resources/repos/haskell/test_repo/src/Helper.hs new file mode 100644 index 00000000..9fa04c38 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/src/Helper.hs @@ -0,0 +1,22 @@ +module Helper + ( validateNumber + , isPositive + , isNegative + , absolute + ) where + +-- | Validate that a number is not zero (for demonstration) +validateNumber :: Int -> Int +validateNumber x = if x == 0 then error "Zero not allowed" else x + +-- | Check if a number is positive +isPositive :: Int -> Bool +isPositive x = x > 0 + +-- | Check if a number is negative +isNegative :: Int -> Bool +isNegative x = x < 0 + +-- | Get absolute value +absolute :: Int -> Int +absolute x = if isNegative x then negate x else x diff --git a/test/resources/repos/haskell/test_repo/stack.yaml b/test/resources/repos/haskell/test_repo/stack.yaml new file mode 100644 index 00000000..1ecc3671 --- /dev/null +++ b/test/resources/repos/haskell/test_repo/stack.yaml @@ -0,0 +1,5 @@ +resolver: ghc-9.8.4 +system-ghc: true +install-ghc: false +packages: + - . diff --git a/test/solidlsp/haskell/__init__.py b/test/solidlsp/haskell/__init__.py new file mode 100644 index 00000000..760bf8f8 --- /dev/null +++ b/test/solidlsp/haskell/__init__.py @@ -0,0 +1 @@ +# Haskell language server tests diff --git a/test/solidlsp/haskell/test_haskell_basic.py b/test/solidlsp/haskell/test_haskell_basic.py new file mode 100644 index 00000000..062cc9ea --- /dev/null +++ b/test/solidlsp/haskell/test_haskell_basic.py @@ -0,0 +1,219 @@ +""" +Rigorous tests for Haskell Language Server integration with Serena. + +Tests prove that Serena's symbol tools can: +1. Discover all expected symbols with precise matching +2. Track cross-file references accurately +3. Identify data type structures and record fields +4. Navigate between definitions and usages + +Test Repository Structure: +- src/Calculator.hs: Calculator data type, arithmetic functions (add, subtract, multiply, divide, calculate) +- src/Helper.hs: Helper functions (validateNumber, isPositive, isNegative, absolute) +- app/Main.hs: Main entry point using Calculator and Helper modules +""" + +import sys + +import pytest + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.haskell +@pytest.mark.skipif(sys.platform == "win32", reason="HLS not installed on Windows CI") +class TestHaskellLanguageServer: + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_calculator_module_symbols(self, language_server: SolidLanguageServer): + """ + Test precise symbol discovery in Calculator.hs. + + Verifies that Serena can identify: + - Data type definition (Calculator with record fields) + - All exported functions with correct names + - Module structure + """ + all_symbols, _ = language_server.request_document_symbols("src/Calculator.hs") + symbol_names = {s["name"] for s in all_symbols} + + # Verify exact set of expected top-level symbols + expected_symbols = { + "Calculator", # Data type + "add", # Function: Int -> Int -> Int + "subtract", # Function: Int -> Int -> Int + "multiply", # Function: Int -> Int -> Int + "divide", # Function: Int -> Int -> Maybe Int + "calculate", # Function: Calculator -> String -> Int -> Int -> Maybe Int + } + + # Verify all expected symbols are present + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Calculator.hs: {missing}" + + # Verify Calculator data type exists + calculator_symbol = next((s for s in all_symbols if s["name"] == "Calculator"), None) + assert calculator_symbol is not None, "Calculator data type not found" + + # The Calculator should be identified as a data type + # HLS may use different SymbolKind values (1=File, 5=Class, 23=Struct) + assert calculator_symbol["kind"] in [ + 1, + 5, + 23, + ], f"Calculator should be a data type (kind 1, 5, or 23), got kind {calculator_symbol['kind']}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_helper_module_symbols(self, language_server: SolidLanguageServer): + """ + Test precise symbol discovery in Helper.hs. + + Verifies Serena identifies all helper functions that are imported + and used by Calculator module. + """ + all_symbols, _ = language_server.request_document_symbols("src/Helper.hs") + symbol_names = {s["name"] for s in all_symbols} + + # Verify expected helper functions (module name may also appear) + expected_symbols = { + "validateNumber", # Function used by Calculator.add and Calculator.subtract + "isPositive", # Predicate function + "isNegative", # Predicate function used by absolute + "absolute", # Function that uses isNegative + } + + # All expected symbols should be present (module name is optional) + missing = expected_symbols - symbol_names + assert not missing, f"Missing expected symbols in Helper.hs: {missing}" + + # Verify no unexpected symbols beyond the module name + extra = symbol_names - expected_symbols - {"Helper"} + assert not extra, f"Unexpected symbols in Helper.hs: {extra}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_main_module_imports(self, language_server: SolidLanguageServer): + """ + Test that Main.hs properly references both Calculator and Helper modules. + + Verifies Serena can identify cross-module dependencies. + """ + all_symbols, _ = language_server.request_document_symbols("app/Main.hs") + symbol_names = {s["name"] for s in all_symbols} + + # Main.hs should have the main function + assert "main" in symbol_names, "Main.hs should contain 'main' function" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_cross_file_references_validateNumber(self, language_server: SolidLanguageServer): + """ + Test cross-file reference tracking for validateNumber function. + + validateNumber is defined in Helper.hs:9 and used in: + - Calculator.hs:21 (in add function) + - Calculator.hs:25 (in subtract function) + + This proves Serena can track function usage across module boundaries. + """ + # Get references to validateNumber (defined at line 9, 0-indexed = line 8) + references = language_server.request_references("src/Helper.hs", line=8, column=0) + + # Should find at least: definition in Helper.hs + 2 usages in Calculator.hs + assert len(references) >= 2, f"Expected at least 2 references to validateNumber (used in add and subtract), got {len(references)}" + + # Verify we have references in Calculator.hs + reference_paths = [ref["relativePath"] for ref in references] + calculator_refs = [path for path in reference_paths if "Calculator.hs" in path] + + assert len(calculator_refs) >= 2, ( + f"Expected at least 2 references in Calculator.hs (add and subtract functions), " + f"got {len(calculator_refs)} references in Calculator.hs" + ) + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_within_file_references_isNegative(self, language_server: SolidLanguageServer): + """ + Test within-file reference tracking for isNegative function. + + isNegative is defined in Helper.hs:17 and used in Helper.hs:22 (absolute function). + This proves Serena can track intra-module function calls. + """ + # isNegative defined at line 17 (0-indexed = line 16) + references = language_server.request_references("src/Helper.hs", line=16, column=0) + + # Should find: definition + usage in absolute function + assert len(references) >= 1, f"Expected at least 1 reference to isNegative (used in absolute), got {len(references)}" + + # All references should be in Helper.hs + reference_paths = [ref["relativePath"] for ref in references] + assert all( + "Helper.hs" in path for path in reference_paths + ), f"All isNegative references should be in Helper.hs, got: {reference_paths}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_function_references_from_main(self, language_server: SolidLanguageServer): + """ + Test that functions used in Main.hs can be traced back to their definitions. + + Main.hs:12 calls 'add' from Calculator module. + Main.hs:25 calls 'isPositive' from Helper module. + Main.hs:26 calls 'absolute' from Helper module. + + This proves Serena can track cross-module function calls from executable code. + """ + # Test 'add' function references (defined in Calculator.hs:20, 0-indexed = line 19) + add_refs = language_server.request_references("src/Calculator.hs", line=19, column=0) + + # Should find references in Main.hs and possibly Calculator.hs (calculate function uses it) + assert len(add_refs) >= 1, f"Expected at least 1 reference to 'add', got {len(add_refs)}" + + add_ref_paths = [ref["relativePath"] for ref in add_refs] + # Should have at least one reference in Main.hs or Calculator.hs + assert any( + "Main.hs" in path or "Calculator.hs" in path for path in add_ref_paths + ), f"Expected 'add' to be referenced in Main.hs or Calculator.hs, got: {add_ref_paths}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_multiply_function_usage_in_calculate(self, language_server: SolidLanguageServer): + """ + Test that multiply function usage is tracked within Calculator module. + + multiply is defined in Calculator.hs:28 and used in: + - Calculator.hs:41 (in calculate function via pattern matching) + - Main.hs:20 (via calculate call with "multiply" operator) + + This proves Serena can track function references even when called indirectly. + """ + # multiply defined at line 28 (0-indexed = line 27) + multiply_refs = language_server.request_references("src/Calculator.hs", line=27, column=0) + + # Should find at least the usage in calculate function + assert len(multiply_refs) >= 1, f"Expected at least 1 reference to 'multiply', got {len(multiply_refs)}" + + # Should have reference in Calculator.hs (calculate function) + multiply_ref_paths = [ref["relativePath"] for ref in multiply_refs] + assert any( + "Calculator.hs" in path for path in multiply_ref_paths + ), f"Expected 'multiply' to be referenced in Calculator.hs, got: {multiply_ref_paths}" + + @pytest.mark.parametrize("language_server", [Language.HASKELL], indirect=True) + def test_data_type_constructor_references(self, language_server: SolidLanguageServer): + """ + Test that Calculator data type constructor usage is tracked. + + Calculator is defined in Calculator.hs:14 and used in: + - Main.hs:8 (constructor call: Calculator "TestCalc" 1) + - Calculator.hs:37 (type signature for calculate function) + + This proves Serena can track data type constructor references. + """ + # Calculator data type defined at line 14 (0-indexed = line 13) + calculator_refs = language_server.request_references("src/Calculator.hs", line=13, column=5) + + # Should find usage in Main.hs + assert len(calculator_refs) >= 1, f"Expected at least 1 reference to Calculator constructor, got {len(calculator_refs)}" + + # Should have at least one reference in Main.hs or Calculator.hs + calc_ref_paths = [ref["relativePath"] for ref in calculator_refs] + assert any( + "Main.hs" in path or "Calculator.hs" in path for path in calc_ref_paths + ), f"Expected Calculator to be referenced in Main.hs or Calculator.hs, got: {calc_ref_paths}"