lsp: schema export + minimal language server (item-type completion)
Adds two new testium CLI subcommands:
testium schema dump the JSON-shaped schema of every test item type
(PARAMS merged with the common ones, console / plot /
json_rpc actions nested under their parent's "actions"
block). Zero runtime dependencies — usable by any editor
that speaks the YAML JSON Schema extension to get static
completion straight away.
testium lsp start a pygls LSP server over stdio. First feature:
completion of test item type names when the user starts
a new step (`- |`). The completion item carries the
item's display name and a hover doc listing its required
and optional non-common parameters.
pygls is declared as an optional extra ([project.optional-dependencies]
lsp = ["pygls>=1.3"]) so the core install isn't enlarged for users who
don't need the server. The import compatibility shim picks
pygls.lsp.server.LanguageServer (pygls 2.x) first and falls back to
pygls.server.LanguageServer (pygls 1.x).
The subcommands intercept argv[1] before argparse runs so they don't
share the GUI/batch flag surface.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,12 @@ dependencies = [
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# `pip install testium[lsp]` adds the language-server dependencies. The
|
||||
# stdio-only LSP server (`testium lsp`) reuses the schema export from the
|
||||
# core install; pygls is the only marginal cost.
|
||||
lsp = ["pygls>=1.3"]
|
||||
|
||||
[project.scripts]
|
||||
testium = "testium:main"
|
||||
|
||||
|
||||
@@ -11,6 +11,30 @@ sys.path.append(os.path.abspath(ourpath.parent))
|
||||
import interpreter.utils.constants as cst
|
||||
|
||||
def main():
|
||||
# Subcommand dispatch (must run *before* argparse so neither 'schema' nor
|
||||
# 'lsp' has to share the GUI/batch flag surface). The subcommands also
|
||||
# skip the multiprocessing 'spawn' setup which is only meaningful for the
|
||||
# main runtime — schema is a pure stdout dump and lsp speaks JSON-RPC
|
||||
# over stdio without ever forking a test process.
|
||||
if len(sys.argv) >= 2 and sys.argv[1] in ("schema", "lsp"):
|
||||
sub = sys.argv[1]
|
||||
if sub == "schema":
|
||||
from lsp.schema import dump_all_schemas_json
|
||||
print(dump_all_schemas_json())
|
||||
return
|
||||
# lsp
|
||||
try:
|
||||
from lsp.server import serve
|
||||
except ImportError as e:
|
||||
print(
|
||||
f"testium lsp: language server dependencies missing ({e.name}). "
|
||||
"Install with: pip install 'testium[lsp]'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
serve()
|
||||
return
|
||||
|
||||
# This line sets the method for the "Process" function. It is required for Linux
|
||||
# support of the test dialogs.
|
||||
multiprocessing.set_start_method('spawn')
|
||||
|
||||
16
src/testium/lsp/__init__.py
Normal file
16
src/testium/lsp/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""testium language tooling.
|
||||
|
||||
Hosts the JSON-Schema-style schema export of every test item type, and a
|
||||
``pygls`` language server that consumes the same schema to provide
|
||||
completion / hover / diagnostics for ``.tum`` files in any LSP-capable
|
||||
editor (VSCode, neovim, Helix, Emacs, …).
|
||||
|
||||
Entry points (both surfaced through the ``testium`` CLI):
|
||||
|
||||
- ``testium schema`` — dump the schema of every item type as JSON on stdout.
|
||||
Zero runtime dependencies; can be used by editors that already speak the
|
||||
YAML JSON Schema extension to get static completion immediately.
|
||||
|
||||
- ``testium lsp`` — start the language server over stdio. Requires the
|
||||
``pygls`` optional dependency (``pip install testium[lsp]``).
|
||||
"""
|
||||
6
src/testium/lsp/__main__.py
Normal file
6
src/testium/lsp/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Entry point for ``python -m testium.lsp`` (alternative to ``testium lsp``)."""
|
||||
|
||||
from lsp.server import serve
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
146
src/testium/lsp/schema.py
Normal file
146
src/testium/lsp/schema.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Schema export of the test item registry.
|
||||
|
||||
Walks every ``TestItemType`` entry (``interpreter/utils/constants.py``),
|
||||
combines its declared ``PARAMS`` with the common ones, and returns a
|
||||
serialisable structure keyed by ``item_cmd`` — the YAML key the user
|
||||
writes (e.g. ``sleep``, ``py_func``, ``dialog_message``).
|
||||
|
||||
Items intentionally without ``PARAMS`` (the unstructured-body classes
|
||||
like console ``write``/``writeln`` or plot ``add``/``export``) are
|
||||
emitted as ``"params_declared": false`` so consumers know to suggest
|
||||
nothing for them rather than reporting a closed empty set.
|
||||
|
||||
Action items (children of ``parallel``, ``console``, ``json_rpc``,
|
||||
``plot``) are registered separately under each parent's ``actions``
|
||||
entry — they're not top-level YAML keys, they live nested inside a
|
||||
parent's ``steps:``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from interpreter.utils.constants import TestItemType
|
||||
from interpreter.utils.test_init import _constants_init
|
||||
|
||||
|
||||
# Action class -> parent cmd (the action's parent in the YAML). Action classes
|
||||
# aren't first-class TestItemType entries (TYPE_*_ACTION is one generic bucket),
|
||||
# so we resolve their YAML key by looking at how each parent registers them.
|
||||
def _collect_action_classes(parent_class):
|
||||
"""Return {action_yaml_key: action_class} for a TestItemActions parent.
|
||||
|
||||
Each parent's ``__init__`` calls ``self.register_actions(name=class, ...)``
|
||||
*during* construction, so we can't read the registry without instantiating
|
||||
one. We work around it by parsing the source for the registration call —
|
||||
cheap, no side effects, and the schema export is a CLI command anyway.
|
||||
"""
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
try:
|
||||
src = inspect.getsource(parent_class)
|
||||
except (OSError, TypeError):
|
||||
return {}
|
||||
|
||||
actions = {}
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if not (isinstance(fn, ast.Attribute) and fn.attr == "register_actions"):
|
||||
continue
|
||||
for kw in node.keywords:
|
||||
if kw.arg is None or not isinstance(kw.value, ast.Name):
|
||||
continue
|
||||
# ast.Name gives us only the bare identifier; resolve it through
|
||||
# the parent class's defining module.
|
||||
mod = __import__(parent_class.__module__, fromlist=[kw.value.id])
|
||||
cls = getattr(mod, kw.value.id, None)
|
||||
if cls is not None:
|
||||
actions[kw.arg] = cls
|
||||
return actions
|
||||
|
||||
|
||||
def _params_to_schema(item_class, common_params):
|
||||
"""Return the params-portion of an item's schema entry.
|
||||
|
||||
Common params are flagged so consumers can render them differently
|
||||
(an editor might show "common" parameters in a separate group).
|
||||
"""
|
||||
own = getattr(item_class, "PARAMS", None)
|
||||
if own is None:
|
||||
return {"params_declared": False}
|
||||
common_names = set(common_params.names())
|
||||
params = []
|
||||
for p in common_params:
|
||||
d = p.to_schema()
|
||||
d["common"] = True
|
||||
params.append(d)
|
||||
for p in own:
|
||||
if p.name in common_names:
|
||||
# Subclass overrode a common param (e.g. tightened doc).
|
||||
for d in params:
|
||||
if d["name"] == p.name:
|
||||
d.update(p.to_schema())
|
||||
break
|
||||
continue
|
||||
d = p.to_schema()
|
||||
d["common"] = False
|
||||
params.append(d)
|
||||
return {"params_declared": True, "params": params}
|
||||
|
||||
|
||||
def dump_all_schemas():
|
||||
"""Return the full schema as a Python dict ready for json.dumps.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"items": {
|
||||
"sleep": {
|
||||
"display_name": "Sleep",
|
||||
"params_declared": true,
|
||||
"params": [{name, kind, required, default?, doc, common}, ...],
|
||||
},
|
||||
"console": {
|
||||
...,
|
||||
"actions": {"open": {...}, "close": {...}, ...},
|
||||
},
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
_constants_init()
|
||||
# Imported lazily — pulls test_item.py which references constants.
|
||||
from interpreter.test_items.test_item import COMMON_PARAMS
|
||||
|
||||
out = {"items": {}}
|
||||
for tp in TestItemType:
|
||||
cls = getattr(tp, "item_class", None)
|
||||
if cls is None:
|
||||
continue
|
||||
# Action types (CONSOLE_ACTION, GRAPH_ACTION, JSON_RPC_ACTION) have no
|
||||
# standalone YAML representation — skip them here, they show up under
|
||||
# their parent's "actions" key.
|
||||
cmd = tp.item_cmd
|
||||
if cmd.endswith("_action"):
|
||||
continue
|
||||
entry = {"display_name": tp.item_name}
|
||||
entry.update(_params_to_schema(cls, COMMON_PARAMS))
|
||||
|
||||
actions = _collect_action_classes(cls)
|
||||
if actions:
|
||||
entry["actions"] = {
|
||||
name: _params_to_schema(acls, COMMON_PARAMS)
|
||||
for name, acls in actions.items()
|
||||
}
|
||||
for name in entry["actions"]:
|
||||
entry["actions"][name]["display_name"] = name
|
||||
|
||||
out["items"][cmd] = entry
|
||||
return out
|
||||
|
||||
|
||||
def dump_all_schemas_json(indent=2):
|
||||
"""Same as ``dump_all_schemas`` but serialised to a JSON string."""
|
||||
return json.dumps(dump_all_schemas(), indent=indent, sort_keys=False,
|
||||
default=str)
|
||||
132
src/testium/lsp/server.py
Normal file
132
src/testium/lsp/server.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""LSP server for ``.tum`` files (MVP).
|
||||
|
||||
This first iteration provides a single feature — completion of test item
|
||||
type names at the start of a YAML step (``- <cursor>``). Hover, outline,
|
||||
and diagnostics will be added in subsequent commits; they all share the
|
||||
schema obtained from :mod:`testium.lsp.schema`.
|
||||
|
||||
The server speaks LSP over stdio. Start it with::
|
||||
|
||||
testium lsp
|
||||
|
||||
Editors invoke it through their LSP client; the connection layer
|
||||
(``vscode-languageclient``, ``nvim-lspconfig``, ``lsp-mode``, …) takes
|
||||
care of the JSON-RPC framing.
|
||||
|
||||
Architecture notes
|
||||
------------------
|
||||
|
||||
We build the schema once at server start. It is **not** reloaded when
|
||||
the user upgrades testium because the server is meant to be restarted
|
||||
in that case (editors typically restart the language server on a
|
||||
package upgrade). The schema is a few KB of JSON-equivalent data so
|
||||
keeping it in memory is trivial.
|
||||
|
||||
The completion handler is intentionally heuristic: we look at the
|
||||
characters preceding the cursor on the current line. A line that
|
||||
matches ``\\s*-\\s*$`` (optionally followed by an identifier prefix)
|
||||
means the user is starting a new step — we offer the item types.
|
||||
Anything else returns no completions for now; richer YAML-context
|
||||
analysis comes with the diagnostic / hover passes.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
try:
|
||||
# pygls 2.x moved LanguageServer under pygls.lsp.server. We pin >=1.3 in
|
||||
# the optional dependency to stay open to either family, but the import
|
||||
# path differs — try the new one first, then the legacy one.
|
||||
try:
|
||||
from pygls.lsp.server import LanguageServer
|
||||
except ImportError:
|
||||
from pygls.server import LanguageServer # pygls < 2
|
||||
from lsprotocol.types import (
|
||||
TEXT_DOCUMENT_COMPLETION,
|
||||
CompletionItem,
|
||||
CompletionItemKind,
|
||||
CompletionList,
|
||||
CompletionOptions,
|
||||
CompletionParams,
|
||||
InsertTextFormat,
|
||||
)
|
||||
except ImportError as exc:
|
||||
# Surfaced by the CLI dispatcher with a friendly install hint.
|
||||
raise
|
||||
|
||||
|
||||
from lsp.schema import dump_all_schemas
|
||||
|
||||
|
||||
_LINE_START_STEP = re.compile(r"^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)?\s*:?\s*$")
|
||||
|
||||
|
||||
def _build_item_completions(schema):
|
||||
"""Return a list of CompletionItem covering every top-level item type.
|
||||
|
||||
Each completion inserts ``<name>:`` with the cursor positioned after
|
||||
the colon so the user can immediately start typing parameters. The
|
||||
item's display name and the first non-common required param (if any)
|
||||
show up in the hover-style detail/documentation.
|
||||
"""
|
||||
items = []
|
||||
for cmd, entry in schema["items"].items():
|
||||
if cmd == "default":
|
||||
# Root sentinel; never appears as a YAML key.
|
||||
continue
|
||||
detail = entry.get("display_name", cmd)
|
||||
doc_lines = [f"**{detail}**", ""]
|
||||
if entry.get("params_declared"):
|
||||
non_common = [p for p in entry["params"] if not p["common"]]
|
||||
required = [p for p in non_common if p["required"]]
|
||||
optional = [p for p in non_common if not p["required"]]
|
||||
if required:
|
||||
doc_lines.append("Required parameters:")
|
||||
for p in required:
|
||||
doc_lines.append(f"- `{p['name']}` — {p['doc']}")
|
||||
doc_lines.append("")
|
||||
if optional:
|
||||
doc_lines.append("Optional parameters:")
|
||||
for p in optional:
|
||||
doc_lines.append(f"- `{p['name']}` — {p['doc']}")
|
||||
items.append(
|
||||
CompletionItem(
|
||||
label=cmd,
|
||||
kind=CompletionItemKind.Class,
|
||||
detail=detail,
|
||||
documentation="\n".join(doc_lines),
|
||||
insert_text=f"{cmd}:",
|
||||
insert_text_format=InsertTextFormat.PlainText,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda it: it.label)
|
||||
return items
|
||||
|
||||
|
||||
def _make_server():
|
||||
server = LanguageServer("testium-lsp", "0.1.0")
|
||||
schema = dump_all_schemas()
|
||||
item_completions = _build_item_completions(schema)
|
||||
|
||||
@server.feature(
|
||||
TEXT_DOCUMENT_COMPLETION,
|
||||
CompletionOptions(trigger_characters=["-", " "]),
|
||||
)
|
||||
def completion(params: CompletionParams):
|
||||
doc = server.workspace.get_text_document(params.text_document.uri)
|
||||
line_idx = params.position.line
|
||||
if line_idx >= len(doc.lines):
|
||||
return CompletionList(is_incomplete=False, items=[])
|
||||
line = doc.lines[line_idx]
|
||||
# Only look at what's left of the cursor.
|
||||
prefix = line[: params.position.character]
|
||||
if not _LINE_START_STEP.match(prefix):
|
||||
return CompletionList(is_incomplete=False, items=[])
|
||||
return CompletionList(is_incomplete=False, items=item_completions)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def serve():
|
||||
"""Start the LSP server on stdio. Blocks until the client disconnects."""
|
||||
server = _make_server()
|
||||
server.start_io()
|
||||
Reference in New Issue
Block a user