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:
2026-05-29 13:59:32 +02:00
parent 2d44f52e96
commit e47d422655
6 changed files with 330 additions and 0 deletions

View File

@@ -30,6 +30,12 @@ dependencies = [
] ]
dynamic = ["version"] 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] [project.scripts]
testium = "testium:main" testium = "testium:main"

View File

@@ -11,6 +11,30 @@ sys.path.append(os.path.abspath(ourpath.parent))
import interpreter.utils.constants as cst import interpreter.utils.constants as cst
def main(): 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 # This line sets the method for the "Process" function. It is required for Linux
# support of the test dialogs. # support of the test dialogs.
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')

View 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]``).
"""

View 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
View 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
View 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()