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:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user