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
2 changes: 1 addition & 1 deletion src/gmat_script/lint/known.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
__all__ = ["GMAT_BUILTINS", "KNOWN_PLUGIN_TYPES"]

# Resource types from optional GMAT plugins, absent from the default-load catalogue
# (#19 / D15 deferred corpus-completeness to the linter). All appear in the R2026a stock corpus.
# (D15 deferred corpus-completeness to the linter). All appear in the R2026a stock corpus.
KNOWN_PLUGIN_TYPES: frozenset[str] = frozenset(
{
# OpenFrames visualisation plugin
Expand Down
8 changes: 4 additions & 4 deletions src/gmat_script/lint/references.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* :func:`object_reference_uses` — *high-confidence*: only the values of catalogue-typed
object-reference fields, backing ``undeclared-reference`` and ``ref-target-mismatch``. Keeping the
scope to fields the catalogue marks as object references is what holds those rules to zero false
positives on real scripts (the design decision for #20 — see the plan).
positives on real scripts.
"""

from __future__ import annotations
Expand Down Expand Up @@ -65,7 +65,7 @@ def walk(node: Node, in_config: bool) -> None:
if kind == "assignment_command" and in_config:
# A configuration assignment's left side is the resource configuring itself — not a use.
right = node.child_by_field_name("right")
if right is not None:
if right is not None: # pragma: no branch - a clean-parse assignment carries a right
walk(right, in_config)
return
if kind == "member_expression":
Expand All @@ -75,9 +75,9 @@ def walk(node: Node, in_config: bool) -> None:
if kind == "call_expression":
function = node.child_by_field_name("function")
arguments = node.child_by_field_name("arguments")
if function is not None:
if function is not None: # pragma: no branch - a clean-parse call has a function
walk(function, in_config)
if arguments is not None:
if arguments is not None: # pragma: no branch - a clean-parse call has arguments
for argument in arguments.named_children:
walk(argument, in_config)
return
Expand Down
2 changes: 1 addition & 1 deletion src/gmat_script/lint/rules.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The v0.3 lint rule set — nine structural checks over the typed AST and the field catalogue.
"""The lint rule set — eight structural checks over the typed AST and the field catalogue.

Each rule is a small generator of :class:`~gmat_script.lint.diagnostics.Diagnostic`; the
:class:`Rule` wrappers in :data:`RULES` make them individually toggleable. The checks are purely
Expand Down
7 changes: 4 additions & 3 deletions src/gmat_script/lsp/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,12 +373,13 @@ def _type_matches(analysis: _Analysis, resource_type: str, target: str, wanted:
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.
The formatter is whole-document and refuses a script with syntax errors (D14); a buffer it
cannot format — a syntax error, or an expression nested deep enough to exhaust the recursion
limit — or one already canonical yields no edit, so this is also the range-formatting result.
"""
try:
formatted = format_source(source)
except (ValueError, TypeError):
except (ValueError, TypeError, RecursionError):
return []
if formatted == source:
return []
Expand Down
2 changes: 1 addition & 1 deletion src/gmat_script/lsp/queries.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""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``
Two of the vendored ``.scm`` queries (decision D1) 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``)
Expand Down
15 changes: 13 additions & 2 deletions src/gmat_script/lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
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.

Every handler — the request handlers *and* the diagnostics publish path — runs its analysis through
:func:`_safe`, so neither a broken parse (the linter degrades to syntax errors) nor a pathological
buffer (one nested deep enough to exhaust the recursion limit) can crash the connection (D7).
"""

from __future__ import annotations
Expand Down Expand Up @@ -57,13 +61,20 @@ def __init__(self) -> None:
self.generations: dict[str, int] = {}

def refresh_diagnostics(self, uri: str) -> None:
"""Recompute and publish diagnostics for the document at *uri*."""
"""Recompute and publish diagnostics for the document at *uri*.

The analysis runs through :func:`_safe`: a pathological buffer (e.g. an expression nested
deep enough to exhaust the recursion limit) publishes no diagnostics rather than raising out
of the ``didOpen`` / ``didChange`` handler and crashing the connection.
"""
document = self.workspace.get_text_document(uri)
empty: list[types.Diagnostic] = []
diagnostics = _safe(lambda: analysis.diagnostics_for(document.source), empty)
self.text_document_publish_diagnostics(
types.PublishDiagnosticsParams(
uri=uri,
version=document.version,
diagnostics=analysis.diagnostics_for(document.source),
diagnostics=diagnostics,
)
)

Expand Down
8 changes: 8 additions & 0 deletions tests/test_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,14 @@ def test_context_indexes_declarations_fields_and_script_blocks() -> None:
assert any("y = 2" in text for text in context.script_block_texts)


def test_value_references_are_computed_once_and_cached() -> None:
source = "Create Spacecraft Sat\nSat.SMA = 7000\nBeginMissionSequence\nPropagate Sat\n"
context = LintContext(Script.parse(source), parse(source), load_catalog())
first = context.value_references()
# A second call returns the cached mapping, not a freshly recomputed one.
assert context.value_references() is first


def test_config_only_script_has_no_mission_sequence() -> None:
context = LintContext(
Script.parse("Create Spacecraft Sat\n"),
Expand Down
7 changes: 7 additions & 0 deletions tests/test_lsp_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ def test_format_edits_syntax_error_is_empty() -> None:
assert analysis.format_edits("Create = = =\n") == []


def test_format_edits_deeply_nested_is_empty() -> None:
# A pathologically deep expression parses cleanly but recurses the formatter past the recursion
# limit; format_edits must absorb the RecursionError and yield no edit, not propagate it.
source = "x = " + "(" * 1000 + "1" + ")" * 1000 + "\n"
assert analysis.format_edits(source) == []


# ----------------------------------------------------------------------------
# robustness: malformed input never raises

Expand Down
24 changes: 24 additions & 0 deletions tests/test_lsp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,30 @@ def boom(*_: object, **__: object) -> None:
assert server_module._hover(server, params) is None


def test_refresh_diagnostics_survives_analysis_error(monkeypatch: pytest.MonkeyPatch) -> None:
# The diagnostics publish path (didOpen / didChange) is wrapped like the request handlers, so a
# failing analysis publishes an empty list instead of crashing the connection.
def boom(*_: object, **__: object) -> None:
raise RuntimeError("diagnostics blew up")

monkeypatch.setattr(analysis_module, "diagnostics_for", boom)
server = _server_with()
published = _capture_publishes(server, monkeypatch)
server.refresh_diagnostics(URI)
assert len(published) == 1 and published[0].diagnostics == []


def test_did_open_survives_pathologically_deep_document(monkeypatch: pytest.MonkeyPatch) -> None:
# A buffer nested deep enough to exhaust the recursion limit parses cleanly but recurses the
# linter; didOpen must still publish (an empty list) rather than raise out of the handler.
deep = "x = " + "(" * 1500 + "1" + ")" * 1500 + "\n"
server = _server_with(deep)
published = _capture_publishes(server, monkeypatch)
item = lsp.TextDocumentItem(uri=URI, language_id="gmat", version=1, text=deep)
server_module._did_open(server, lsp.DidOpenTextDocumentParams(text_document=item))
assert len(published) == 1 and published[0].diagnostics == []


# ----------------------------------------------------------------------------
# console entry: graceful degradation without the extra

Expand Down
Loading