diff --git a/src/pyproject.toml b/src/pyproject.toml index 9c0c207..d64ea6d 100644 --- a/src/pyproject.toml +++ b/src/pyproject.toml @@ -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" diff --git a/src/testium/__init__.py b/src/testium/__init__.py index 99a253c..30a7c84 100755 --- a/src/testium/__init__.py +++ b/src/testium/__init__.py @@ -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') diff --git a/src/testium/lsp/__init__.py b/src/testium/lsp/__init__.py new file mode 100644 index 0000000..2595684 --- /dev/null +++ b/src/testium/lsp/__init__.py @@ -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]``). +""" diff --git a/src/testium/lsp/__main__.py b/src/testium/lsp/__main__.py new file mode 100644 index 0000000..c2f14c2 --- /dev/null +++ b/src/testium/lsp/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for ``python -m testium.lsp`` (alternative to ``testium lsp``).""" + +from lsp.server import serve + +if __name__ == "__main__": + serve() diff --git a/src/testium/lsp/schema.py b/src/testium/lsp/schema.py new file mode 100644 index 0000000..5b7f7e8 --- /dev/null +++ b/src/testium/lsp/schema.py @@ -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) diff --git a/src/testium/lsp/server.py b/src/testium/lsp/server.py new file mode 100644 index 0000000..0ed3c2f --- /dev/null +++ b/src/testium/lsp/server.py @@ -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 (``- ``). 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 ``:`` 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()