diff --git a/src/gmat_script/lint/known.py b/src/gmat_script/lint/known.py index 99de00e..f6a4cbe 100644 --- a/src/gmat_script/lint/known.py +++ b/src/gmat_script/lint/known.py @@ -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 diff --git a/src/gmat_script/lint/references.py b/src/gmat_script/lint/references.py index 5e85e65..8ad6091 100644 --- a/src/gmat_script/lint/references.py +++ b/src/gmat_script/lint/references.py @@ -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 @@ -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": @@ -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 diff --git a/src/gmat_script/lint/rules.py b/src/gmat_script/lint/rules.py index 0436de2..a6ffb13 100644 --- a/src/gmat_script/lint/rules.py +++ b/src/gmat_script/lint/rules.py @@ -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 diff --git a/src/gmat_script/lsp/analysis.py b/src/gmat_script/lsp/analysis.py index 9613b5e..2a5349c 100644 --- a/src/gmat_script/lsp/analysis.py +++ b/src/gmat_script/lsp/analysis.py @@ -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 [] diff --git a/src/gmat_script/lsp/queries.py b/src/gmat_script/lsp/queries.py index 444f800..096fb85 100644 --- a/src/gmat_script/lsp/queries.py +++ b/src/gmat_script/lsp/queries.py @@ -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``) diff --git a/src/gmat_script/lsp/server.py b/src/gmat_script/lsp/server.py index a7fefe4..3df264d 100644 --- a/src/gmat_script/lsp/server.py +++ b/src/gmat_script/lsp/server.py @@ -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 @@ -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, ) ) diff --git a/tests/test_lint.py b/tests/test_lint.py index 29a76f2..fd950e8 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -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"), diff --git a/tests/test_lsp_analysis.py b/tests/test_lsp_analysis.py index 0ef72cd..978ef27 100644 --- a/tests/test_lsp_analysis.py +++ b/tests/test_lsp_analysis.py @@ -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 diff --git a/tests/test_lsp_server.py b/tests/test_lsp_server.py index e0d1bf7..b79088b 100644 --- a/tests/test_lsp_server.py +++ b/tests/test_lsp_server.py @@ -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