lsp: hover + document symbols on test item types

Adds two new LSP features that share the schema with completion:

- textDocument/hover — when the cursor is on the item-type word of a
  step line (`- sleep:`), the server renders the same Markdown doc
  used by the completion item, listing required/optional params.
  Other words (string values, YAML keys other than item types) don't
  trigger the popup.

- textDocument/documentSymbol — the outline view now contains one
  entry per step, nested by leading-dash indentation so container
  items (group, parallel, cycle, console, plot, json_rpc) display
  their children as a subtree. Each symbol's `detail` shows the
  YAML `name:` field if present nearby — found via a small forward
  scan, no YAML parsing yet.

Action item types (console open/close/…, plot open/close/…, json_rpc
query/receive/…) are accepted by hover and outline too, so the
outline doesn't stop at the parent.

Markdown rendering is now shared by completion and hover via
`_render_item_markdown(cmd, entry)`; both surfaces show the same
description regardless of how the user reached it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 16:01:39 +02:00
parent e47d422655
commit a01268cd0e

View File

@@ -1,9 +1,17 @@
"""LSP server for ``.tum`` files (MVP). """LSP server for ``.tum`` files.
This first iteration provides a single feature — completion of test item Features available so far:
type names at the start of a YAML step (``- <cursor>``). Hover, outline,
and diagnostics will be added in subsequent commits; they all share the - **Completion** — when the user starts a new YAML step (``- <cursor>``),
schema obtained from :mod:`testium.lsp.schema`. the server proposes the full list of known item types. The completion
item carries a short hover-style description listing required and
optional parameters.
- **Hover** — over a known item-type word (``sleep``, ``py_func``, …)
the server renders the same description in a popup.
- **Document symbols (outline)** — every ``- <type>:`` line becomes an
entry in the editor's outline view. Nesting follows YAML indentation,
so containers (``group``, ``loop``, ``parallel``, ``console`` …)
display their children as a subtree.
The server speaks LSP over stdio. Start it with:: The server speaks LSP over stdio. Start it with::
@@ -16,18 +24,16 @@ care of the JSON-RPC framing.
Architecture notes Architecture notes
------------------ ------------------
We build the schema once at server start. It is **not** reloaded when The schema is built once at server start (``dump_all_schemas()``) and
the user upgrades testium because the server is meant to be restarted kept in memory; an editor restart picks up upstream changes. The schema
in that case (editors typically restart the language server on a is the **only** source of truth — when testium adds a new item type or
package upgrade). The schema is a few KB of JSON-equivalent data so parameter, the LSP automatically exposes it without any change here.
keeping it in memory is trivial.
The completion handler is intentionally heuristic: we look at the The current handlers stay deliberately heuristic on the parser side:
characters preceding the cursor on the current line. A line that completion uses a line-prefix regex, outline a per-line ``- <known>:``
matches ``\\s*-\\s*$`` (optionally followed by an identifier prefix) sweep with indentation tracking. A proper YAML+Jinja parsing pass is
means the user is starting a new step — we offer the item types. still pending and is the prerequisite for *parameter*-level completion
Anything else returns no completions for now; richer YAML-context and diagnostics.
analysis comes with the diagnostic / hover passes.
""" """
import re import re
@@ -42,12 +48,23 @@ try:
from pygls.server import LanguageServer # pygls < 2 from pygls.server import LanguageServer # pygls < 2
from lsprotocol.types import ( from lsprotocol.types import (
TEXT_DOCUMENT_COMPLETION, TEXT_DOCUMENT_COMPLETION,
TEXT_DOCUMENT_DOCUMENT_SYMBOL,
TEXT_DOCUMENT_HOVER,
CompletionItem, CompletionItem,
CompletionItemKind, CompletionItemKind,
CompletionList, CompletionList,
CompletionOptions, CompletionOptions,
CompletionParams, CompletionParams,
DocumentSymbol,
DocumentSymbolParams,
Hover,
HoverParams,
InsertTextFormat, InsertTextFormat,
MarkupContent,
MarkupKind,
Position,
Range,
SymbolKind,
) )
except ImportError as exc: except ImportError as exc:
# Surfaced by the CLI dispatcher with a friendly install hint. # Surfaced by the CLI dispatcher with a friendly install hint.
@@ -59,41 +76,67 @@ from lsp.schema import dump_all_schemas
_LINE_START_STEP = re.compile(r"^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)?\s*:?\s*$") _LINE_START_STEP = re.compile(r"^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)?\s*:?\s*$")
# Matches "- <identifier>:" for outline / hover purposes. Captures the start
# column of the identifier and the identifier itself. Trailing tokens after
# the colon (inline-form params, comments) are tolerated.
_STEP_LINE = re.compile(r"^(?P<lead>\s*-\s*)(?P<ident>[A-Za-z_][A-Za-z0-9_]*)\s*:")
# Matches a ``name: <value>`` line under an item — used by the outline pass
# to surface the user's display name next to the item type.
_NAME_FIELD = re.compile(r"^\s*name\s*:\s*(?P<value>.+?)\s*$")
# Word boundary used by hover to extract the identifier under the cursor.
_IDENT_AT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
def _render_item_markdown(cmd, entry):
"""Render an item-type's schema entry as a Markdown hover string.
Reused by both the completion-item documentation and the hover
handler so the editor presents identical information regardless of
how the user reached it.
"""
detail = entry.get("display_name", cmd)
lines = [f"**{cmd}** — {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:
lines.append("Required parameters:")
for p in required:
lines.append(f"- `{p['name']}` — {p['doc']}")
lines.append("")
if optional:
lines.append("Optional parameters:")
for p in optional:
lines.append(f"- `{p['name']}` — {p['doc']}")
else:
lines.append("(Parameter list is not described — this item's body is the "
"raw user value.)")
return "\n".join(lines)
def _build_item_completions(schema): def _build_item_completions(schema):
"""Return a list of CompletionItem covering every top-level item type. """Return a list of CompletionItem covering every top-level item type.
Each completion inserts ``<name>:`` with the cursor positioned after Each completion inserts ``<name>:`` with the cursor positioned after
the colon so the user can immediately start typing parameters. The the colon so the user can immediately start typing parameters.
item's display name and the first non-common required param (if any)
show up in the hover-style detail/documentation.
""" """
items = [] items = []
for cmd, entry in schema["items"].items(): for cmd, entry in schema["items"].items():
if cmd == "default": if cmd == "default":
# Root sentinel; never appears as a YAML key. # Root sentinel; never appears as a YAML key.
continue 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( items.append(
CompletionItem( CompletionItem(
label=cmd, label=cmd,
kind=CompletionItemKind.Class, kind=CompletionItemKind.Class,
detail=detail, detail=entry.get("display_name", cmd),
documentation="\n".join(doc_lines), documentation=MarkupContent(
kind=MarkupKind.Markdown,
value=_render_item_markdown(cmd, entry),
),
insert_text=f"{cmd}:", insert_text=f"{cmd}:",
insert_text_format=InsertTextFormat.PlainText, insert_text_format=InsertTextFormat.PlainText,
) )
@@ -102,10 +145,104 @@ def _build_item_completions(schema):
return items return items
def _word_at(line, character):
"""Return ``(start, end, text)`` of the identifier under ``character``.
Returns ``None`` when the cursor isn't on a word. Used by hover.
"""
for m in _IDENT_AT.finditer(line):
if m.start() <= character <= m.end():
return m.start(), m.end(), m.group(0)
return None
def _build_document_symbols(lines, item_cmds):
"""Walk ``lines`` and produce a nested ``DocumentSymbol`` tree.
Heuristics (no YAML parsing yet):
- Each ``- <known_cmd>:`` line becomes a symbol.
- Nesting follows the indentation of the leading ``-``: a deeper-
indented step is treated as a child of the most recent shallower
step.
- The symbol's ``detail`` is the ``name: <value>`` field if found
within a small window after the step header (no YAML parsing —
we just look at indented lines that aren't another ``- …`` step).
The result is suitable for the LSP outline panel even when the
surrounding YAML is mid-edit and structurally invalid.
"""
root_children = []
# Each stack entry: (indent_col, children_list_to_append_to,
# pending_parent_symbol or None).
stack = [(-1, root_children, None)]
def _attach_name(parent_symbol, start_line):
"""Look for the nearest ``name:`` field in the children of ``parent``."""
if parent_symbol is None or start_line + 1 >= len(lines):
return
base_indent = len(lines[start_line]) - len(lines[start_line].lstrip(" "))
for j in range(start_line + 1, min(start_line + 10, len(lines))):
l = lines[j]
stripped = l.lstrip(" ")
indent = len(l) - len(stripped)
if indent <= base_indent and stripped.strip() != "":
break
m = _NAME_FIELD.match(l)
if m:
value = m.group("value").strip("\"' ")
parent_symbol.detail = value
return
for i, raw_line in enumerate(lines):
m = _STEP_LINE.match(raw_line)
if not m:
continue
cmd = m.group("ident")
if cmd not in item_cmds:
continue
indent = len(m.group("lead")) - len(m.group("lead").lstrip(" "))
# Pop the stack until we find a parent with strictly smaller indent.
while stack and stack[-1][0] >= indent:
stack.pop()
if not stack:
stack.append((-1, root_children, None))
parent_children = stack[-1][1]
ident_start = m.start("ident")
ident_end = m.end("ident")
symbol = DocumentSymbol(
name=cmd,
detail=None,
kind=SymbolKind.Function,
range=Range(
start=Position(line=i, character=0),
end=Position(line=i, character=len(raw_line.rstrip("\n"))),
),
selection_range=Range(
start=Position(line=i, character=ident_start),
end=Position(line=i, character=ident_end),
),
children=[],
)
parent_children.append(symbol)
stack.append((indent, symbol.children, symbol))
_attach_name(symbol, i)
return root_children
def _make_server(): def _make_server():
server = LanguageServer("testium-lsp", "0.1.0") server = LanguageServer("testium-lsp", "0.1.0")
schema = dump_all_schemas() schema = dump_all_schemas()
item_completions = _build_item_completions(schema) item_completions = _build_item_completions(schema)
# Set of cmd names accepted by the outline / hover passes. We include
# action names (console open/close/…, plot open/close/…, …) too so they
# appear in the outline tree and respond to hover.
item_cmds = set()
for cmd, entry in schema["items"].items():
if cmd == "default":
continue
item_cmds.add(cmd)
item_cmds.update(entry.get("actions", {}).keys())
@server.feature( @server.feature(
TEXT_DOCUMENT_COMPLETION, TEXT_DOCUMENT_COMPLETION,
@@ -123,6 +260,50 @@ def _make_server():
return CompletionList(is_incomplete=False, items=[]) return CompletionList(is_incomplete=False, items=[])
return CompletionList(is_incomplete=False, items=item_completions) return CompletionList(is_incomplete=False, items=item_completions)
@server.feature(TEXT_DOCUMENT_HOVER)
def hover(params: HoverParams):
doc = server.workspace.get_text_document(params.text_document.uri)
line_idx = params.position.line
if line_idx >= len(doc.lines):
return None
line = doc.lines[line_idx]
# Only respond when the cursor is on the type part of a step line
# ("- sleep:") — never for arbitrary words in a string.
step_match = _STEP_LINE.match(line)
if not step_match:
return None
word = _word_at(line, params.position.character)
if word is None:
return None
start, end, text = word
if text != step_match.group("ident") or text not in item_cmds:
return None
# Resolve the entry: top-level item, or action of any parent.
entry = schema["items"].get(text)
if entry is None:
for parent_entry in schema["items"].values():
actions = parent_entry.get("actions") or {}
if text in actions:
entry = actions[text]
break
if entry is None:
return None
return Hover(
contents=MarkupContent(
kind=MarkupKind.Markdown,
value=_render_item_markdown(text, entry),
),
range=Range(
start=Position(line=line_idx, character=start),
end=Position(line=line_idx, character=end),
),
)
@server.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL)
def document_symbols(params: DocumentSymbolParams):
doc = server.workspace.get_text_document(params.text_document.uri)
return _build_document_symbols(doc.lines, item_cmds)
return server return server