feat: Add comprehensive OCaml language support with ocaml-lsp-server integration (#573)

This commit is contained in:
John Haley authored and GitHub committed 2026-03-04 21:56:49 +01:00
1 parent 2c901b0a0e
commit 13e20c1cbd
19 files changed
+899 -5

No files matched your search

+17
View File
@@ -199,6 +199,23 @@ jobs:
- name: Install Ruby language server
shell: bash
run: gem install ruby-lsp
- name: Install OCaml and opam
uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: ${{ runner.os == 'Windows' && '4.14' || '5.3.x' }}
dune-cache: true
opam-repositories: |
${{ runner.os == 'Windows' && 'opam-repository-mingw: https://github.com/ocaml-opam/opam-repository-mingw.git#sunset' || '' }}
default: https://github.com/ocaml/opam-repository.git
- name: Install OCaml packages
shell: bash
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
opam install -y dune ocaml-lsp-server
else
# Require ocaml-lsp-server >= 1.23.0 for cross-file reference support
opam install -y dune 'ocaml-lsp-server>=1.23.0'
fi
- name: Install R
uses: r-lib/actions/setup-r@v2
with:
+7 -1
View File
@@ -226,6 +226,9 @@ tmp/
# Exception: Don't ignore Nix test repository lib directory (contains source code)
!/test/resources/repos/nix/test_repo/lib
# Exception: Don't ignore OCaml test repository lib directory (contains source code)
!/test/resources/repos/ocaml/test_repo/lib
# Exception: Don't ignore Julia test repository lib directory (contains source code)
!/test/resources/repos/julia/test_repo/lib
@@ -233,6 +236,9 @@ tmp/
/test/resources/repos/swift/test_repo/.build
/test/resources/repos/swift/test_repo/.swiftpm
# OCaml
/test/resources/repos/ocaml/test_repo/_build
# Elm
/test/resources/repos/elm/test_repo/.elm/
/test/resources/repos/elm/test_repo/elm-stuff/
@@ -254,4 +260,4 @@ cabal.project.local*
.ghc.environment.*
zz-misc/
vue-implementation/
vue-implementation/
+1
View File
@@ -39,6 +39,7 @@ Status of the `main` branch. Changes prior to the next official version change w
* Language support:
* **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+)
* **Add support for Fortran** via fortls language server (requires `pip install fortls`)
* **Add partial support for Groovy** requires user-provided Groovy language server JAR (see [setup guide](docs/03-special-guides/groovy_setup_guide_for_serena.md))
+5 -4
View File
@@ -82,22 +82,23 @@ 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.
With Serena's LSP library, we provide **support for over 30 programming languages**, including
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
AL, Bash, C#, C/C++, Clojure, Dart, Elixir, Elm, Erlang, Fortran, GLSL, Go, Groovy (partial support), Haskell, HLSL, Java, Javascript, Julia, Kotlin, Lua, Markdown, MATLAB, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, Swift, TOML, TypeScript, WGSL, YAML, and Zig.
> [!IMPORTANT]
> Some language servers require additional dependencies to be installed; see the [Language Support](https://oraios.github.io/serena/01-about/020_programming-languages.html) page for details.
> OCaml requires opam and ocaml-lsp-server to be installed manually; see the [OCaml Setup Guide](docs/03-special-guides/ocaml_setup_guide_for_serena.md).
### The Serena JetBrains Plugin
As an alternative to language servers, the [Serena JetBrains Plugin](https://plugins.jetbrains.com/plugin/28946-serena/)
leverages the powerful code analysis capabilities of your JetBrains IDE.
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
leverages the powerful code analysis capabilities of your JetBrains IDE.
The plugin naturally supports all programming languages and frameworks that are supported by JetBrains IDEs,
including IntelliJ IDEA, PyCharm, Android Studio, WebStorm, PhpStorm, RubyMine, GoLand, CLion, and others.
Only Rider is not supported.
<a href="https://plugins.jetbrains.com/plugin/28946-serena/"><img src="docs/_static/images/jetbrains-marketplace-button.png"></a>
The plugin offers the most robust and most powerful Serena experience.
The plugin offers the most robust and most powerful Serena experience.
See our [documentation page](https://oraios.github.io/serena/02-usage/025_jetbrains_plugin.html) for further details and instructions.
## Quick Start
@@ -0,0 +1,88 @@
# OCaml Setup Guide for Serena
This guide explains how to set up an OCaml project so that Serena can provide code intelligence via ocaml-lsp-server (ocamllsp).
Unlike some other languages, Serena does not download the OCaml language server automatically. You must install it yourself via opam, as OCaml tooling is compiled from source against your specific environment.
---
## Prerequisites
Install the following on your system and ensure they are available on `PATH`:
- **opam** (OCaml package manager)
- macOS: `brew install opam`
- Ubuntu/Debian: `sudo apt install opam`
- Fedora: `sudo dnf install opam`
- Other: https://opam.ocaml.org/doc/Install.html
- **OCaml compiler** (via opam)
- OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is **not supported** by ocaml-lsp-server)
- Recommended: OCaml 4.14.x (stable) or 5.2+ (for cross-file references)
- **ocaml-lsp-server** (via opam)
- **dune** (build system, via opam)
---
## Installation
1. Initialize opam if you haven't already:
```bash
opam init
eval $(opam env)
```
2. Create an opam switch with a compatible OCaml version:
```bash
# For cross-file reference support (recommended)
opam switch create serena-ocaml ocaml-base-compiler.5.2.1
eval $(opam env)
# Or for stable OCaml 4.14.x
opam switch create serena-ocaml ocaml-base-compiler.4.14.2
eval $(opam env)
```
3. Install the language server and build tools:
```bash
opam install ocaml-lsp-server dune
```
4. Verify the installation:
```bash
opam exec -- ocamllsp --version
opam exec -- ocaml -version
```
---
## Cross-File References
Cross-file reference support (finding all usages of a symbol across your project) requires:
- OCaml >= 5.2
- ocaml-lsp-server >= 1.23.0
- dune >= 3.16.0
When these requirements are met, Serena automatically builds the cross-file index during startup via `dune build @ocaml-index`. Without these versions, references are limited to the current file.
---
## Using Serena with OCaml
- Serena automatically detects OCaml files (`*.ml`, `*.mli`) and Reason files (`*.re`, `*.rei`).
- The language server is started via `opam exec -- ocamllsp`, so your opam environment must be configured.
- Ensure your project builds successfully with `dune build` before using Serena for best results.
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "opam not found" | Install opam and add it to PATH |
| "OCaml 5.1.0 is incompatible" | Create a new switch: `opam switch create <name> ocaml-base-compiler.5.2.1` |
| "ocaml-lsp-server not found" | `opam install ocaml-lsp-server` |
| Cross-file refs not working | Ensure OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0; run `dune build` first |
| Stale index | Rebuild with `dune build @ocaml-index` |
---
## Reference
- opam: https://opam.ocaml.org
- ocaml-lsp-server: https://github.com/ocaml/ocaml-lsp
- Project-wide occurrences: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847
+1
View File
@@ -310,6 +310,7 @@ markers = [
"nix: language server running for Nix",
"dart: language server running for Dart",
"erlang: language server running for Erlang",
"ocaml: language server running for OCaml and Reason",
"scala: language server running for Scala",
"al: language server running for AL (Microsoft Dynamics 365 Business Central)",
"fsharp: language server running for F#",
@@ -0,0 +1,432 @@
"""
Provides OCaml and Reason specific instantiation of the SolidLanguageServer class.
Contains various configurations and settings specific to OCaml and Reason.
"""
import logging
import os
import pathlib
import platform
import re
import shutil
import stat
import subprocess
import threading
from typing import Any
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
from solidlsp.settings import SolidLSPSettings
from solidlsp.util.subprocess_util import subprocess_kwargs
log = logging.getLogger(__name__)
class OcamlLanguageServer(SolidLanguageServer):
"""
Provides OCaml and Reason specific instantiation of the SolidLanguageServer class.
Contains various configurations and settings specific to OCaml and Reason.
"""
_ocaml_version: tuple[int, int, int]
_lsp_version: tuple[int, int, int]
_index_built: bool
# Minimum LSP version for reliable cross-file references
MIN_LSP_VERSION_FOR_CROSS_FILE_REFS: tuple[int, int, int] = (1, 23, 0)
@staticmethod
def _ensure_opam_installed() -> None:
"""Ensure OPAM is installed and available."""
opam_path = shutil.which("opam")
if opam_path is None:
raise RuntimeError(
"OPAM is not installed or not in PATH.\n"
"Please install OPAM from: https://opam.ocaml.org/doc/Install.html\n\n"
"Installation instructions:\n"
" - macOS: brew install opam\n"
" - Ubuntu/Debian: sudo apt install opam\n"
" - Fedora: sudo dnf install opam\n"
" - Windows: https://fdopen.github.io/opam-repository-mingw/installation/\n\n"
"After installation, initialize OPAM with: opam init"
)
@staticmethod
def _detect_ocaml_version(repository_root_path: str) -> tuple[int, int, int]:
"""
Detect and return the OCaml version as a tuple (major, minor, patch).
Also checks for version compatibility with ocaml-lsp-server.
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
["opam", "exec", "--", "ocaml", "-version"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", result.stdout)
if version_match:
major = int(version_match.group(1))
minor = int(version_match.group(2))
patch = int(version_match.group(3))
version_tuple = (major, minor, patch)
version_str = f"{major}.{minor}.{patch}"
log.info(f"OCaml version: {version_str}")
if version_tuple == (5, 1, 0):
raise RuntimeError(
f"OCaml {version_str} is incompatible with ocaml-lsp-server.\n"
"Please use OCaml < 5.1 or >= 5.1.1.\n"
"Consider creating a new opam switch:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2"
)
return version_tuple
raise RuntimeError(
f"Could not parse OCaml version from output: {result.stdout.strip()}\n"
"Please ensure OCaml is properly installed: opam exec -- ocaml -version"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to detect OCaml version: {e.stderr}\n"
"Please ensure OCaml is installed and opam is configured:\n"
" opam switch show\n"
" opam exec -- ocaml -version"
) from e
except FileNotFoundError as e:
raise RuntimeError(
"OCaml not found. Please install OCaml via opam:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2\n"
" eval $(opam env)"
) from e
@staticmethod
def _detect_lsp_version(repository_root_path: str) -> tuple[int, int, int]:
"""
Detect and return the ocaml-lsp-server version as a tuple (major, minor, patch).
Raises RuntimeError if version cannot be determined.
"""
try:
result = subprocess.run(
["opam", "list", "-i", "ocaml-lsp-server", "--columns=version", "--short"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
version_str = result.stdout.strip()
version_match = re.search(r"(\d+)\.(\d+)\.(\d+)", version_str)
if version_match:
major = int(version_match.group(1))
minor = int(version_match.group(2))
patch = int(version_match.group(3))
version_tuple = (major, minor, patch)
log.info(f"ocaml-lsp-server version: {major}.{minor}.{patch}")
return version_tuple
raise RuntimeError(
f"Could not parse ocaml-lsp-server version from output: {version_str}\n"
"Please ensure ocaml-lsp-server is properly installed:\n"
" opam list -i ocaml-lsp-server"
)
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to detect ocaml-lsp-server version: {e.stderr}\n"
"Please install ocaml-lsp-server:\n"
" opam install ocaml-lsp-server"
) from e
except FileNotFoundError as e:
raise RuntimeError("opam not found. Please install opam:\n https://opam.ocaml.org/doc/Install.html") from e
@staticmethod
def _ensure_ocaml_lsp_installed(repository_root_path: str) -> str:
"""
Ensure ocaml-lsp-server is installed and return the executable path.
Raises RuntimeError with helpful message if not installed.
"""
# Check if ocaml-lsp-server is installed
try:
result = subprocess.run(
["opam", "list", "-i", "ocaml-lsp-server"],
check=False,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
if "ocaml-lsp-server" not in result.stdout or "# No matches found" in result.stdout:
raise RuntimeError(
"ocaml-lsp-server is not installed.\n\n"
"Please install it with:\n"
" opam install ocaml-lsp-server\n\n"
"Note: ocaml-lsp-server requires OCaml < 5.1 or >= 5.1.1 (OCaml 5.1.0 is not supported).\n"
"If you have OCaml 5.1.0, create a new opam switch with a compatible version:\n"
" opam switch create <name> ocaml-base-compiler.4.14.2\n"
" opam switch <name>\n"
" eval $(opam env)\n"
" opam install ocaml-lsp-server\n\n"
"For more information: https://github.com/ocaml/ocaml-lsp"
)
log.info("ocaml-lsp-server is installed")
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Failed to check ocaml-lsp-server installation: {e.stderr}")
# Find the executable path
try:
if platform.system() == "Windows":
result = subprocess.run(
["opam", "exec", "--", "where", "ocamllsp"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
executable_path = result.stdout.strip().split("\n")[0]
else:
result = subprocess.run(
["opam", "exec", "--", "which", "ocamllsp"],
check=True,
capture_output=True,
text=True,
cwd=repository_root_path,
**subprocess_kwargs(),
)
executable_path = result.stdout.strip()
if not os.path.exists(executable_path):
raise RuntimeError(f"ocaml-lsp-server executable not found at {executable_path}")
if platform.system() != "Windows":
os.chmod(executable_path, os.stat(executable_path).st_mode | stat.S_IEXEC)
return executable_path
except subprocess.CalledProcessError as e:
raise RuntimeError(
f"Failed to find ocaml-lsp-server executable.\n"
f"Command failed: {e.cmd}\n"
f"Return code: {e.returncode}\n"
f"Stderr: {e.stderr}\n\n"
"This usually means ocaml-lsp-server is not installed or not in PATH.\n"
"Try:\n"
" 1. Check opam switch: opam switch show\n"
" 2. Install ocaml-lsp-server: opam install ocaml-lsp-server\n"
" 3. Ensure opam env is activated: eval $(opam env)"
)
@property
def supports_cross_file_references(self) -> bool:
"""
Check if this OCaml environment supports cross-file references.
Cross-file references require OCaml >= 5.2 with project-wide occurrences
AND ocaml-lsp-server >= 1.23.0 for reliable cross-file reference support.
Full requirements:
- OCaml 5.2+
- ocaml-lsp-server >= 1.23.0 (earlier versions have unreliable cross-file refs)
- merlin >= 5.1-502 (provides ocaml-index tool)
- dune >= 3.16.0
- Index built via `dune build @ocaml-index`
- For best results: `dune build -w` running (enables dune RPC)
Note: Even when this returns True, cross-file refs may not work in all
cases. The LSP server needs dune's RPC server (via -w flag) to be fully
aware of the index. Without watch mode, cross-file refs are best-effort.
See: https://discuss.ocaml.org/t/ann-project-wide-occurrences-in-merlin-and-lsp/14847
"""
ocaml_ok = self._ocaml_version >= (5, 2, 0)
lsp_ok = self._lsp_version >= self.MIN_LSP_VERSION_FOR_CROSS_FILE_REFS
return ocaml_ok and lsp_ok
@staticmethod
def _build_ocaml_index_static(repository_root_path: str) -> bool:
"""
Build the OCaml index for project-wide occurrences.
This enables cross-file reference finding on OCaml 5.2+.
Must be called BEFORE starting the LSP server.
Returns True if successful, False otherwise.
"""
log.info("Building OCaml index for cross-file references (dune build @ocaml-index)...")
try:
result = subprocess.run(
["opam", "exec", "--", "dune", "build", "@ocaml-index"],
cwd=repository_root_path,
capture_output=True,
text=True,
timeout=120,
check=False,
**subprocess_kwargs(),
)
if result.returncode == 0:
log.info("OCaml index built successfully")
return True
else:
log.warning(f"Failed to build OCaml index: {result.stderr}")
return False
except subprocess.TimeoutExpired:
log.warning("OCaml index build timed out after 120 seconds")
return False
except FileNotFoundError:
log.warning("opam not found, cannot build OCaml index")
return False
except Exception as e:
log.warning(f"Error building OCaml index: {e}")
return False
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
"""
Creates an OcamlLanguageServer instance.
This class is not meant to be instantiated directly. Use SolidLanguageServer.create() instead.
"""
# Ensure dependencies are available
self._ensure_opam_installed()
# Detect OCaml version for feature gating
self._ocaml_version = self._detect_ocaml_version(repository_root_path)
self._index_built = False
# Verify ocaml-lsp-server is installed (we don't need the path, just validation)
self._ensure_ocaml_lsp_installed(repository_root_path)
# Detect LSP version for cross-file reference support
self._lsp_version = self._detect_lsp_version(repository_root_path)
# Build OCaml index BEFORE starting server (required for cross-file refs on OCaml 5.2+)
if self._ocaml_version >= (5, 2, 0):
self._index_built = self._build_ocaml_index_static(repository_root_path)
# Use opam exec to run ocamllsp - this ensures correct opam environment
# which is required for project-wide occurrences (cross-file references) to work
ocaml_lsp_cmd = ["opam", "exec", "--", "ocamllsp", "--fallback-read-dot-merlin"]
log.info(f"Using ocaml-lsp-server via: {' '.join(ocaml_lsp_cmd)}")
super().__init__(
config,
repository_root_path,
ProcessLaunchInfo(cmd=ocaml_lsp_cmd, cwd=repository_root_path),
"ocaml",
solidlsp_settings,
)
self.server_ready = threading.Event()
self.completions_available = threading.Event()
@override
def is_ignored_dirname(self, dirname: str) -> bool:
"""Define language-specific directories to ignore for OCaml projects."""
return super().is_ignored_dirname(dirname) or dirname in ["_build", "_opam", ".opam"]
@staticmethod
def _get_initialize_params(repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the OCaml Language Server.
Supports both OCaml and Reason.
"""
root_uri = pathlib.Path(repository_absolute_path).as_uri()
initialize_params = {
"processId": os.getpid(),
"clientInfo": {"name": "Serena", "version": "0.1.0"},
"locale": "en",
"rootPath": repository_absolute_path,
"rootUri": root_uri,
"capabilities": {
"workspace": {
"workspaceFolders": True,
"configuration": True,
},
"textDocument": {
"synchronization": {
"dynamicRegistration": True,
"willSave": True,
"willSaveWaitUntil": True,
"didSave": True,
},
"completion": {
"dynamicRegistration": True,
"completionItem": {
"snippetSupport": True,
"documentationFormat": ["markdown", "plaintext"],
},
},
"hover": {
"dynamicRegistration": True,
"contentFormat": ["markdown", "plaintext"],
},
"definition": {"dynamicRegistration": True, "linkSupport": True},
"references": {"dynamicRegistration": True},
"documentSymbol": {
"dynamicRegistration": True,
"hierarchicalDocumentSymbolSupport": True,
},
"formatting": {"dynamicRegistration": True},
"rename": {"dynamicRegistration": True, "prepareSupport": True},
},
},
"trace": "verbose",
"workspaceFolders": [
{
"uri": root_uri,
"name": os.path.basename(repository_absolute_path),
}
],
}
return initialize_params # type: ignore[return-value]
def _start_server(self) -> None:
"""
Starts the OCaml Language Server (supports both OCaml and Reason)
"""
def register_capability_handler(params: Any) -> None:
if "registrations" in params:
for registration in params.get("registrations", []):
method = registration.get("method", "")
log.info(f"OCaml LSP registered capability: {method}")
return
def lang_status_handler(params: dict[str, Any]) -> None:
if params.get("type") == "ServiceReady" and params.get("message") == "ServiceReady":
self.server_ready.set()
def do_nothing(params: Any) -> None:
return
def window_log_message(msg: dict[str, Any]) -> None:
log.info(f"LSP: window/logMessage: {msg}")
if "initialization done" in msg.get("message", "").lower():
self.server_ready.set()
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("language/status", lang_status_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
log.info("Starting OCaml LSP server process")
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
log.info("Sending initialize request from LSP client to LSP server and awaiting response")
init_response = self.server.send.initialize(initialize_params)
# Verify expected capabilities
capabilities = init_response.get("capabilities", {})
log.info(f"OCaml LSP capabilities: {list(capabilities.keys())}")
text_doc_sync = capabilities.get("textDocumentSync")
if isinstance(text_doc_sync, dict):
assert text_doc_sync.get("change") == 2, "Expected incremental sync"
assert "completionProvider" in capabilities, "Expected completion support"
self.server.notify.initialized({})
self.completions_available.set()
self.server_ready.set()
log.info("OCaml Language Server initialized successfully")
+7
View File
@@ -55,6 +55,7 @@ class Language(str, Enum):
LUA = "lua"
NIX = "nix"
ERLANG = "erlang"
OCAML = "ocaml"
AL = "al"
FSHARP = "fsharp"
REGO = "rego"
@@ -223,6 +224,8 @@ class Language(str, Enum):
return FilenameMatcher("*.nix")
case self.ERLANG:
return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src")
case self.OCAML:
return FilenameMatcher("*.ml", "*.mli", "*.re", "*.rei")
case self.AL:
return FilenameMatcher("*.al", "*.dal")
case self.FSHARP:
@@ -405,6 +408,10 @@ class Language(str, Enum):
from solidlsp.language_servers.erlang_language_server import ErlangLanguageServer
return ErlangLanguageServer
case self.OCAML:
from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer
return OcamlLanguageServer
case self.AL:
from solidlsp.language_servers.al_language_server import ALLanguageServer
@@ -0,0 +1,4 @@
(executable
(public_name test_repo)
(name main)
(libraries test_repo))
@@ -0,0 +1,9 @@
open Test_repo
let n = 20
let () =
let res = fib n in
Printf.printf "fib(%d) = %d\n" n res;
let greeting = DemoModule.someFunction "Hello" in
Printf.printf "%s\n" greeting
@@ -0,0 +1,26 @@
(lang dune 3.18)
(name test_repo)
(generate_opam_files true)
(source
(github username/reponame))
(authors "Author Name <author@example.com>")
(maintainers "Maintainer Name <maintainer@example.com>")
(license LICENSE)
(documentation https://url/to/documentation)
(package
(name test_repo)
(synopsis "A short synopsis")
(description "A longer description")
(depends ocaml)
(tags
("add topics" "to describe" your project)))
; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html
@@ -0,0 +1,3 @@
(library
(public_name test_repo)
(name test_repo))
@@ -0,0 +1,12 @@
module DemoModule = struct
type value = string
let someFunction s =
s ^ " More String"
end
let rec fib n =
if n < 2 then 1
else fib (n-1) + fib (n-2)
let num_domains = 2
@@ -0,0 +1,7 @@
module DemoModule : sig
type value = string
val someFunction : string -> string
end
val fib : int -> int
val num_domains : int
@@ -0,0 +1,3 @@
(test
(name test_test_repo)
(libraries test_repo))
@@ -0,0 +1,18 @@
open Test_repo
let test_fib () =
assert (fib 0 = 1);
assert (fib 1 = 1);
assert (fib 2 = 2);
assert (fib 5 = 8);
Printf.printf "fib tests passed\n"
let test_demo_module () =
let result = DemoModule.someFunction "Test" in
assert (result = "Test More String");
Printf.printf "DemoModule tests passed\n"
let () =
test_fib ();
test_demo_module ();
Printf.printf "All tests passed!\n"
@@ -0,0 +1,32 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
synopsis: "A short synopsis"
description: "A longer description"
maintainer: ["Maintainer Name <maintainer@example.com>"]
authors: ["Author Name <author@example.com>"]
license: "LICENSE"
tags: ["add topics" "to describe" "your" "project"]
homepage: "https://github.com/username/reponame"
doc: "https://url/to/documentation"
bug-reports: "https://github.com/username/reponame/issues"
depends: [
"dune" {>= "3.18"}
"ocaml"
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "git+https://github.com/username/reponame.git"
x-maintenance-intent: ["(latest)"]
@@ -0,0 +1,75 @@
"""
Test cross-file references for OCaml.
Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0.
On environments without these (e.g. Windows CI with OCaml 4.14), only
same-file references are asserted.
"""
import logging
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.language_servers.ocaml_lsp_server import OcamlLanguageServer
from solidlsp.ls_config import Language
log = logging.getLogger(__name__)
@pytest.mark.ocaml
class TestCrossFileReferences:
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_fib_has_cross_file_references(self, language_server: SolidLanguageServer) -> None:
"""Test that fib function references are found across multiple files.
The `fib` function is defined in lib/test_repo.ml and used in:
- lib/test_repo.ml (definition + 2 recursive calls)
- bin/main.ml (1 call)
- test/test_test_repo.ml (5 references)
Total: 9 references across 3 files.
"""
file_path = os.path.join("lib", "test_repo.ml")
fib_line = 7
fib_char = 8
refs = language_server.request_references(file_path, fib_line, fib_char)
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
bin_refs = [ref for ref in refs if "bin/main.ml" in ref.get("uri", "")]
test_refs = [ref for ref in refs if "test/test_test_repo.ml" in ref.get("uri", "")]
log.info("Cross-file references result:")
log.info(f"Total references found: {len(refs)}")
log.info(f" lib/test_repo.ml: {len(lib_refs)}")
log.info(f" bin/main.ml: {len(bin_refs)}")
log.info(f" test/test_test_repo.ml: {len(test_refs)}")
for ref in refs:
uri = ref.get("uri", "")
filename = uri.split("/")[-1]
line = ref.get("range", {}).get("start", {}).get("line", -1)
log.info(f" {filename}:{line}")
# Same-file references always work
assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml (definition + 2 recursive), but got {len(lib_refs)}"
# Cross-file references require OCaml >= 5.2 and ocaml-lsp-server >= 1.23.0
if isinstance(language_server, OcamlLanguageServer) and language_server.supports_cross_file_references:
assert len(refs) >= 9, (
f"Expected at least 9 total references (3 in lib + 1 in bin + 5 in test), "
f"but got {len(refs)}. Cross-file references are NOT working!"
)
assert len(bin_refs) >= 1, (
f"Expected at least 1 reference in bin/main.ml, but got {len(bin_refs)}. "
"Cross-file references are NOT working - bin/main.ml not found!"
)
assert len(test_refs) >= 1, (
f"Expected at least 1 reference in test/test_test_repo.ml, but got {len(test_refs)}. "
"Cross-file references are NOT working - test file not found!"
)
+152
View File
@@ -0,0 +1,152 @@
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
@pytest.mark.ocaml
class TestOCamlLanguageServer:
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_find_symbol(self, language_server: SolidLanguageServer) -> None:
symbols = language_server.request_full_symbol_tree()
assert SymbolUtils.symbol_tree_contains_name(symbols, "DemoModule"), "DemoModule not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "fib"), "fib not found in symbol tree"
assert SymbolUtils.symbol_tree_contains_name(symbols, "someFunction"), "someFunction function not found in symbol tree"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None:
file_path = os.path.join("lib", "test_repo.ml")
# Use the correct character position for 'fib' function name
# Line 8: "let rec fib n =" - 'fib' starts at character 8 (0-indexed)
fib_line = 7 # 0-indexed line number
fib_char = 8 # 0-indexed character position
refs = language_server.request_references(file_path, fib_line, fib_char)
# Should find at least 3 references: definition + 2 recursive calls in same file
assert len(refs) >= 3, f"Expected at least 3 references to fib (definition + 2 recursive), found {len(refs)}"
# All references should be in lib/test_repo.ml (same file as definition)
# Use forward slashes for URI matching (URIs always use /)
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(lib_refs) >= 3, f"Expected at least 3 references in lib/test_repo.ml, found {len(lib_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_mixed_ocaml_modules(self, language_server: SolidLanguageServer) -> None:
"""Test that the language server can find symbols from OCaml modules"""
# Test that full symbol tree includes symbols from various file types
all_symbols = language_server.request_full_symbol_tree()
# Should find symbols from main OCaml files
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "fib"), "Should find fib from .ml file"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule"), "Should find DemoModule from .ml file"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "someFunction"), "Should find someFunction from DemoModule"
assert SymbolUtils.symbol_tree_contains_name(all_symbols, "num_domains"), "Should find num_domains constant"
def test_reason_file_patterns(self) -> None:
"""Test that OCaml language configuration recognizes Reason file extensions"""
from solidlsp.ls_config import Language
ocaml_lang = Language.OCAML
file_matcher = ocaml_lang.get_source_fn_matcher()
# Test OCaml extensions
assert file_matcher.is_relevant_filename("test.ml"), "Should match .ml files"
assert file_matcher.is_relevant_filename("test.mli"), "Should match .mli files"
# Test Reason extensions
assert file_matcher.is_relevant_filename("test.re"), "Should match .re files"
assert file_matcher.is_relevant_filename("test.rei"), "Should match .rei files"
# Test non-matching extensions
assert not file_matcher.is_relevant_filename("test.py"), "Should not match .py files"
assert not file_matcher.is_relevant_filename("test.js"), "Should not match .js files"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_module_hierarchy_navigation(self, language_server: SolidLanguageServer) -> None:
"""Test navigation within module hierarchy including DemoModule."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'DemoModule' (line 1, char 7)
# Line 1: "module DemoModule = struct" - 'DemoModule' starts around char 7
module_line = 0 # 0-indexed
module_char = 7 # 0-indexed
refs = language_server.request_references(file_path, module_line, module_char)
# Should find at least 1 reference (the definition)
assert len(refs) >= 1, f"Expected at least 1 reference to DemoModule, found {len(refs)}"
# Check that references are found - use forward slashes for URI matching
lib_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(lib_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(lib_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_let_binding_references(self, language_server: SolidLanguageServer) -> None:
"""Test finding references to let-bound values across files."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'num_domains' (line 12, char 4)
# Line 12: "let num_domains = 2" - 'num_domains' starts around char 4
num_domains_line = 11 # 0-indexed
num_domains_char = 4 # 0-indexed
refs = language_server.request_references(file_path, num_domains_line, num_domains_char)
# Should find at least 1 reference (the definition)
assert len(refs) >= 1, f"Expected at least 1 reference to num_domains, found {len(refs)}"
# Check that reference is found in the definition file - use forward slashes
ml_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
assert len(ml_refs) >= 1, f"Expected at least 1 reference in lib/test_repo.ml, found {len(ml_refs)}"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_recursive_function_analysis(self, language_server: SolidLanguageServer) -> None:
"""Test that recursive function calls are properly identified within the definition file."""
file_path = os.path.join("lib", "test_repo.ml")
# Use correct position for 'fib' function name (line 8, char 8)
fib_line = 7 # 0-indexed
fib_char = 8 # 0-indexed
refs = language_server.request_references(file_path, fib_line, fib_char)
# Filter to references within the definition file only - use forward slashes
same_file_refs = [ref for ref in refs if "lib/test_repo.ml" in ref.get("uri", "")]
# Should find at least 3 references in test_repo.ml: definition + 2 recursive calls
# On OCaml 5.2+ with cross-file refs, there may be more total refs but same-file count stays the same
assert (
len(same_file_refs) >= 3
), f"Expected at least 3 references in test_repo.ml (definition + 2 recursive), found {len(same_file_refs)}"
# Verify references are on different lines (definition + recursive calls)
ref_lines = [ref.get("range", {}).get("start", {}).get("line", -1) for ref in same_file_refs]
unique_lines = len(set(ref_lines))
assert unique_lines >= 2, f"Recursive calls should appear on multiple lines, found {unique_lines} unique lines"
@pytest.mark.parametrize("language_server", [Language.OCAML], indirect=True)
def test_open_statement_resolution(self, language_server: SolidLanguageServer) -> None:
"""Test that open statements allow unqualified access to module contents."""
# In bin/main.ml, fib is called without Test_repo prefix due to 'open Test_repo'
all_symbols = language_server.request_full_symbol_tree()
# Should be able to find fib through symbol tree
fib_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "fib")
assert fib_accessible, "fib should be accessible through open statement"
# DemoModule should also be accessible
demo_module_accessible = SymbolUtils.symbol_tree_contains_name(all_symbols, "DemoModule")
assert demo_module_accessible, "DemoModule should be accessible"
# Verify we have access to both qualified and unqualified symbols
assert len(all_symbols) > 0, "Should find symbols from OCaml files"
# Test that the language server recognizes the open statement context
file_path = os.path.join("bin", "main.ml")
symbols, _roots = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
assert len(symbols) > 0, "Should find symbols in main.ml that use opened modules"