diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c723af..db2ca579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ Status of the `main` branch. Changes prior to the next official version change w so they can no longer inherit and clobber Serena's stdin, which is the JSON-RPC pipe under the stdio transport. #1577 - Improve quoting of arguments in shell executions + - Add **LaTeX** support (experimental) via [texlab](https://github.com/latex-lsp/texlab). Auto-downloads a + SHA-256-verified prebuilt texlab binary (osx-arm64/x64, linux-arm64/x64, win-x64); covers `.tex`, `.bib`, + `.sty`, and `.cls` files. Provides sectioning document symbols (including beamer frames), label/citation + definitions, and `\ref`/`\cite` references. Must be explicitly enabled via language `latex`. texlab is + GPL-3.0 and runs as a separate downloaded process. * JetBrains: - Add configuration option `jetbrains_launch_command`, allowing Serena to spawn IDE instances automatically diff --git a/README.md b/README.md index 44cdee0f..76c0e383 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Serena incorporates a powerful abstraction layer for the integration of language The underlying language servers are typically open-source projects or at least freely available for use. When using Serena's language server backend, we provide **support for over 40 programming languages**, including -Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, Swift, TOML, TypeScript, WGSL, YAML, and Zig. +Ada / SPARK, AL, Angular, Ansible, Bash, BSL, C#, C/C++, Clojure, Crystal, CUE, Dart, Elixir, Elm, Erlang, Fortran, F#, GDScript, GLSL, Go, Groovy, Haskell, Haxe, HLSL, HTML, Java, JavaScript, JSON, Julia, Kotlin, LaTeX, Lean 4, Lua, Luau, Markdown, MATLAB, mSL, Nix, OCaml, Perl, PHP, PowerShell, Python, R, Ruby, Rust, Scala, SCSS / Sass / CSS, Solidity, Svelte, 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 6f435c2f..da560a2d 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -97,6 +97,10 @@ Some languages require additional installations or setup steps, as noted. * **Julia** * **Kotlin** (uses the pre-alpha [official kotlin LS](https://github.com/Kotlin/kotlin-lsp), some issues may appear) +* **LaTeX** + (experimental; must be explicitly enabled via language `latex`; uses [texlab](https://github.com/latex-lsp/texlab), + auto-downloaded as a SHA-256-verified prebuilt binary; supports `.tex`, `.bib`, `.sty`, and `.cls` files; texlab is + GPL-3.0 and runs as a separate downloaded process) * **Lean 4** (requires `lean` and `lake` installed via [elan](https://github.com/leanprover/elan); uses the built-in Lean 4 LSP; the project must be a Lake project with `lake build` run before use) diff --git a/pyproject.toml b/pyproject.toml index b71e2535..118a786a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -337,6 +337,7 @@ markers = [ "fsharp: language server running for F#", "rego: language server running for Rego", "markdown: language server running for Markdown", + "latex: language server running for LaTeX", "julia: Julia language server tests", "fortran: language server running for Fortran", "haskell: Haskell language server tests", diff --git a/src/solidlsp/language_servers/texlab_language_server.py b/src/solidlsp/language_servers/texlab_language_server.py new file mode 100644 index 00000000..ea95f6e6 --- /dev/null +++ b/src/solidlsp/language_servers/texlab_language_server.py @@ -0,0 +1,199 @@ +""" +Provides LaTeX specific instantiation of the LanguageServer class using texlab. +texlab is downloaded as a prebuilt binary from the latex-lsp/texlab GitHub releases. +""" + +import logging +import os +from typing import cast + +from overrides import override + +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_utils import PathUtils, PlatformUtils +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +from .common import RuntimeDependency, RuntimeDependencyCollection + +log = logging.getLogger(__name__) + +TEXLAB_VERSION = "5.25.1" +TEXLAB_BASE_URL = f"https://github.com/latex-lsp/texlab/releases/download/v{TEXLAB_VERSION}" +TEXLAB_ALLOWED_HOSTS = ( + "github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +) +TEXLAB_SHA256_BY_PLATFORM = { + "osx-arm64": "3755e9d1d4ad0b25135bdacd2fb453a612e88f48133185f96d660fa550398f66", + "osx-x64": "11289a231f0cf382857a6a4a2eda1ba9f4f4e950af343b455797e3922d13b1ea", + "linux-arm64": "e0d8e0b27b2e6e3526fa5019323bb3fddb1202a0f0049e527672b5ff323cc15e", + "linux-x64": "c8260b2fd2849cbad7d1f54c4ffa0389f34664b049392107bc4f7f9c8ec542ba", + "win-x64": "aa5fc1fe6004c17cd83086a57a8c8f28bb3f360914872711bfbb83490dc3c19e", +} + + +class TexlabLanguageServer(SolidLanguageServer): + """ + Provides LaTeX specific instantiation of the LanguageServer class using texlab. + + Symbol navigation maps onto LaTeX structure: document symbols are the sectioning + hierarchy, definitions/references cover labels and citations. + """ + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in ["_minted", "_build", "build", "out", "auto"] + + @classmethod + def _setup_runtime_dependencies(cls, solidlsp_settings: SolidLSPSettings) -> str: + """Download and install texlab for the current platform if not already present.""" + platform_id = PlatformUtils.get_platform_id() + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="Texlab", + description="texlab for macOS (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-macos.tar.gz", + platform_id="osx-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for macOS (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-macos.tar.gz", + platform_id="osx-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["osx-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (ARM64)", + url=f"{TEXLAB_BASE_URL}/texlab-aarch64-linux.tar.gz", + platform_id="linux-arm64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-arm64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Linux (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-linux.tar.gz", + platform_id="linux-x64", + archive_type="gztar", + binary_name="texlab", + sha256=TEXLAB_SHA256_BY_PLATFORM["linux-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + RuntimeDependency( + id="Texlab", + description="texlab for Windows (x64)", + url=f"{TEXLAB_BASE_URL}/texlab-x86_64-windows.zip", + platform_id="win-x64", + archive_type="zip", + binary_name="texlab.exe", + sha256=TEXLAB_SHA256_BY_PLATFORM["win-x64"], + allowed_hosts=TEXLAB_ALLOWED_HOSTS, + ), + ] + ) + dependency = deps.get_single_dep_for_current_platform() + install_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), f"texlab-{TEXLAB_VERSION}") + texlab_executable_path = deps.binary_path(install_dir) + if not os.path.exists(texlab_executable_path): + log.info(f"Downloading texlab from {dependency.url}") + deps.install(install_dir) + + assert os.path.exists(texlab_executable_path), f"texlab executable not found at {texlab_executable_path}" + + if platform_id.value != "win-x64": + os.chmod(texlab_executable_path, 0o755) + + return texlab_executable_path + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates a TexlabLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + texlab_executable_path = self._setup_runtime_dependencies(solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=texlab_executable_path, cwd=repository_root_path), + "latex", + solidlsp_settings, + ) + + @staticmethod + def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + """Returns the initialize params for the texlab Language Server.""" + root_uri = PathUtils.path_to_uri(repository_absolute_path) + result = { + "processId": os.getpid(), + "locale": "en", + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "rename": {"dynamicRegistration": True}, + }, + "workspace": {"workspaceFolders": True, "didChangeConfiguration": {"dynamicRegistration": True}}, + }, + "workspaceFolders": [ + { + "name": os.path.basename(repository_absolute_path), + "uri": root_uri, + } + ], + } + return cast(InitializeParams, result) + + def _start_server(self) -> None: + """Start the texlab server process.""" + + def register_capability_handler(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + + def do_nothing(params: dict) -> None: + 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) + + log.info("Starting texlab server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to texlab and awaiting response") + init_response = self.server.send.initialize(initialize_params) + + assert "textDocumentSync" in init_response["capabilities"] + assert "documentSymbolProvider" in init_response["capabilities"] + assert "definitionProvider" in init_response["capabilities"] + + self.server.notify.initialized({}) diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 8f593833..2c1850fa 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -159,6 +159,11 @@ class Language(str, Enum): Must be explicitly specified as the main language, not auto-detected. This is an edge case primarily useful when working on documentation-heavy projects. """ + LATEX = "latex" + """texlab language server for LaTeX/BibTeX (experimental). + Must be explicitly specified as the main language, not auto-detected. + Provides sectioning-hierarchy document symbols plus label/citation definitions and references. + """ YAML = "yaml" """YAML language server (experimental). Must be explicitly specified as the main language, not auto-detected. @@ -248,6 +253,7 @@ class Language(str, Enum): self.RUBY_SOLARGRAPH, self.PHP_PHPACTOR, self.MARKDOWN, + self.LATEX, self.YAML, self.JSON, self.TOML, @@ -263,7 +269,7 @@ class Language(str, Enum): """Whether the supported language should be considered a programming language. Solidlsp supports languages like markdown or json, this method returns False for them. """ - return self not in frozenset((self.MARKDOWN, self.JSON, self.TOML, self.YAML, self.ANSIBLE)) + return self not in frozenset((self.MARKDOWN, self.LATEX, self.JSON, self.TOML, self.YAML, self.ANSIBLE)) def __str__(self) -> str: return self.value @@ -429,6 +435,8 @@ class Language(str, Enum): return FilenameMatcher(".rego") case self.MARKDOWN: return FilenameMatcher(".md", ".markdown") + case self.LATEX: + return FilenameMatcher(".tex", ".bib", ".sty", ".cls") case self.SCALA: return FilenameMatcher(".scala", ".sbt") case self.JULIA: @@ -681,6 +689,10 @@ class Language(str, Enum): from solidlsp.language_servers.marksman import Marksman return Marksman + case self.LATEX: + from solidlsp.language_servers.texlab_language_server import TexlabLanguageServer + + return TexlabLanguageServer case self.R: from solidlsp.language_servers.r_language_server import RLanguageServer diff --git a/test/conftest.py b/test/conftest.py index de0ac507..672a255b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -256,6 +256,7 @@ _LANGUAGE_PYTEST_MARKERS: dict[Language, list[MarkDecorator | Mark]] = { Language.JAVA: [pytest.mark.java], Language.KOTLIN: [pytest.mark.kotlin, pytest.mark.skipif(is_ci, reason="Kotlin LSP JVM crashes on restart in CI")], Language.LEAN4: [pytest.mark.lean4, pytest.mark.skipif(_sh.which("lean") is None, reason="Lean is not installed")], + Language.LATEX: [pytest.mark.latex], Language.MSL: [pytest.mark.msl], Language.PHP: [pytest.mark.php], Language.PHP_PHPACTOR: [pytest.mark.php], diff --git a/test/resources/repos/latex/test_repo/.gitignore b/test/resources/repos/latex/test_repo/.gitignore new file mode 100644 index 00000000..531e44bf --- /dev/null +++ b/test/resources/repos/latex/test_repo/.gitignore @@ -0,0 +1,7 @@ +*.aux +*.log +*.out +*.pdf +*.toc +*.synctex.gz +build/ diff --git a/test/resources/repos/latex/test_repo/main.tex b/test/resources/repos/latex/test_repo/main.tex new file mode 100644 index 00000000..6b0cb8cf --- /dev/null +++ b/test/resources/repos/latex/test_repo/main.tex @@ -0,0 +1,35 @@ +\documentclass{article} +\usepackage{amsmath} + +\title{Serena Texlab Smoke Test} +\author{Test Author} + +\begin{document} +\maketitle + +\section{Introduction} +\label{sec:intro} +This is the introduction. It points forward to Section~\ref{sec:methods}. + +\section{Methods} +\label{sec:methods} +The methods build on Section~\ref{sec:intro}. + +\subsection{Implementation Details} +\label{sec:details} +Details that refer back to Section~\ref{sec:methods} once more. + +\section{Conclusion} +\label{sec:conclusion} +Wrapping up, see Section~\ref{sec:details}. + +\input{sections/background.tex} + +For the broader context behind these methods, see Section~\ref{sec:background}. +The typesetting system itself is described by Lamport~\cite{lamport1994} and +Knuth~\cite{knuth1984}. + +\bibliographystyle{plain} +\bibliography{references} + +\end{document} diff --git a/test/resources/repos/latex/test_repo/references.bib b/test/resources/repos/latex/test_repo/references.bib new file mode 100644 index 00000000..25931bea --- /dev/null +++ b/test/resources/repos/latex/test_repo/references.bib @@ -0,0 +1,13 @@ +@book{knuth1984, + author = {Knuth, Donald E.}, + title = {The {TeXbook}}, + publisher = {Addison-Wesley}, + year = {1984}, +} + +@book{lamport1994, + author = {Lamport, Leslie}, + title = {{LaTeX}: A Document Preparation System}, + publisher = {Addison-Wesley}, + year = {1994}, +} diff --git a/test/resources/repos/latex/test_repo/sections/background.tex b/test/resources/repos/latex/test_repo/sections/background.tex new file mode 100644 index 00000000..c9edfe82 --- /dev/null +++ b/test/resources/repos/latex/test_repo/sections/background.tex @@ -0,0 +1,9 @@ +\section{Background} +\label{sec:background} +The document model used here follows the sectioning structure of the TeX +typesetting system~\cite{knuth1984}. Cross-referencing between sections relies on +stable label keys, which is the behaviour this fixture exercises. + +\subsection{Prior Work} +\label{sec:prior-work} +A fuller description of the macro layer appears in the LaTeX manual~\cite{lamport1994}. diff --git a/test/resources/repos/latex/test_repo/slides.tex b/test/resources/repos/latex/test_repo/slides.tex new file mode 100644 index 00000000..d19f996d --- /dev/null +++ b/test/resources/repos/latex/test_repo/slides.tex @@ -0,0 +1,33 @@ +\documentclass{beamer} +\usetheme{default} + +\title{Serena Texlab Beamer Smoke Test} +\author{Test Author} + +\begin{document} + +\frame{\titlepage} + +\section{Overview} + +\begin{frame} +\frametitle{Introduction} +\label{frame:intro} +An introductory frame for the overview section. +\end{frame} + +\begin{frame} +\frametitle{Methodology} +\label{frame:method} +A frame describing the methodology, see Section~\ref{sec:results}. +\end{frame} + +\section{Results} +\label{sec:results} + +\begin{frame} +\frametitle{Findings} +A closing frame summarising the findings. +\end{frame} + +\end{document} diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 5bbd0a6d..5ab27115 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -517,6 +517,9 @@ FIND_SYMBOL_REFERENCES_CASES = [ FindSymbolCase( language=Language.RUST, id="rust_add_function", symbol_name="add", expected_kind="Function", expected_file="lib.rs" ).to_pytest_param(), + FindSymbolCase( + language=Language.LATEX, id="latex_methods_section", symbol_name="Methods", expected_kind="Module", expected_file="main.tex" + ).to_pytest_param(), ] FIND_REFERENCE_CASES = [ @@ -597,6 +600,13 @@ FIND_REFERENCE_CASES = [ ).to_pytest_param( pytest.mark.xfail(reason="F# language server is unreliable"), # See issue #1040 ), + FindReferenceCase( + language=Language.LATEX, + id="latex_background_refs", + symbol_name="Background", + definition_file="sections/background.tex", + reference_file="main.tex", + ).to_pytest_param(), ] FIND_DEFINING_SYMBOL_REGEX_ERROR_CASES = [ @@ -779,6 +789,7 @@ def serena_config(): Language.HAXE, Language.LEAN4, Language.MSL, + Language.LATEX, ]: repo_path = get_repo_path(language) if repo_path.exists(): diff --git a/test/solidlsp/latex/test_latex_basic.py b/test/solidlsp/latex/test_latex_basic.py new file mode 100644 index 00000000..36cc67df --- /dev/null +++ b/test/solidlsp/latex/test_latex_basic.py @@ -0,0 +1,37 @@ +"""Basic tests for the texlab-based LaTeX language server.""" + +import pytest + +from serena.symbol import LanguageServerSymbol +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + + +@pytest.mark.latex +class TestLatexLanguageServerBasics: + """Basic functionality of the LaTeX (texlab) language server.""" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_document_symbols_are_sections(self, language_server: SolidLanguageServer) -> None: + """Sectioning commands should surface as document symbols.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + assert len(symbols) > 0, "Should find at least some symbols in main.tex" + + names = {s.get("name", "") for s in symbols} + for expected in ("Introduction", "Methods", "Conclusion"): + assert expected in names, f"Expected section '{expected}' among document symbols, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_symbol_present(self, language_server: SolidLanguageServer) -> None: + """A nested subsection should also be exposed as a symbol.""" + symbols, _roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + names = {s.get("name", "") for s in symbols} + assert "Implementation Details" in names, f"Expected the subsection symbol, got: {sorted(names)}" + + @pytest.mark.parametrize("language_server", [Language.LATEX], indirect=True) + def test_subsection_name_path_nests_under_section(self, language_server: SolidLanguageServer) -> None: + """A subsection's name path nests under its parent section ("section/subsection").""" + _symbols, roots = language_server.request_document_symbols("main.tex").get_all_symbols_and_roots() + matches = [m for root in roots for m in LanguageServerSymbol(root).find("Methods/Implementation Details")] + assert len(matches) == 1, matches + assert matches[0].get_name_path() == "Methods/Implementation Details" diff --git a/test/solidlsp/latex/test_latex_beamer.py b/test/solidlsp/latex/test_latex_beamer.py new file mode 100644 index 00000000..adc6daea --- /dev/null +++ b/test/solidlsp/latex/test_latex_beamer.py @@ -0,0 +1,59 @@ +r"""Beamer presentation support for the LaTeX (texlab) language server. + +Beamer frames surface as ``Frame: