From 215f8db747cd7e819f5b3222de31762b6e775518 Mon Sep 17 00:00:00 2001 From: Dimitrije Jankovic Date: Mon, 8 Jun 2026 17:41:06 -0400 Subject: [PATCH 1/3] Wire the pygls LSP extra, console entry, and query vendoring Add an optional `lsp` extra (pygls) so `pip install gmat-script` stays at its single tree-sitter runtime dependency, and register the `gmat-script-lsp` console entry. The build hook now vendors the grammar's `.scm` queries into the wheel (mirroring the compiled binding) so the language server can load locals/tags at runtime from an installed package; the minimal-install CI job verifies they ship and still compile. CI's `uv sync` gains `--all-extras` so the server is exercised under test, mypy, and ruff; `__main__` is omitted from coverage as a stdio entry shim. Part of #22. --- .github/workflows/ci.yml | 23 ++++++++++----- .gitignore | 5 ++++ hatch_build.py | 27 +++++++++++++++++- pyproject.toml | 19 +++++++++++-- uv.lock | 61 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 123 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccf25aa..ed8f8d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: enable-cache: true cache-dependency-glob: uv.lock - name: Install project - run: uv sync --all-groups + run: uv sync --all-groups --all-extras - name: Run pytest run: uv run pytest -m "not corpus" --cov --cov-report=term-missing @@ -50,7 +50,7 @@ jobs: with: enable-cache: true cache-dependency-glob: uv.lock - - run: uv sync --all-groups + - run: uv sync --all-groups --all-extras - run: uv run ruff check - run: uv run ruff format --check @@ -67,7 +67,7 @@ jobs: with: enable-cache: true cache-dependency-glob: uv.lock - - run: uv sync --all-groups + - run: uv sync --all-groups --all-extras - run: uv run mypy # Smoke-check that `pip install .` builds the wheel (committed parser.c + a C compiler — no Node), @@ -82,15 +82,24 @@ jobs: with: python-version: '3.12' - run: pip install . - - name: import + load grammar on a clean install + - name: import + load grammar + queries on a clean install run: | python - <<'PY' + from importlib import resources + import gmat_script from gmat_script._grammar import language - from tree_sitter import Language, Parser + from tree_sitter import Language, Parser, Query - tree = Parser(Language(language())).parse(b"Create Spacecraft Sat;") + grammar = Language(language()) + tree = Parser(grammar).parse(b"Create Spacecraft Sat;") assert tree.root_node.type == "source_file" and not tree.root_node.has_error + + # The vendored queries must travel in the wheel (the language server loads them) and stay + # compatible with the grammar — compiling them here is an install-time drift guard. + queries = resources.files("gmat_script._grammar") / "queries" + for name in ("locals.scm", "tags.scm", "highlights.scm"): + Query(grammar, (queries / name).read_text(encoding="utf-8")) print("minimal install OK", gmat_script.__version__) PY @@ -141,7 +150,7 @@ jobs: with: enable-cache: true cache-dependency-glob: uv.lock - - run: uv sync --all-groups + - run: uv sync --all-groups --all-extras - name: Validate corpus and log file/error counts run: uv run python tests/check_corpus.py - name: Per-file corpus tests diff --git a/.gitignore b/.gitignore index 2f20750..4b33080 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,11 @@ node_modules/ tree-sitter-gmat/build/ *.wasm +# The grammar's queries are vendored into the package by the build hook (hatch_build.py) so the +# wheel and editable installs can load them at runtime; the canonical copies live under +# tree-sitter-gmat/queries/. +src/gmat_script/_grammar/queries/ + # IDE / editor .vscode/ .idea/ diff --git a/hatch_build.py b/hatch_build.py index 6db1283..068f35e 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -13,6 +13,7 @@ from __future__ import annotations +import shutil import sysconfig from pathlib import Path from typing import Any @@ -29,8 +30,10 @@ _SCANNER_C = _GRAMMAR / "src" / "scanner.c" _BINDING_C = _GRAMMAR / "bindings" / "python" / "binding.c" _PARSER_INCLUDE = _GRAMMAR / "src" +_QUERIES_SRC = _GRAMMAR / "queries" _GRAMMAR_PKG_DIR = _ROOT / "src" / "gmat_script" / "_grammar" +_QUERIES_PKG_DIR = _GRAMMAR_PKG_DIR / "queries" _EXT_FULLNAME = "gmat_script._grammar._binding" @@ -70,6 +73,25 @@ def _compile_extension() -> Path: return built +def _vendor_queries() -> list[Path]: + """Copy the grammar's tree-sitter queries into the package; return the vendored paths. + + The ``.scm`` queries live canonically with the grammar (``tree-sitter-gmat/queries/``, D1) and + ship in the sdist, but the wheel packages only ``src/gmat_script``. The language server loads + ``locals.scm`` / ``tags.scm`` at runtime, so they must travel in the wheel. This mirrors the + compiled-binding vendoring: copy them into ``gmat_script/_grammar/queries/`` (git-ignored, the + canonical source stays single) so editable installs find them in the source tree, and + force-include them so they land in the built wheel. + """ + _QUERIES_PKG_DIR.mkdir(parents=True, exist_ok=True) + vendored: list[Path] = [] + for query in sorted(_QUERIES_SRC.glob("*.scm")): + dest = _QUERIES_PKG_DIR / query.name + shutil.copyfile(query, dest) + vendored.append(dest) + return vendored + + class TreeSitterGrammarBuildHook(BuildHookInterface): """Compile the vendored grammar extension and mark the wheel platform-specific (abi3).""" @@ -77,9 +99,12 @@ class TreeSitterGrammarBuildHook(BuildHookInterface): def initialize(self, version: str, build_data: dict[str, Any]) -> None: built = _compile_extension() + queries = _vendor_queries() build_data["pure_python"] = False build_data["infer_tag"] = False build_data["tag"] = f"{_ABI3_PYTHON_TAG}-abi3-{_platform_tag()}" - # Ship the compiled extension even though it is git-ignored. + # Ship the compiled extension and the vendored queries even though they are git-ignored. build_data["force_include"][str(built)] = str(built.relative_to(_ROOT / "src")) + for query in queries: + build_data["force_include"][str(query)] = str(query.relative_to(_ROOT / "src")) diff --git a/pyproject.toml b/pyproject.toml index 6d22bb9..0a4e8bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,16 @@ dependencies = [ ] dynamic = ["version"] +[project.optional-dependencies] +# The language server. Kept out of the base install so `pip install gmat-script` stays at its single +# `tree-sitter` runtime dependency (D9); editors install `gmat-script[lsp]`. +lsp = [ + "pygls>=2,<3", +] + [project.scripts] gmat-script = "gmat_script.cli:main" +gmat-script-lsp = "gmat_script.lsp:main" [project.urls] Homepage = "https://github.com/astro-tools/gmat-script" @@ -163,9 +171,14 @@ markers = [ [tool.coverage.run] source = ["src/gmat_script"] branch = true -# The catalogue generator is the only GMAT-touching code; it cannot run without a GMAT install and -# is exercised in the dedicated `catalogue` CI job, not the unit suite. -omit = ["*/gmat_script/tools/gen_catalog.py"] +omit = [ + # The catalogue generator is the only GMAT-touching code; it cannot run without a GMAT install + # and is exercised in the dedicated `catalogue` CI job, not the unit suite. + "*/gmat_script/tools/gen_catalog.py", + # The language-server stdio entry point: a one-line `python -m` shim exercised by the stdio smoke + # test, which runs the server in a subprocess and so is not measured in-process. + "*/gmat_script/lsp/__main__.py", +] [tool.coverage.report] show_missing = true diff --git a/uv.lock b/uv.lock index f89de9b..ede0c8c 100644 --- a/uv.lock +++ b/uv.lock @@ -46,6 +46,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -68,6 +77,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, ] +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -379,6 +402,11 @@ dependencies = [ { name = "tree-sitter" }, ] +[package.optional-dependencies] +lsp = [ + { name = "pygls" }, +] + [package.dev-dependencies] dev = [ { name = "mypy" }, @@ -395,7 +423,11 @@ docs = [ ] [package.metadata] -requires-dist = [{ name = "tree-sitter", specifier = ">=0.25,<0.26" }] +requires-dist = [ + { name = "pygls", marker = "extra == 'lsp'", specifier = ">=2,<3" }, + { name = "tree-sitter", specifier = ">=0.25,<0.26" }, +] +provides-extras = ["lsp"] [package.metadata.requires-dev] dev = [ @@ -545,6 +577,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, ] +[[package]] +name = "lsprotocol" +version = "2025.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/26/67b84e6ec1402f0e6764ef3d2a0aaf9a79522cc1d37738f4e5bb0b21521a/lsprotocol-2025.0.0.tar.gz", hash = "sha256:e879da2b9301e82cfc3e60d805630487ac2f7ab17492f4f5ba5aaba94fe56c29", size = 74896, upload-time = "2025-06-17T21:30:18.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/f0/92f2d609d6642b5f30cb50a885d2bf1483301c69d5786286500d15651ef2/lsprotocol-2025.0.0-py3-none-any.whl", hash = "sha256:f9d78f25221f2a60eaa4a96d3b4ffae011b107537facee61d3da3313880995c7", size = 76250, upload-time = "2025-06-17T21:30:19.455Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -906,6 +951,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pygls" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "lsprotocol" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/2e/7bbe061d175c0baddde8fc9edb908a4c31ba5d9165b8c68e3439c3a9f138/pygls-2.1.1.tar.gz", hash = "sha256:1da03ba9053201bb337dcdd8d121df70feb2a91e1a0dcc74de5da79755b1a201", size = 55091, upload-time = "2026-03-25T11:19:10.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/1a/208293b6c350f5abea6941d5606080d4a492644052504f5312e5de30a902/pygls-2.1.1-py3-none-any.whl", hash = "sha256:510a6dea2476177230c7d851125e5948efdf3fdb9ebfd8543fc434972f8faed4", size = 68975, upload-time = "2026-03-25T11:19:11.374Z" }, +] + [[package]] name = "pygments" version = "2.20.0" From 86e9ad92ae886b120aa62e8ab198832edac8a6b7 Mon Sep 17 00:00:00 2001 From: Dimitrije Jankovic Date: Mon, 8 Jun 2026 17:41:20 -0400 Subject: [PATCH 2/3] LSP server (gmat_script/lsp/, pygls) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pygls language server exposing the parser, linter, catalogue, and queries over LSP — the editor-agnostic backend for any client. Pure, unit-testable feature functions (analysis.py) wrap the existing layers; a thin pygls shell (server.py) marshals them and never crashes on a malformed buffer: - publishDiagnostics from the linter (and parser syntax errors), debounced on change; - hover and completion from the field catalogue (field docs, valid fields, enum values, object-reference candidates); - definition, references, and documentSymbol from the locals/tags queries; - formatting and rangeFormatting from the canonical formatter. Positions convert between the library's 1-indexed byte positions and LSP's 0-indexed UTF-16 positions (conversions.py). An integration smoke test drives the server over stdio; unit tests cover the feature core and handlers. Closes #22. --- src/gmat_script/lsp/__init__.py | 33 +++ src/gmat_script/lsp/__main__.py | 10 + src/gmat_script/lsp/analysis.py | 387 +++++++++++++++++++++++++++ src/gmat_script/lsp/conversions.py | 108 ++++++++ src/gmat_script/lsp/queries.py | 92 +++++++ src/gmat_script/lsp/server.py | 181 +++++++++++++ tests/test_lsp_analysis.py | 308 ++++++++++++++++++++++ tests/test_lsp_conversions.py | 102 ++++++++ tests/test_lsp_queries.py | 49 ++++ tests/test_lsp_server.py | 407 +++++++++++++++++++++++++++++ 10 files changed, 1677 insertions(+) create mode 100644 src/gmat_script/lsp/__init__.py create mode 100644 src/gmat_script/lsp/__main__.py create mode 100644 src/gmat_script/lsp/analysis.py create mode 100644 src/gmat_script/lsp/conversions.py create mode 100644 src/gmat_script/lsp/queries.py create mode 100644 src/gmat_script/lsp/server.py create mode 100644 tests/test_lsp_analysis.py create mode 100644 tests/test_lsp_conversions.py create mode 100644 tests/test_lsp_queries.py create mode 100644 tests/test_lsp_server.py diff --git a/src/gmat_script/lsp/__init__.py b/src/gmat_script/lsp/__init__.py new file mode 100644 index 0000000..53d191f --- /dev/null +++ b/src/gmat_script/lsp/__init__.py @@ -0,0 +1,33 @@ +"""The gmat-script language server — the ``gmat-script-lsp`` console entry. + +pygls is an optional dependency (the ``lsp`` extra), kept out of the base install so +``pip install gmat-script`` stays at its single ``tree-sitter`` runtime dependency (D9). This entry +point degrades gracefully when the extra is absent; importing the server modules +(:mod:`gmat_script.lsp.server`) requires it. +""" + +from __future__ import annotations + +import importlib.util +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = ["main"] + +_EXTRA_HINT = ( + "gmat-script-lsp needs the language-server extra. Install it with:\n" + " pip install 'gmat-script[lsp]'\n" +) + + +def main(argv: Sequence[str] | None = None) -> int: + """Console entry: start the server, or hint at the ``lsp`` extra when pygls is missing.""" + if importlib.util.find_spec("pygls") is None: + sys.stderr.write(_EXTRA_HINT) + return 1 + from .server import main as serve + + return serve(argv) diff --git a/src/gmat_script/lsp/__main__.py b/src/gmat_script/lsp/__main__.py new file mode 100644 index 0000000..e4170a8 --- /dev/null +++ b/src/gmat_script/lsp/__main__.py @@ -0,0 +1,10 @@ +"""``python -m gmat_script.lsp`` — run the language server over stdio.""" + +from __future__ import annotations + +import sys + +from . import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/gmat_script/lsp/analysis.py b/src/gmat_script/lsp/analysis.py new file mode 100644 index 0000000..9613b5e --- /dev/null +++ b/src/gmat_script/lsp/analysis.py @@ -0,0 +1,387 @@ +"""The language features, as pure functions over source text — the testable core of the server. + +Each function takes a script's current text (plus a cursor position where relevant) and returns +``lsprotocol`` types, reusing the library's existing layers rather than reimplementing them: +diagnostics come from the linter and the parser's syntax errors, hover and completion from the field +catalogue, and definition / references / document-symbol from the tree-sitter queries. The server +(``server.py``) is a thin pygls shell over these; keeping the logic here means it is unit-testable +without a running protocol connection, and that the ≥90% coverage bar is met by ordinary tests. + +Everything is best-effort on a broken parse — the tree-sitter error recovery still yields a usable +tree (D7) — so an in-progress edit degrades gracefully instead of raising. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from lsprotocol import types as lsp + +from ..ast.base import node_text +from ..ast.script import Script +from ..catalog import load_catalog +from ..format import format as format_source +from ..lint import Severity, lint +from ..lint.references import reference_root +from ..parser import parse +from . import queries +from .conversions import LineIndex + +if TYPE_CHECKING: + from tree_sitter import Node + + from ..catalog import Catalog, FieldSpec + from ..parser import Tree + +_DIAGNOSTIC_SOURCE = "gmat-script" + +_SEVERITY: dict[Severity, lsp.DiagnosticSeverity] = { + Severity.ERROR: lsp.DiagnosticSeverity.Error, + Severity.WARNING: lsp.DiagnosticSeverity.Warning, + Severity.INFO: lsp.DiagnosticSeverity.Information, +} + +# A trailing ``resource.partial`` field-access at the cursor (a completion context). The optional +# ``=`` look-ahead is excluded so a value position falls through to ``_VALUE_CONTEXT`` instead. +_FIELD_CONTEXT = re.compile(r"(?P[A-Za-z_]\w*)\s*\.\s*\w*$") +# A trailing ``resource.field = …`` value position at the cursor (enum / object-reference values). +_VALUE_CONTEXT = re.compile(r"(?P[A-Za-z_]\w*)\s*\.\s*(?P\w+)\s*=\s*[^=]*$") + + +# ---------------------------------------------------------------------------- +# diagnostics + + +def diagnostics_for(source: str) -> list[lsp.Diagnostic]: + """Diagnostics for *source*: the linter's findings, or its syntax errors if the parse is broken. + + The linter reduces a script with syntax errors to ``syntax-error`` diagnostics alone (D7), + so this returns live syntax feedback on a half-typed buffer and structural findings on a clean + one. + """ + lines = LineIndex(source) + return [ + lsp.Diagnostic( + range=lines.range_from_internal(finding.start, finding.end), + message=finding.message, + severity=_SEVERITY[finding.severity], + code=finding.rule, + source=_DIAGNOSTIC_SOURCE, + ) + for finding in lint(source) + ] + + +# ---------------------------------------------------------------------------- +# shared per-request analysis + + +@dataclass(frozen=True, slots=True) +class _Analysis: + """Everything the tree-driven features share for one request, computed once.""" + + tree: Tree + script: Script + lines: LineIndex + catalog: Catalog + + @property + def types_by_name(self) -> dict[str, str]: + """Each declared resource name mapped to its GMAT type (for hover / completion).""" + return {name: resource.type for name, resource in self.script.resources.items()} + + +def _analyze(source: str) -> _Analysis: + """Parse *source* and assemble the shared per-request analysis (tolerates a broken parse).""" + tree = parse(source) + return _Analysis(tree, Script(tree), LineIndex(source), load_catalog()) + + +def _identifier_at(analysis: _Analysis, position: lsp.Position) -> Node | None: + """The ``identifier`` token under *position*, or ``None`` if the cursor is not on one. + + Tries the point itself, then the byte just before it, so a cursor resting on an identifier's + trailing edge (where the smallest node is the following token) still resolves to the identifier. + """ + row, column = analysis.lines.point_from_position(position) + root = analysis.tree.root_node + candidates = [(row, column)] + ([(row, column - 1)] if column > 0 else []) + for point in candidates: + node = root.descendant_for_point_range(point, point) + if node is not None and node.type == "identifier": + return node + return None + + +# ---------------------------------------------------------------------------- +# hover + + +def hover_at(source: str, position: lsp.Position) -> lsp.Hover | None: + """Hover documentation for the identifier under the cursor. + + Resolves, in order: a ``resource.field`` property (the catalogue field doc), a ``Create`` type + name (the resource-type summary), and a declared resource name (its type). ``None`` if the + cursor is not on a recognised identifier. + """ + analysis = _analyze(source) + node = _identifier_at(analysis, position) + if node is None: + return None + markdown = ( + _field_doc(analysis, node) or _type_doc(analysis, node) or _resource_doc(analysis, node) + ) + if markdown is None: + return None + return lsp.Hover( + contents=lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), + range=analysis.lines.range_of_node(node), + ) + + +def _field_doc(analysis: _Analysis, node: Node) -> str | None: + """Catalogue documentation when *node* is the ``property`` of a ``resource.field`` reference.""" + parent = node.parent + if parent is None or parent.type != "member_expression": + return None + property_node = parent.child_by_field_name("property") + if property_node is None or property_node.id != node.id: + return None + object_name = node_text(reference_root(parent)) + type_name = analysis.types_by_name.get(object_name) + if type_name is None: + return None + spec = analysis.catalog.field(type_name, node_text(node)) + if spec is None: + return None + return _render_field(type_name, spec) + + +def _type_doc(analysis: _Analysis, node: Node) -> str | None: + """A resource-type summary when *node* is the ``type`` of a ``Create`` command.""" + parent = node.parent + if parent is None or parent.type != "create_command": + return None + type_node = parent.child_by_field_name("type") + if type_node is None or type_node.id != node.id: + return None + name = node_text(node) + if not analysis.catalog.has_type(name): + return None + field_count = len(analysis.catalog.fields(name)) + return f"**{name}** — GMAT resource type\n\n{field_count} catalogued field(s)." + + +def _resource_doc(analysis: _Analysis, node: Node) -> str | None: + """A one-line summary when *node* names a declared resource.""" + name = node_text(node) + type_name = analysis.types_by_name.get(name) + if type_name is None: + return None + return f"**{name}** — `{type_name}`" + + +def _render_field(type_name: str, spec: FieldSpec) -> str: + """Markdown hover body for a catalogue field spec.""" + unit = f" ({spec.unit})" if spec.unit else "" + lines = [f"**{type_name}.{spec.name}** — `{spec.type}`{unit}"] + details: list[str] = [] + if spec.default is not None: + details.append(f"Default: `{spec.default}`") + if spec.allowed: + details.append("Allowed: " + ", ".join(f"`{value}`" for value in spec.allowed)) + if spec.ref_target: + details.append(f"References a `{spec.ref_target}`.") + if spec.read_only: + details.append("_Read-only._") + if details: + lines.append("") + lines.extend(details) + return "\n".join(lines) + + +# ---------------------------------------------------------------------------- +# definition / references + + +def definition_ranges(source: str, position: lsp.Position) -> list[lsp.Range]: + """Ranges of the definition(s) of the resource / symbol named under the cursor (same file).""" + analysis = _analyze(source) + node = _identifier_at(analysis, position) + if node is None: + return [] + name = node_text(node) + return [ + analysis.lines.range_of_node(definition) + for definition in queries.definition_nodes(analysis.tree.root_node) + if node_text(definition) == name + ] + + +def reference_ranges( + source: str, position: lsp.Position, *, include_declaration: bool +) -> list[lsp.Range]: + """Ranges of every reference to the name under the cursor, in source order. + + With *include_declaration* false, occurrences that are the symbol's own declaration name are + dropped (the query captures a declaration name as both a definition and a reference). + """ + analysis = _analyze(source) + node = _identifier_at(analysis, position) + if node is None: + return [] + name = node_text(node) + root = analysis.tree.root_node + matches = [use for use in queries.reference_nodes(root) if node_text(use) == name] + if not include_declaration: + declaration_ids = { + definition.id + for definition in queries.definition_nodes(root) + if node_text(definition) == name + } + matches = [use for use in matches if use.id not in declaration_ids] + ordered = sorted(matches, key=lambda use: use.start_byte) + return [analysis.lines.range_of_node(use) for use in ordered] + + +# ---------------------------------------------------------------------------- +# document symbols + + +def document_symbols(source: str) -> list[lsp.DocumentSymbol]: + """The document outline: each ``Create``'d resource and GmatFunction header, in source order.""" + analysis = _analyze(source) + symbols: list[lsp.DocumentSymbol] = [] + for tag in queries.symbol_tags(analysis.tree.root_node): + kind = lsp.SymbolKind.Class if tag.kind == "class" else lsp.SymbolKind.Function + symbols.append( + lsp.DocumentSymbol( + name=tag.name, + kind=kind, + range=analysis.lines.range_of_node(tag.definition_node), + selection_range=analysis.lines.range_of_node(tag.name_node), + detail=_symbol_detail(tag), + ) + ) + return symbols + + +def _symbol_detail(tag: queries.SymbolTag) -> str | None: + """The resource's GMAT type for a class tag (its ``Create`` type); ``None`` for a function.""" + if tag.kind != "class": + return None + type_node = tag.definition_node.child_by_field_name("type") + return node_text(type_node) if type_node is not None else None + + +# ---------------------------------------------------------------------------- +# completion + + +def completions_at(source: str, position: lsp.Position) -> list[lsp.CompletionItem]: + """Completions at the cursor: enum / reference values, field names, or resource names. + + The context is read from the text before the cursor on its line: ``resource.field =`` offers the + field's enum values (or candidate resources for an object-reference field), ``resource.`` offers + that resource type's field names, and anything else offers the declared resource names. + """ + analysis = _analyze(source) + prefix = analysis.lines.prefix_before(position) + + value = _VALUE_CONTEXT.search(prefix) + if value is not None: + return _value_completions(analysis, value.group("object"), value.group("field")) + + field = _FIELD_CONTEXT.search(prefix) + if field is not None: + type_name = analysis.types_by_name.get(field.group("object")) + return _field_completions(analysis, type_name) if type_name else [] + + return _resource_completions(analysis) + + +def _value_completions( + analysis: _Analysis, object_name: str, field: str +) -> list[lsp.CompletionItem]: + """Completions for a ``resource.field =`` value: enum members, else candidate references.""" + type_name = analysis.types_by_name.get(object_name) + if type_name is None: + return _resource_completions(analysis) + enum = analysis.catalog.enum_values(type_name, field) + if enum: + return [ + lsp.CompletionItem(label=value, kind=lsp.CompletionItemKind.EnumMember) + for value in enum + ] + target = analysis.catalog.ref_target(type_name, field) + return _resource_completions(analysis, target) + + +def _field_completions(analysis: _Analysis, type_name: str) -> list[lsp.CompletionItem]: + """The catalogued field names of *type_name* as completion items.""" + items: list[lsp.CompletionItem] = [] + for field in analysis.catalog.fields(type_name): + spec = analysis.catalog.field(type_name, field) + items.append( + lsp.CompletionItem( + label=field, + kind=lsp.CompletionItemKind.Field, + detail=spec.type if spec is not None else None, + ) + ) + return items + + +def _resource_completions( + analysis: _Analysis, target_type: str | None = None +) -> list[lsp.CompletionItem]: + """Declared resource names as completion items, optionally filtered to a target GMAT type. + + The match prefers the catalogue's alias resolution, falling back to an exact type-name match so + a reference whose target type the catalogue does not carry (an estimation / plugin type) still + narrows correctly instead of degrading to every resource. + """ + wanted = analysis.catalog.resolve(target_type) if target_type else None + items: list[lsp.CompletionItem] = [] + for name, resource in analysis.script.resources.items(): + if target_type is not None and not _type_matches( + analysis, resource.type, target_type, wanted + ): + continue + items.append( + lsp.CompletionItem( + label=name, kind=lsp.CompletionItemKind.Variable, detail=resource.type + ) + ) + return items + + +def _type_matches(analysis: _Analysis, resource_type: str, target: str, wanted: str | None) -> bool: + """Whether *resource_type* satisfies a *target* GMAT type (canonical match, else exact name).""" + resolved = analysis.catalog.resolve(resource_type) + if wanted is not None and resolved is not None: + return resolved == wanted + return resource_type == target + + +# ---------------------------------------------------------------------------- +# formatting + + +def format_edits(source: str) -> list[lsp.TextEdit]: + """A whole-document edit re-emitting *source* canonically, or none if it is already canonical. + + The formatter is whole-document and refuses a script with syntax errors (D14); a broken or + already-canonical buffer yields no edit, so this is also the range-formatting result. + """ + try: + formatted = format_source(source) + except (ValueError, TypeError): + return [] + if formatted == source: + return [] + lines = LineIndex(source) + whole = lsp.Range(start=lsp.Position(line=0, character=0), end=lines.end_position()) + return [lsp.TextEdit(range=whole, new_text=formatted)] diff --git a/src/gmat_script/lsp/conversions.py b/src/gmat_script/lsp/conversions.py new file mode 100644 index 0000000..a187d9f --- /dev/null +++ b/src/gmat_script/lsp/conversions.py @@ -0,0 +1,108 @@ +"""Position conversions between gmat-script's internal model and the LSP wire format. + +The library counts positions two ways: the tree-sitter CST uses 0-indexed ``(row, byte_column)`` +points (``Node.start_point`` / ``end_point``), and the parser and linter surface 1-indexed +``(line, column)`` :class:`~gmat_script.Position` records whose ``column`` is a byte offset (D8). +The Language Server Protocol uses a third convention: 0-indexed lines with 0-indexed UTF-16 +character offsets. This module is the single place those map to one another, so every LSP response +speaks the protocol's units exactly — including on the multi-byte characters where a byte column and +a UTF-16 offset disagree. + +:class:`LineIndex` splits a source into its line byte-spans once and converts both ways: CST +points and internal positions out to an :class:`lsprotocol.types.Range`, and an incoming LSP +:class:`~lsprotocol.types.Position` back to the ``(row, byte_column)`` point tree-sitter's +``descendant_for_point_range`` expects. It is total — an out-of-range line or column clamps rather +than raising — so a stale or malformed client position never crashes a handler. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from lsprotocol import types as lsp + +if TYPE_CHECKING: + from tree_sitter import Node + + from ..parser import Position + + +def _utf16_units(text: str) -> int: + """The number of UTF-16 code units *text* encodes to — the LSP character unit. + + A Basic-Multilingual-Plane character is one unit; an astral-plane one is a surrogate pair (two). + Encoding to UTF-16 and halving the byte count counts both without a per-character branch. + """ + return len(text.encode("utf-16-le")) // 2 + + +class LineIndex: + """A reusable per-source converter between CST / internal positions and LSP positions.""" + + __slots__ = ("_lines",) + + def __init__(self, source: str) -> None: + # One entry per line, split on LF; a CRLF line keeps its trailing '\r'. tree-sitter counts + # rows by '\n' and includes the '\r' in the line's bytes, so the byte columns line up. + self._lines: list[bytes] = source.encode("utf-8").split(b"\n") + + def _line_bytes(self, row: int) -> bytes: + """The raw bytes of line *row* (LF-split), or empty for an out-of-range row.""" + if 0 <= row < len(self._lines): + return self._lines[row] + return b"" + + def position_from_point(self, row: int, byte_column: int) -> lsp.Position: + """Convert a 0-indexed tree-sitter ``(row, byte_column)`` point to an LSP position.""" + prefix = self._line_bytes(row)[:byte_column] + return lsp.Position(line=row, character=_utf16_units(prefix.decode("utf-8", "replace"))) + + def position_from_internal(self, position: Position) -> lsp.Position: + """Convert a 1-indexed internal :class:`~gmat_script.Position` (byte column) to LSP.""" + return self.position_from_point(position.line - 1, position.column - 1) + + def range_of_node(self, node: Node) -> lsp.Range: + """The LSP range spanning *node*, from its start point to its end point.""" + return lsp.Range( + start=self.position_from_point(node.start_point.row, node.start_point.column), + end=self.position_from_point(node.end_point.row, node.end_point.column), + ) + + def range_from_internal(self, start: Position, end: Position) -> lsp.Range: + """The LSP range between two 1-indexed internal positions (a linter diagnostic span).""" + return lsp.Range( + start=self.position_from_internal(start), + end=self.position_from_internal(end), + ) + + def end_position(self) -> lsp.Position: + """The LSP position one past the last character of the source (a whole-document end).""" + row = len(self._lines) - 1 + return self.position_from_point(row, len(self._line_bytes(row))) + + def point_from_position(self, position: lsp.Position) -> tuple[int, int]: + """Convert an incoming LSP position to a 0-indexed ``(row, byte_column)`` point. + + The inverse of :meth:`position_from_point`: walk the line's characters, accumulating UTF-16 + units until the requested character offset is reached, then return the byte offset there. A + character offset past the line's end clamps to the line's end. + """ + row = position.line + line = self._line_bytes(row).decode("utf-8", "replace") + utf16 = 0 + byte_column = 0 + for char in line: + if utf16 >= position.character: + break + utf16 += 1 if ord(char) <= 0xFFFF else 2 + byte_column += len(char.encode("utf-8")) + return row, byte_column + + def prefix_before(self, position: lsp.Position) -> str: + """The text on *position*'s line from the line start up to the cursor. + + The completion logic reads this to decide context (a trailing ``resource.`` field access, a + ``resource.field =`` value position, or a bare identifier). + """ + row, byte_column = self.point_from_position(position) + return self._line_bytes(row)[:byte_column].decode("utf-8", "replace") diff --git a/src/gmat_script/lsp/queries.py b/src/gmat_script/lsp/queries.py new file mode 100644 index 0000000..444f800 --- /dev/null +++ b/src/gmat_script/lsp/queries.py @@ -0,0 +1,92 @@ +"""Running the grammar's tree-sitter queries for the language server's navigation features. + +Two of the vendored ``.scm`` queries (decisions D1 / #21) run over a parsed tree: ``locals.scm`` +captures resource / function / loop-variable *definitions* and every identifier *reference* (go-to- +definition and find-references), and ``tags.scm`` captures the declared-symbol *tags* the document +outline is built from. The queries are vendored into the package at build time (``hatch_build.py``) +and loaded through :mod:`importlib.resources`, so they load the same from a wheel or an editable +install — no GMAT, and no grammar source tree, at runtime. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cache +from importlib import resources +from typing import TYPE_CHECKING + +from tree_sitter import Query, QueryCursor + +from ..ast.base import node_text +from ..parser import _gmat_language + +if TYPE_CHECKING: + from tree_sitter import Node + +# Anchor the query files at the grammar package; they are vendored under its ``queries/`` directory. +_QUERY_ANCHOR = "gmat_script._grammar" +_QUERY_DIR = "queries" + + +@cache +def _load_query(name: str) -> Query: + """Compile and cache the vendored query *name* (e.g. ``"locals.scm"``).""" + source = (resources.files(_QUERY_ANCHOR) / _QUERY_DIR / name).read_text(encoding="utf-8") + return Query(_gmat_language(), source) + + +def _captures(query_name: str, root: Node) -> dict[str, list[Node]]: + """Run *query_name* over *root*; return its capture-name → matched-nodes mapping.""" + return QueryCursor(_load_query(query_name)).captures(root) + + +def definition_nodes(root: Node) -> list[Node]: + """The ``@local.definition`` name nodes — resource, function, parameter, and loop-variable + declarations (``locals.scm``).""" + return _captures("locals.scm", root).get("local.definition", []) + + +def reference_nodes(root: Node) -> list[Node]: + """The ``@local.reference`` name nodes — every identifier use (``locals.scm``). + + The query captures *every* identifier, so a declaration's own name node appears here too; the + caller filters against :func:`definition_nodes` when a use-only set is wanted. + """ + return _captures("locals.scm", root).get("local.reference", []) + + +@dataclass(frozen=True, slots=True) +class SymbolTag: + """One declared-symbol tag from ``tags.scm``: its kind, name, and the spans for each.""" + + kind: str # "class" (a Create'd resource) or "function" (a GmatFunction header) + name: str + name_node: Node + definition_node: Node + + +def symbol_tags(root: Node) -> list[SymbolTag]: + """The declared-symbol tags (``@definition.class`` / ``@definition.function``) in source order. + + The ``@reference.call`` tags (command and call sites) are not part of the outline, so matches + that carry only a reference capture are skipped. + """ + cursor = QueryCursor(_load_query("tags.scm")) + tags: list[SymbolTag] = [] + for _, capture in cursor.matches(root): + names = capture.get("name") + if not names: # pragma: no cover - every tags.scm pattern captures a @name + continue + for capture_name in ("definition.class", "definition.function"): + definitions = capture.get(capture_name) + if definitions: + tags.append( + SymbolTag( + kind=capture_name.split(".", 1)[1], + name=node_text(names[0]), + name_node=names[0], + definition_node=definitions[0], + ) + ) + tags.sort(key=lambda tag: tag.definition_node.start_byte) + return tags diff --git a/src/gmat_script/lsp/server.py b/src/gmat_script/lsp/server.py new file mode 100644 index 0000000..a7fefe4 --- /dev/null +++ b/src/gmat_script/lsp/server.py @@ -0,0 +1,181 @@ +"""The pygls language server — a thin protocol shell over :mod:`gmat_script.lsp.analysis`. + +The server registers the LSP feature handlers, syncs documents (pygls keeps each buffer +current from ``didChange`` notifications), and debounces diagnostics so a fast typist triggers one +parse per quiet window rather than one per keystroke. Every request handler delegates to a pure +analysis function and is wrapped so a malformed buffer can never crash the connection (D7); the +diagnostics path goes through the linter, which already degrades to syntax errors on a broken parse. + +Handlers are module-level functions taking ``(ls, params)`` — pygls injects the server because the +first parameter is named ``ls`` — and per-URI debounce state lives on the :class:`LanguageServer` +subclass, so both are reachable in-process for unit tests; only the stdio run loop in :func:`main` +needs a real connection. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Callable +from typing import TYPE_CHECKING, TypeVar + +from lsprotocol import types +from pygls.lsp.server import LanguageServer + +from .. import __version__ +from . import analysis + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = ["SERVER_NAME", "GmatScriptLanguageServer", "create_server", "main"] + +SERVER_NAME = "gmat-script-lsp" +# Quiet window after the last keystroke before diagnostics recompute (the issue's debounce). +_DEBOUNCE_SECONDS = 0.2 + +_LOGGER = logging.getLogger(__name__) +_T = TypeVar("_T") + + +def _safe(thunk: Callable[[], _T], default: _T) -> _T: + """Run *thunk*, returning *default* if it raises — a request must never crash the server.""" + try: + return thunk() + except Exception: # an editor server stays up no matter what a buffer holds + _LOGGER.exception("gmat-script-lsp request handler failed") + return default + + +class GmatScriptLanguageServer(LanguageServer): + """The gmat-script language server, carrying per-URI diagnostics debounce state.""" + + def __init__(self) -> None: + super().__init__(SERVER_NAME, __version__) + # Per-URI change counter: each change bumps it, and a debounced task publishes only if its + # generation is still current, so a superseded keystroke never publishes a stale parse. + self.generations: dict[str, int] = {} + + def refresh_diagnostics(self, uri: str) -> None: + """Recompute and publish diagnostics for the document at *uri*.""" + document = self.workspace.get_text_document(uri) + self.text_document_publish_diagnostics( + types.PublishDiagnosticsParams( + uri=uri, + version=document.version, + diagnostics=analysis.diagnostics_for(document.source), + ) + ) + + +def _did_open(ls: GmatScriptLanguageServer, params: types.DidOpenTextDocumentParams) -> None: + ls.refresh_diagnostics(params.text_document.uri) + + +async def _did_change( + ls: GmatScriptLanguageServer, params: types.DidChangeTextDocumentParams +) -> None: + uri = params.text_document.uri + generation = ls.generations.get(uri, 0) + 1 + ls.generations[uri] = generation + await asyncio.sleep(_DEBOUNCE_SECONDS) + if ls.generations.get(uri) == generation: + ls.refresh_diagnostics(uri) + + +def _did_close(ls: GmatScriptLanguageServer, params: types.DidCloseTextDocumentParams) -> None: + uri = params.text_document.uri + ls.generations.pop(uri, None) + # Clear the closed file's diagnostics in the client. + ls.text_document_publish_diagnostics(types.PublishDiagnosticsParams(uri=uri, diagnostics=[])) + + +def _hover(ls: GmatScriptLanguageServer, params: types.HoverParams) -> types.Hover | None: + document = ls.workspace.get_text_document(params.text_document.uri) + return _safe(lambda: analysis.hover_at(document.source, params.position), None) + + +def _definition( + ls: GmatScriptLanguageServer, params: types.DefinitionParams +) -> list[types.Location]: + uri = params.text_document.uri + document = ls.workspace.get_text_document(uri) + empty: list[types.Range] = [] + ranges = _safe(lambda: analysis.definition_ranges(document.source, params.position), empty) + return [types.Location(uri=uri, range=found) for found in ranges] + + +def _references( + ls: GmatScriptLanguageServer, params: types.ReferenceParams +) -> list[types.Location]: + uri = params.text_document.uri + document = ls.workspace.get_text_document(uri) + include = params.context.include_declaration + empty: list[types.Range] = [] + ranges = _safe( + lambda: analysis.reference_ranges( + document.source, params.position, include_declaration=include + ), + empty, + ) + return [types.Location(uri=uri, range=found) for found in ranges] + + +def _document_symbol( + ls: GmatScriptLanguageServer, params: types.DocumentSymbolParams +) -> list[types.DocumentSymbol]: + document = ls.workspace.get_text_document(params.text_document.uri) + return _safe(lambda: analysis.document_symbols(document.source), []) + + +def _completion( + ls: GmatScriptLanguageServer, params: types.CompletionParams +) -> types.CompletionList: + document = ls.workspace.get_text_document(params.text_document.uri) + empty: list[types.CompletionItem] = [] + items = _safe(lambda: analysis.completions_at(document.source, params.position), empty) + return types.CompletionList(is_incomplete=False, items=items) + + +def _formatting( + ls: GmatScriptLanguageServer, params: types.DocumentFormattingParams +) -> list[types.TextEdit]: + document = ls.workspace.get_text_document(params.text_document.uri) + return _safe(lambda: analysis.format_edits(document.source), []) + + +def _range_formatting( + ls: GmatScriptLanguageServer, params: types.DocumentRangeFormattingParams +) -> list[types.TextEdit]: + # The formatter is whole-document (D14), so a range request reformats the whole buffer. + document = ls.workspace.get_text_document(params.text_document.uri) + return _safe(lambda: analysis.format_edits(document.source), []) + + +def _register(server: GmatScriptLanguageServer) -> None: + """Register every feature handler on *server*.""" + server.feature(types.TEXT_DOCUMENT_DID_OPEN)(_did_open) + server.feature(types.TEXT_DOCUMENT_DID_CHANGE)(_did_change) + server.feature(types.TEXT_DOCUMENT_DID_CLOSE)(_did_close) + server.feature(types.TEXT_DOCUMENT_HOVER)(_hover) + server.feature(types.TEXT_DOCUMENT_DEFINITION)(_definition) + server.feature(types.TEXT_DOCUMENT_REFERENCES)(_references) + server.feature(types.TEXT_DOCUMENT_DOCUMENT_SYMBOL)(_document_symbol) + server.feature( + types.TEXT_DOCUMENT_COMPLETION, types.CompletionOptions(trigger_characters=["."]) + )(_completion) + server.feature(types.TEXT_DOCUMENT_FORMATTING)(_formatting) + server.feature(types.TEXT_DOCUMENT_RANGE_FORMATTING)(_range_formatting) + + +def create_server() -> GmatScriptLanguageServer: + """Build a configured (but not yet running) language server.""" + server = GmatScriptLanguageServer() + _register(server) + return server + + +def main(argv: Sequence[str] | None = None) -> int: # pragma: no cover - stdio run loop + """Run the server over stdio until the client disconnects (*argv* is unused).""" + create_server().start_io() + return 0 diff --git a/tests/test_lsp_analysis.py b/tests/test_lsp_analysis.py new file mode 100644 index 0000000..0ef72cd --- /dev/null +++ b/tests/test_lsp_analysis.py @@ -0,0 +1,308 @@ +"""Tests for the language features (:mod:`gmat_script.lsp.analysis`). + +These exercise the pure feature functions directly — the server (``test_lsp_server.py``) is a thin +shell over them. Coverage of the catalogue-driven paths uses fields whose reflection carries +the relevant data (``BatchEstimator`` has both an enum field and an object-reference field), and a +synthetic :class:`~gmat_script.FieldSpec` covers the hover-rendering branches deterministically. +Every feature is also checked against a malformed buffer to confirm it degrades rather than raises. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from lsprotocol import types as lsp + +from gmat_script.catalog import FieldSpec +from gmat_script.lsp import analysis + +_SCRIPT = """Create Spacecraft Sat +Sat.SMA = 7000 +Create ForceModel FM + +BeginMissionSequence +Propagate Prop(Sat) +""" + + +def pos(line: int, character: int) -> lsp.Position: + return lsp.Position(line=line, character=character) + + +def markdown(hover: lsp.Hover | None) -> str: + """The markdown body of a hover, asserting it is present and a ``MarkupContent``.""" + assert hover is not None + assert isinstance(hover.contents, lsp.MarkupContent) + return hover.contents.value + + +# ---------------------------------------------------------------------------- +# diagnostics + + +def test_diagnostics_reports_lint_findings() -> None: + # FM is created but never referenced — the linter's info-level unused-resource rule. + diagnostics = analysis.diagnostics_for(_SCRIPT) + unused = [d for d in diagnostics if d.code == "unused-resource"] + assert len(unused) == 1 + finding = unused[0] + assert finding.severity == lsp.DiagnosticSeverity.Information + assert finding.source == "gmat-script" + assert finding.range.start.line == 2 # the `Create ForceModel FM` line (0-indexed) + + +def test_diagnostics_reports_syntax_errors() -> None: + diagnostics = analysis.diagnostics_for("Create Spacecraft\nSat.SMA = = =\n") + assert any(d.code == "syntax-error" for d in diagnostics) + assert all(d.severity == lsp.DiagnosticSeverity.Error for d in diagnostics) + + +def test_diagnostics_clean_script_is_empty() -> None: + clean = "Create Spacecraft Sat\nSat.SMA = 7000\n\nBeginMissionSequence\nPropagate Prop(Sat)\n" + assert analysis.diagnostics_for(clean) == [] + + +# ---------------------------------------------------------------------------- +# hover + + +def test_hover_on_field_uses_catalogue() -> None: + hover = analysis.hover_at(_SCRIPT, pos(1, 5)) # on `SMA` in `Sat.SMA` + assert hover is not None and hover.range is not None + assert "Spacecraft.SMA" in markdown(hover) + assert hover.range.start == pos(1, 4) + + +def test_hover_on_resource_name() -> None: + hover = analysis.hover_at(_SCRIPT, pos(0, 19)) # on `Sat` in the Create + assert markdown(hover) == "**Sat** — `Spacecraft`" + + +def test_hover_on_create_type() -> None: + hover = analysis.hover_at(_SCRIPT, pos(0, 8)) # on `Spacecraft` + assert "GMAT resource type" in markdown(hover) + + +def test_hover_on_object_of_member_falls_through_to_resource() -> None: + # The cursor is on `Sat` (the object) of `Sat.SMA`, not the field — so the field doc is skipped + # and the resource summary is shown instead. + hover = analysis.hover_at(_SCRIPT, pos(1, 1)) + assert markdown(hover) == "**Sat** — `Spacecraft`" + + +def test_hover_on_unknown_field_is_none() -> None: + assert analysis.hover_at("Create Spacecraft Sat\nSat.Bogus = 1\n", pos(1, 6)) is None + + +def test_hover_on_uncatalogued_type_is_none() -> None: + # A plugin type absent from the default catalogue (D15) has no type summary. + assert analysis.hover_at("Create OpenFramesView ofv\n", pos(0, 10)) is None + + +def test_hover_on_field_of_undeclared_resource_is_none() -> None: + assert analysis.hover_at("Foo.SMA = 7000\n", pos(0, 5)) is None + + +def test_hover_off_identifier_is_none() -> None: + assert analysis.hover_at(_SCRIPT, pos(1, 8)) is None # on the `=` / whitespace + + +def test_hover_trailing_edge_of_identifier_resolves() -> None: + # Cursor at the end of `Sat` (column past the last char) still resolves to the identifier. + assert "Sat" in markdown(analysis.hover_at(_SCRIPT, pos(0, 21))) + + +def test_render_field_includes_all_detail_lines() -> None: + spec = FieldSpec( + name="Axes", + type="enum", + gmat_type="Enumeration", + read_only=True, + allowed=("MJ2000Eq", "Fixed"), + ref_target="CoordinateSystem", + default="MJ2000Eq", + unit="deg", + ) + rendered = analysis._render_field("ImpulsiveBurn", spec) + assert "**ImpulsiveBurn.Axes** — `enum` (deg)" in rendered + assert "Default: `MJ2000Eq`" in rendered + assert "`MJ2000Eq`, `Fixed`" in rendered + assert "References a `CoordinateSystem`." in rendered + assert "_Read-only._" in rendered + + +def test_render_field_minimal_has_no_detail_block() -> None: + spec = FieldSpec(name="SMA", type="real", gmat_type="Real", read_only=False) + assert analysis._render_field("Spacecraft", spec) == "**Spacecraft.SMA** — `real`" + + +# ---------------------------------------------------------------------------- +# definition / references + + +def test_definition_from_usage() -> None: + line = _SCRIPT.splitlines()[5] + column = line.index("Sat", line.index("Prop")) + ranges = analysis.definition_ranges(_SCRIPT, pos(5, column + 1)) + assert len(ranges) == 1 + assert ranges[0] == lsp.Range(start=pos(0, 18), end=pos(0, 21)) + + +def test_definition_off_identifier_is_empty() -> None: + assert analysis.definition_ranges(_SCRIPT, pos(1, 8)) == [] + + +def test_definition_of_unknown_name_is_empty() -> None: + # `Prop` is referenced but never created, so it has no definition. + line = _SCRIPT.splitlines()[5] + ranges = analysis.definition_ranges(_SCRIPT, pos(5, line.index("Prop") + 1)) + assert ranges == [] + + +def test_references_include_and_exclude_declaration() -> None: + with_decl = analysis.reference_ranges(_SCRIPT, pos(0, 19), include_declaration=True) + without_decl = analysis.reference_ranges(_SCRIPT, pos(0, 19), include_declaration=False) + # The declaration occurrence (the Create name) is the one dropped. + assert lsp.Range(start=pos(0, 18), end=pos(0, 21)) in with_decl + assert lsp.Range(start=pos(0, 18), end=pos(0, 21)) not in without_decl + assert len(with_decl) == len(without_decl) + 1 + # Results are in source order. + starts = [r.start for r in with_decl] + assert starts == sorted(starts, key=lambda p: (p.line, p.character)) + + +def test_references_off_identifier_is_empty() -> None: + assert analysis.reference_ranges(_SCRIPT, pos(1, 8), include_declaration=True) == [] + + +# ---------------------------------------------------------------------------- +# document symbols + + +def test_document_symbols_outline() -> None: + symbols = analysis.document_symbols(_SCRIPT) + assert [(s.name, s.kind, s.detail) for s in symbols] == [ + ("Sat", lsp.SymbolKind.Class, "Spacecraft"), + ("FM", lsp.SymbolKind.Class, "ForceModel"), + ] + # The selection range is the name; the full range spans the Create. + assert symbols[0].selection_range.start == pos(0, 18) + assert symbols[0].range.start == pos(0, 0) + + +def test_document_symbols_function() -> None: + symbols = analysis.document_symbols("function [q] = Quat(a)\n") + assert (symbols[0].name, symbols[0].kind, symbols[0].detail) == ( + "Quat", + lsp.SymbolKind.Function, + None, + ) + + +# ---------------------------------------------------------------------------- +# completion + + +def test_completion_resource_names_in_bare_position() -> None: + items = {item.label: item for item in analysis.completions_at(_SCRIPT, pos(5, 0))} + assert set(items) == {"Sat", "FM"} + assert items["Sat"].kind == lsp.CompletionItemKind.Variable + assert items["Sat"].detail == "Spacecraft" + + +def test_completion_field_names_after_dot() -> None: + items = analysis.completions_at("Create Spacecraft Sat\nSat.\n", pos(1, 4)) + labels = {item.label for item in items} + assert {"SMA", "ECC", "INC"} <= labels + assert all(item.kind == lsp.CompletionItemKind.Field for item in items) + + +def test_completion_field_context_with_undeclared_object_is_empty() -> None: + assert analysis.completions_at("Foo.\n", pos(0, 4)) == [] + + +def test_completion_enum_values_in_value_position() -> None: + source = "Create BatchEstimator BE\nBE.ReportStyle = \n" + items = analysis.completions_at(source, pos(1, len("BE.ReportStyle = "))) + assert [item.label for item in items] == ["Normal", "Concise", "Verbose", "Debug"] + assert all(item.kind == lsp.CompletionItemKind.EnumMember for item in items) + + +def test_completion_object_reference_narrows_to_target_type() -> None: + source = ( + "Create BatchEstimator BE\n" + "Create TrackingFileSet tfs\n" + "Create MeasurementModel mm\n" + "BE.Measurements = \n" + ) + items = analysis.completions_at(source, pos(3, len("BE.Measurements = "))) + assert [item.label for item in items] == ["mm"] + + +def test_completion_object_reference_matches_via_alias_resolution() -> None: + # BE.Propagator references a PropSetup; `Create Propagator` builds a PropSetup (an alias), so + # the candidate narrows by canonical resolution, not exact spelling. + source = ( + "Create BatchEstimator BE\n" + "Create Propagator prop\n" + "Create Spacecraft Sat\n" + "BE.Propagator = \n" + ) + items = analysis.completions_at(source, pos(3, len("BE.Propagator = "))) + assert [item.label for item in items] == ["prop"] + + +def test_completion_value_position_without_enum_or_ref_offers_resources() -> None: + # SMA is a plain real field: a value position there falls back to candidate resource names. + source = "Create Spacecraft Sat\nCreate Variable v\nSat.SMA = \n" + labels = {item.label for item in analysis.completions_at(source, pos(2, len("Sat.SMA = ")))} + assert labels == {"Sat", "v"} + + +def test_completion_value_position_with_undeclared_object_offers_resources() -> None: + source = "Create Spacecraft Sat\nFoo.Bar = \n" + labels = {item.label for item in analysis.completions_at(source, pos(1, len("Foo.Bar = ")))} + assert labels == {"Sat"} + + +# ---------------------------------------------------------------------------- +# formatting + + +def test_format_edits_reformats() -> None: + edits = analysis.format_edits("GMAT Sat.SMA=7000;\n") + assert len(edits) == 1 + assert edits[0].new_text == "Sat.SMA = 7000\n" + assert edits[0].range.start == pos(0, 0) + + +def test_format_edits_already_canonical_is_empty() -> None: + assert analysis.format_edits("Sat.SMA = 7000\n") == [] + + +def test_format_edits_syntax_error_is_empty() -> None: + assert analysis.format_edits("Create = = =\n") == [] + + +# ---------------------------------------------------------------------------- +# robustness: malformed input never raises + + +_MALFORMED = "Create Spacecraft\nSat.SMA = = =\nPropagate {{{\n" + + +@pytest.mark.parametrize( + "call", + [ + lambda: analysis.diagnostics_for(_MALFORMED), + lambda: analysis.hover_at(_MALFORMED, pos(1, 2)), + lambda: analysis.definition_ranges(_MALFORMED, pos(1, 2)), + lambda: analysis.reference_ranges(_MALFORMED, pos(1, 2), include_declaration=True), + lambda: analysis.document_symbols(_MALFORMED), + lambda: analysis.completions_at(_MALFORMED, pos(1, 2)), + lambda: analysis.format_edits(_MALFORMED), + ], +) +def test_features_never_raise_on_malformed_input(call: Callable[[], object]) -> None: + call() # must not raise; the return value is feature-specific and asserted elsewhere diff --git a/tests/test_lsp_conversions.py b/tests/test_lsp_conversions.py new file mode 100644 index 0000000..ac1c756 --- /dev/null +++ b/tests/test_lsp_conversions.py @@ -0,0 +1,102 @@ +"""Tests for the LSP position conversions (:mod:`gmat_script.lsp.conversions`). + +The conversions are the one place byte offsets, internal 1-indexed positions, and LSP 0-indexed +UTF-16 positions meet, so they are exercised directly — including the multi-byte and astral-plane +cases where a byte column and a UTF-16 character offset diverge, and the CRLF / out-of-range edges +where a naive implementation would crash or miscount. +""" + +from __future__ import annotations + +import pytest +from lsprotocol import types as lsp + +from gmat_script.lsp.conversions import LineIndex, _utf16_units +from gmat_script.parser import Position, parse + +# A line mixing a 2-byte/1-unit char (é), a 4-byte/2-unit astral char (😀), and an ASCII char. +_MIXED = "é😀x" + + +@pytest.mark.parametrize( + ("text", "units"), + [("", 0), ("abc", 3), ("é", 1), ("😀", 2), (_MIXED, 4)], +) +def test_utf16_units(text: str, units: int) -> None: + assert _utf16_units(text) == units + + +def test_position_from_point_ascii() -> None: + index = LineIndex("Create Spacecraft Sat\nSat.SMA = 7000\n") + assert index.position_from_point(1, 4) == lsp.Position(line=1, character=4) + + +def test_position_from_point_multibyte_and_astral() -> None: + index = LineIndex(_MIXED) + # Byte columns fall on character boundaries: after é (2 bytes), after é+😀 (6 bytes), after x. + assert index.position_from_point(0, 0) == lsp.Position(line=0, character=0) + assert index.position_from_point(0, 2) == lsp.Position(line=0, character=1) + assert index.position_from_point(0, 6) == lsp.Position(line=0, character=3) + assert index.position_from_point(0, 7) == lsp.Position(line=0, character=4) + + +def test_position_from_point_out_of_range_row_clamps() -> None: + index = LineIndex("one line\n") + # A row past the end yields an empty line, character 0 — never an IndexError. + assert index.position_from_point(99, 5) == lsp.Position(line=99, character=0) + + +def test_position_from_internal_is_one_indexed() -> None: + index = LineIndex("abcdef\n") + # Internal Position is 1-indexed line + 1-indexed byte column; LSP is 0-indexed. + assert index.position_from_internal(Position(line=1, column=1)) == lsp.Position(0, 0) + assert index.position_from_internal(Position(line=1, column=4)) == lsp.Position(0, 3) + + +def test_crlf_line_split_keeps_columns_aligned() -> None: + index = LineIndex("ab\r\ncd\r\n") + # tree-sitter keeps the trailing '\r' in the line, so a column past 'b' counts it. + assert index.position_from_point(0, 1) == lsp.Position(line=0, character=1) + assert index.position_from_point(1, 2) == lsp.Position(line=1, character=2) + + +def test_range_of_node_spans_the_token() -> None: + source = "Create Spacecraft Sat\n" + index = LineIndex(source) + sat = parse(source).root_node.descendant_for_point_range((0, 18), (0, 18)) + assert sat is not None and sat.type == "identifier" + found = index.range_of_node(sat) + assert found == lsp.Range(start=lsp.Position(0, 18), end=lsp.Position(0, 21)) + + +def test_range_from_internal() -> None: + index = LineIndex("abcdef\n") + found = index.range_from_internal(Position(1, 2), Position(1, 5)) + assert found == lsp.Range(start=lsp.Position(0, 1), end=lsp.Position(0, 4)) + + +def test_end_position() -> None: + assert LineIndex("abc\ndef").end_position() == lsp.Position(line=1, character=3) + # A trailing newline makes the final (empty) line the document end. + assert LineIndex("abc\n").end_position() == lsp.Position(line=1, character=0) + + +@pytest.mark.parametrize("character", [0, 1, 3, 4]) +def test_point_from_position_round_trips(character: int) -> None: + index = LineIndex(_MIXED) + row, byte_column = index.point_from_position(lsp.Position(line=0, character=character)) + assert row == 0 + # Converting the resulting point back yields the original character offset. + assert index.position_from_point(0, byte_column).character == character + + +def test_point_from_position_clamps_past_line_end() -> None: + index = LineIndex("abc\n") + # A character offset beyond the line clamps to the line's byte length. + assert index.point_from_position(lsp.Position(line=0, character=99)) == (0, 3) + + +def test_prefix_before() -> None: + index = LineIndex("Create Spacecraft Sat\nSat.SMA = 7000\n") + assert index.prefix_before(lsp.Position(line=1, character=4)) == "Sat." + assert index.prefix_before(lsp.Position(line=1, character=0)) == "" diff --git a/tests/test_lsp_queries.py b/tests/test_lsp_queries.py new file mode 100644 index 0000000..6b9b041 --- /dev/null +++ b/tests/test_lsp_queries.py @@ -0,0 +1,49 @@ +"""Tests for the LSP tree-sitter query helpers (:mod:`gmat_script.lsp.queries`).""" + +from __future__ import annotations + +from gmat_script.ast.base import node_text +from gmat_script.lsp import queries +from gmat_script.parser import parse + +_SCRIPT = """Create Spacecraft Sat +Create ForceModel FM +Sat.SMA = 7000 + +BeginMissionSequence +Propagate Prop(Sat) +""" + +# A GmatFunction header — the function-definition tag (D10). +_FUNCTION = "function [dr, dv] = RICdelta(rv1, rv2)\n" + + +def test_definition_nodes_are_create_names() -> None: + names = [node_text(node) for node in queries.definition_nodes(parse(_SCRIPT).root_node)] + assert names == ["Sat", "FM"] + + +def test_reference_nodes_capture_every_identifier_use() -> None: + names = [node_text(node) for node in queries.reference_nodes(parse(_SCRIPT).root_node)] + # The generous reference capture includes type names and command keywords, and a resource used + # several times appears once per use. + assert names.count("Sat") >= 2 + assert "Propagate" in names and "Spacecraft" in names + + +def test_symbol_tags_resources_are_class_tags_in_source_order() -> None: + tags = queries.symbol_tags(parse(_SCRIPT).root_node) + assert [(tag.kind, tag.name) for tag in tags] == [("class", "Sat"), ("class", "FM")] + # The name node is the resource name; the definition node spans the whole Create. + assert node_text(tags[0].name_node) == "Sat" + assert node_text(tags[0].definition_node).startswith("Create Spacecraft") + + +def test_symbol_tags_include_function_definitions() -> None: + tags = queries.symbol_tags(parse(_FUNCTION).root_node) + assert ("function", "RICdelta") in [(tag.kind, tag.name) for tag in tags] + + +def test_load_query_is_cached() -> None: + # The compiled query is memoised, so repeated loads return the same object. + assert queries._load_query("locals.scm") is queries._load_query("locals.scm") diff --git a/tests/test_lsp_server.py b/tests/test_lsp_server.py new file mode 100644 index 0000000..e0d1bf7 --- /dev/null +++ b/tests/test_lsp_server.py @@ -0,0 +1,407 @@ +"""Tests for the pygls server (:mod:`gmat_script.lsp.server`) and the console entry. + +Two layers: the request handlers are driven **in-process** against a server with a populated +workspace (covering the protocol shell, the debounce, and the never-crash wrapper without a real +connection), and one end-to-end **smoke test drives the server over stdio** as a subprocess — the +issue's definition-of-done — asserting every feature responds on a known document, that diagnostics +update live on an edit, and that malformed input never crashes the connection. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib.util +import json +import queue +import subprocess +import sys +import threading +from collections.abc import Iterator +from typing import Any, cast + +import pytest +from lsprotocol import types as lsp +from pygls.workspace import Workspace + +from gmat_script import lsp as lsp_pkg +from gmat_script.lsp import analysis as analysis_module +from gmat_script.lsp import server as server_module + +URI = "file:///mission.script" +SCRIPT = ( + "Create Spacecraft Sat\n" + "Sat.SMA = 7000\n" + "Create ForceModel FM\n" + "\n" + "BeginMissionSequence\n" + "Propagate Prop(Sat)\n" +) + + +# ---------------------------------------------------------------------------- +# in-process handler tests + + +def _server_with(text: str = SCRIPT, uri: str = URI) -> server_module.GmatScriptLanguageServer: + """A server whose workspace already holds *text* at *uri* (no connection needed).""" + server = server_module.create_server() + server.protocol._workspace = Workspace(None, position_encoding=lsp.PositionEncodingKind.Utf16) + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="gmat", version=1, text=text) + ) + return server + + +def _capture_publishes( + server: server_module.GmatScriptLanguageServer, + monkeypatch: pytest.MonkeyPatch, +) -> list[lsp.PublishDiagnosticsParams]: + """Replace the server's publish notification with a recorder (no transport in unit tests).""" + published: list[lsp.PublishDiagnosticsParams] = [] + monkeypatch.setattr( + server, "text_document_publish_diagnostics", lambda params: published.append(params) + ) + return published + + +def _ident(uri: str = URI) -> lsp.TextDocumentIdentifier: + return lsp.TextDocumentIdentifier(uri=uri) + + +def test_did_open_publishes_diagnostics(monkeypatch: pytest.MonkeyPatch) -> None: + server = _server_with() + published = _capture_publishes(server, monkeypatch) + item = lsp.TextDocumentItem(uri=URI, language_id="gmat", version=1, text=SCRIPT) + server_module._did_open(server, lsp.DidOpenTextDocumentParams(text_document=item)) + assert len(published) == 1 + assert published[0].uri == URI + assert any(d.code == "unused-resource" for d in published[0].diagnostics) + + +def test_did_change_publishes_after_debounce(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server_module, "_DEBOUNCE_SECONDS", 0) + server = _server_with() + published = _capture_publishes(server, monkeypatch) + params = lsp.DidChangeTextDocumentParams( + text_document=lsp.VersionedTextDocumentIdentifier(uri=URI, version=2), + content_changes=[], + ) + asyncio.run(server_module._did_change(server, params)) + assert len(published) == 1 and published[0].uri == URI + + +def test_did_change_superseded_does_not_publish(monkeypatch: pytest.MonkeyPatch) -> None: + server = _server_with() + published = _capture_publishes(server, monkeypatch) + + async def _supersede(_: float) -> None: + # A newer change arrives during the debounce wait, bumping the generation. + server.generations[URI] = 999 + + monkeypatch.setattr(asyncio, "sleep", _supersede) + params = lsp.DidChangeTextDocumentParams( + text_document=lsp.VersionedTextDocumentIdentifier(uri=URI, version=2), + content_changes=[], + ) + asyncio.run(server_module._did_change(server, params)) + assert published == [] + + +def test_did_close_clears_diagnostics(monkeypatch: pytest.MonkeyPatch) -> None: + server = _server_with() + server.generations[URI] = 3 + published = _capture_publishes(server, monkeypatch) + server_module._did_close(server, lsp.DidCloseTextDocumentParams(text_document=_ident())) + assert published == [] or published[0].diagnostics == [] + assert published[-1].uri == URI and published[-1].diagnostics == [] + assert URI not in server.generations + + +def test_hover_handler() -> None: + server = _server_with() + params = lsp.HoverParams(text_document=_ident(), position=lsp.Position(line=1, character=5)) + hover = server_module._hover(server, params) + assert hover is not None + assert isinstance(hover.contents, lsp.MarkupContent) + assert "SMA" in hover.contents.value + + +def test_definition_handler() -> None: + server = _server_with() + params = lsp.DefinitionParams( + text_document=_ident(), position=lsp.Position(line=5, character=15) + ) + locations = server_module._definition(server, params) + assert locations and locations[0].uri == URI + assert locations[0].range.start == lsp.Position(line=0, character=18) + + +def test_references_handler() -> None: + server = _server_with() + params = lsp.ReferenceParams( + text_document=_ident(), + position=lsp.Position(line=0, character=19), + context=lsp.ReferenceContext(include_declaration=True), + ) + locations = server_module._references(server, params) + assert len(locations) >= 2 and all(loc.uri == URI for loc in locations) + + +def test_document_symbol_handler() -> None: + server = _server_with() + symbols = server_module._document_symbol( + server, lsp.DocumentSymbolParams(text_document=_ident()) + ) + assert [s.name for s in symbols] == ["Sat", "FM"] + + +def test_completion_handler() -> None: + server = _server_with() + params = lsp.CompletionParams( + text_document=_ident(), position=lsp.Position(line=5, character=0) + ) + result = server_module._completion(server, params) + assert result.is_incomplete is False + assert "Sat" in {item.label for item in result.items} + + +def test_formatting_handler() -> None: + server = _server_with("GMAT Sat.SMA=7000;\n") + params = lsp.DocumentFormattingParams( + text_document=_ident(), options=lsp.FormattingOptions(tab_size=4, insert_spaces=True) + ) + edits = server_module._formatting(server, params) + assert len(edits) == 1 and edits[0].new_text == "Sat.SMA = 7000\n" + + +def test_range_formatting_handler() -> None: + server = _server_with("GMAT Sat.SMA=7000;\n") + params = lsp.DocumentRangeFormattingParams( + text_document=_ident(), + range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(0, 5)), + options=lsp.FormattingOptions(tab_size=4, insert_spaces=True), + ) + edits = server_module._range_formatting(server, params) + assert len(edits) == 1 and edits[0].new_text == "Sat.SMA = 7000\n" + + +def test_safe_returns_default_on_exception() -> None: + def boom() -> str: + raise RuntimeError("boom") + + assert server_module._safe(boom, "fallback") == "fallback" + + +def test_handler_survives_analysis_error(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_: object, **__: object) -> None: + raise RuntimeError("analysis blew up") + + monkeypatch.setattr(analysis_module, "hover_at", boom) + server = _server_with() + params = lsp.HoverParams(text_document=_ident(), position=lsp.Position(line=1, character=5)) + assert server_module._hover(server, params) is None + + +# ---------------------------------------------------------------------------- +# console entry: graceful degradation without the extra + + +def test_entry_point_hints_when_pygls_missing( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(importlib.util, "find_spec", lambda _name: None) + assert lsp_pkg.main([]) == 1 + assert "gmat-script[lsp]" in capsys.readouterr().err + + +def test_entry_point_starts_server_when_available(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server_module, "main", lambda argv=None: 0) + assert lsp_pkg.main([]) == 0 + + +# ---------------------------------------------------------------------------- +# end-to-end stdio smoke test (the definition-of-done integration test) + + +class _StdioClient: + """A minimal LSP JSON-RPC client speaking to the server subprocess over stdio.""" + + def __init__(self) -> None: + self._proc = subprocess.Popen( + [sys.executable, "-m", "gmat_script.lsp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self._messages: queue.Queue[dict[str, Any]] = queue.Queue() + self._id = 0 + self._stdout = self._proc.stdout + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + self._stderr_chunks: list[bytes] = [] + self._draining = threading.Thread(target=self._drain_stderr, daemon=True) + self._draining.start() + + def _drain_stderr(self) -> None: + stderr = self._proc.stderr + assert stderr is not None + for chunk in iter(lambda: stderr.read(4096), b""): + self._stderr_chunks.append(chunk) + + def _read_loop(self) -> None: + assert self._stdout is not None + try: + while True: + header = b"" + while not header.endswith(b"\r\n\r\n"): + byte = self._stdout.read(1) + if not byte: + return + header += byte + length = next( + int(line.split(b":", 1)[1]) + for line in header.split(b"\r\n") + if line.lower().startswith(b"content-length") + ) + self._messages.put(json.loads(self._stdout.read(length))) + except Exception: # the loop ends when the process closes its pipe + return + + def _write(self, payload: dict[str, Any]) -> None: + assert self._proc.stdin is not None + data = json.dumps(payload).encode("utf-8") + self._proc.stdin.write(f"Content-Length: {len(data)}\r\n\r\n".encode() + data) + self._proc.stdin.flush() + + def notify(self, method: str, params: dict[str, Any]) -> None: + self._write({"jsonrpc": "2.0", "method": method, "params": params}) + + def request(self, method: str, params: dict[str, Any], timeout: float = 15.0) -> Any: + self._id += 1 + request_id = self._id + self._write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + return self._await(lambda m: m.get("id") == request_id, timeout)["result"] + + def await_notification(self, method: str, timeout: float = 15.0) -> dict[str, Any]: + params = self._await(lambda m: m.get("method") == method, timeout)["params"] + return cast("dict[str, Any]", params) + + def _await(self, predicate: Any, timeout: float) -> dict[str, Any]: + deadline = timeout + while True: + try: + message = self._messages.get(timeout=deadline) + except queue.Empty: # pragma: no cover - only on a hung/broken server + stderr = b"".join(self._stderr_chunks).decode(errors="replace") + raise AssertionError(f"timed out; server stderr:\n{stderr}") from None + if predicate(message): + return message + + def close(self) -> int: + with contextlib.suppress(OSError): # the pipe may already be closed + self._proc.stdin.close() # type: ignore[union-attr] + return self._proc.wait(timeout=15) + + +@pytest.fixture +def client() -> Iterator[_StdioClient]: + connection = _StdioClient() + try: + yield connection + finally: + if connection._proc.poll() is None: # pragma: no cover - normal path exits via `exit` + connection._proc.kill() + connection._proc.wait(timeout=10) + + +def _open(client: _StdioClient, text: str = SCRIPT) -> None: + client.request("initialize", {"processId": None, "rootUri": None, "capabilities": {}}) + client.notify("initialized", {}) + client.notify( + "textDocument/didOpen", + {"textDocument": {"uri": URI, "languageId": "gmat", "version": 1, "text": text}}, + ) + + +def test_stdio_smoke(client: _StdioClient) -> None: + _open(client) + + # didOpen triggers diagnostics (FM is unused). + diagnostics = client.await_notification("textDocument/publishDiagnostics")["diagnostics"] + assert any(d["code"] == "unused-resource" for d in diagnostics) + + hover = client.request( + "textDocument/hover", + {"textDocument": {"uri": URI}, "position": {"line": 1, "character": 5}}, + ) + assert "SMA" in hover["contents"]["value"] + + definition = client.request( + "textDocument/definition", + {"textDocument": {"uri": URI}, "position": {"line": 5, "character": 15}}, + ) + assert definition[0]["range"]["start"] == {"line": 0, "character": 18} + + references = client.request( + "textDocument/references", + { + "textDocument": {"uri": URI}, + "position": {"line": 0, "character": 19}, + "context": {"includeDeclaration": True}, + }, + ) + assert len(references) >= 2 + + symbols = client.request("textDocument/documentSymbol", {"textDocument": {"uri": URI}}) + assert [s["name"] for s in symbols] == ["Sat", "FM"] + + completion = client.request( + "textDocument/completion", + {"textDocument": {"uri": URI}, "position": {"line": 5, "character": 0}}, + ) + assert "Sat" in {item["label"] for item in completion["items"]} + + formatting = client.request( + "textDocument/formatting", + {"textDocument": {"uri": URI}, "options": {"tabSize": 4, "insertSpaces": True}}, + ) + assert len(formatting) >= 1 + + range_formatting = client.request( + "textDocument/rangeFormatting", + { + "textDocument": {"uri": URI}, + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, + "options": {"tabSize": 4, "insertSpaces": True}, + }, + ) + assert len(range_formatting) >= 1 + + client.request("shutdown", {}) + client.notify("exit", {}) + assert client.close() == 0 + + +def test_stdio_live_diagnostics_update_and_survive_malformed(client: _StdioClient) -> None: + _open( + client, + "Create Spacecraft Sat\nSat.SMA = 7000\n\nBeginMissionSequence\nPropagate Prop(Sat)\n", + ) + first = client.await_notification("textDocument/publishDiagnostics") + assert first["diagnostics"] == [] + + # A malformed edit must not crash the server, and must republish with a syntax error. + client.notify( + "textDocument/didChange", + { + "textDocument": {"uri": URI, "version": 2}, + "contentChanges": [{"text": "Create Spacecraft\nSat.SMA = = =\n"}], + }, + ) + updated = client.await_notification("textDocument/publishDiagnostics") + assert any(d["severity"] == lsp.DiagnosticSeverity.Error for d in updated["diagnostics"]) + + client.request("shutdown", {}) + client.notify("exit", {}) + assert client.close() == 0 From 6e436bf71660369d4cfb3d2e1c763682c925ac77 Mon Sep 17 00:00:00 2001 From: Dimitrije Jankovic Date: Mon, 8 Jun 2026 17:41:27 -0400 Subject: [PATCH 3/3] Document the language server Add docs/lsp.md (install via the lsp extra, feature table, editor setup for Neovim/Emacs/VS Code, and how it reuses the library layers) and a nav entry. Part of #22. --- docs/lsp.md | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 87 insertions(+) create mode 100644 docs/lsp.md diff --git a/docs/lsp.md b/docs/lsp.md new file mode 100644 index 0000000..f8994f2 --- /dev/null +++ b/docs/lsp.md @@ -0,0 +1,86 @@ +# The language server + +gmat-script ships a [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) +server that brings the parser, linter, field catalogue, and tree-sitter queries to any LSP-capable +editor — Neovim, Emacs, VS Code, Helix, and the rest. It is editor-agnostic: the editor speaks LSP +to the server, so a single backend serves every client. + +The server is built on [pygls](https://github.com/openlawlibrary/pygls) and, like the rest of the +library, needs no GMAT, C, or Node toolchain at runtime. + +## Installing + +pygls is an optional dependency, so the server lives behind the `lsp` extra — a plain +`pip install gmat-script` stays lean and pulls in only the parser: + +```console +$ pip install "gmat-script[lsp]" +``` + +This puts a `gmat-script-lsp` console script on your `PATH`. The server speaks LSP over stdin/stdout; +you do not run it by hand — your editor launches it and talks to it. Running it without the extra +installed prints a one-line hint to install `gmat-script[lsp]`. + +## Features + +The server responds to the standard requests, each backed by an existing library layer: + +| Capability | What you get | Backed by | +|------------|--------------|-----------| +| `publishDiagnostics` | Live errors and warnings as you type, debounced on change | the linter (and the parser's syntax errors) | +| `hover` | Field documentation — type, default, allowed values, units, reference target | the field catalogue | +| `completion` | Resource names, the fields valid for the resource under the cursor, and a field's enum values | the catalogue + the parsed model | +| `definition` | Jump to a resource's `Create` declaration | the `locals` query | +| `references` | Every use of a resource across the file | the `locals` query | +| `documentSymbol` | The outline — every `Create`'d resource and GmatFunction header | the `tags` query | +| `formatting` / `rangeFormatting` | Canonical re-emission on save or on demand | the formatter | + +Diagnostics update on every edit and a malformed, half-typed buffer never crashes the server: the +error-recovering parse still yields a usable tree, so completion and hover keep working while you +type. Completion and hover quality follow the catalogue — a field GMAT does not reflect simply has +no hover, and the feature degrades quietly rather than guessing. + +## Editor setup + +Point your editor's LSP client at the `gmat-script-lsp` command for GMAT `.script` and `.gmf` files. + +### Neovim (`nvim-lspconfig` style) + +```lua +vim.api.nvim_create_autocmd("FileType", { + pattern = { "gmat" }, + callback = function(args) + vim.lsp.start({ + name = "gmat-script-lsp", + cmd = { "gmat-script-lsp" }, + root_dir = vim.fs.dirname(args.file), + }) + end, +}) +``` + +(Map the `gmat` filetype to `*.script` / `*.gmf` with an `autocmd`/`filetype` rule of your choice.) + +### Emacs (`eglot`) + +```elisp +(add-to-list 'eglot-server-programs '(gmat-mode . ("gmat-script-lsp"))) +``` + +### VS Code + +The VS Code extension bundles a client that launches this server for you — install it from the +Marketplace rather than configuring the command by hand. + +## How it fits together + +The server is a thin shell over the same functions the [CLI](cli.md) and Python API use: + +- diagnostics come from [`lint`](lint.md) and the parser's [error reporting](errors.md); +- hover and completion read the bundled field catalogue; +- definition, references, and the symbol outline run the grammar's tree-sitter queries; +- formatting calls the [canonical formatter](formatting.md). + +Positions cross the boundary cleanly: the library reports 1-indexed byte positions, and the server +converts them to the protocol's 0-indexed UTF-16 positions (so multi-byte characters land in the +right column). diff --git a/mkdocs.yml b/mkdocs.yml index b9ad0ed..51add51 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,7 @@ nav: - Formatter: formatting.md - Linter: lint.md - CLI: cli.md + - Language server: lsp.md - Error reporting: errors.md - API reference: api.md - Design: