Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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),
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
86 changes: 86 additions & 0 deletions docs/lsp.md
Original file line number Diff line number Diff line change
@@ -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).
27 changes: 26 additions & 1 deletion hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from __future__ import annotations

import shutil
import sysconfig
from pathlib import Path
from typing import Any
Expand All @@ -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"


Expand Down Expand Up @@ -70,16 +73,38 @@ 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)."""

PLUGIN_NAME = "custom"

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"))
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 16 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/gmat_script/lsp/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
10 changes: 10 additions & 0 deletions src/gmat_script/lsp/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading