feat(def): add cross-file navigation and rename for PSC .def block templates and addresses

Add client and server support to navigate, inspect, reference, and rename block
template and address symbols declared in PSC .def files:

- Client: register language providers for .def (definition, hover, references,
  prepare/provide rename) and send the current document text with each request.
- Server: parse .def files into DefDocument/DefDeclaration/DefReference, expose
  def-specific LSP endpoints (definition/hover/references/prepareRename/rename),
  and integrate .def lookups into existing Tcl hover/definition/references/rename
  flows so Tcl calls jump to .def declarations.
- Tcl server keeps a snapshot API for .def documents (current editor content can
  replace file on request); only declared names in loaded .def files can be
  renamed. Name validation uses DEF_NAME_RE.

Update README and CHANGELOG to document navigation features.
This commit is contained in:
2026-09-24 22:55:40 +02:00
parent 1d36b3bb77
commit 513b1cb340
8 changed files with 473 additions and 28 deletions
+156 -12
View File
@@ -1,11 +1,53 @@
"""Block templates and addresses declared in NX post definition (.def) files."""
"""Block templates, addresses and formats declared in NX post definition (.def) files."""
import re
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
_BLOCK_TEMPLATE_RE = re.compile(r"^\s*BLOCK_TEMPLATE\s+([^\s{]+)", re.MULTILINE)
_ADDRESS_RE = re.compile(r"^\s*ADDRESS\s+([^\s{]+)", re.MULTILINE)
BLOCK_TEMPLATE = "block_template"
ADDRESS = "address"
FORMAT = "format"
_KINDS = {"BLOCK_TEMPLATE": BLOCK_TEMPLATE, "ADDRESS": ADDRESS, "FORMAT": FORMAT}
_HEADER_RE = re.compile(r"^\s*(BLOCK_TEMPLATE|ADDRESS|FORMAT)\s+([^\s{]+)")
# A block template element is an address followed by its expression: X[$mom_pos(0)].
_ELEMENT_RE = re.compile(r"^\s*([A-Za-z_]\w*)\[")
_PROPERTY_RE = re.compile(r"^\s*([A-Za-z_]+)\s*(.*?)\s*$")
@dataclass(frozen=True)
class DefDeclaration:
kind: str
name: str
# 0-based line and UTF-16 columns of the name.
line: int
start: int
end: int
end_line: int
text: str
# ADDRESS: body properties such as ("LEADER", '"X"'); FORMAT: (("FORMAT", '"%d"'),).
properties: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
class DefReference:
"""An address used as element of a block template."""
kind: str
name: str
line: int
start: int
end: int
container: str
@dataclass(frozen=True)
class DefDocument:
declarations: tuple[DefDeclaration, ...] = ()
references: tuple[DefReference, ...] = ()
def names(self, kind: str) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.name for item in self.declarations if item.kind == kind))
@dataclass(frozen=True)
@@ -14,23 +56,125 @@ class DefSymbols:
addresses: tuple[str, ...] = ()
def _names(pattern: re.Pattern, source: str) -> tuple[str, ...]:
return tuple(dict.fromkeys(pattern.findall(source)))
def _utf16_length(text: str) -> int:
return len(text.encode("utf-16-le")) // 2
def _is_comment(line: str) -> bool:
return line.lstrip().startswith("#")
def _body(lines: list[str], header: int, rest: str) -> tuple[int, int, int] | None:
"""Return (first body line, first body column, closing line) of a braced body.
The body starts after the first "{" on the header line or on a following
line and ends at the next line starting with "}".
"""
if "{" in rest:
after = rest.split("{", 1)[1]
if "}" in after:
return None
start = header + 1
else:
start = header + 1
while start < len(lines) and (not lines[start].strip() or _is_comment(lines[start])):
start += 1
if start >= len(lines) or not lines[start].lstrip().startswith("{"):
return None
start += 1
end = start
while end < len(lines) and not lines[end].lstrip().startswith("}"):
end += 1
return start, 0, min(end, len(lines) - 1)
def parse_def_document(source: str) -> DefDocument:
"""Parse the block templates, addresses and formats of a .def source."""
lines = source.splitlines()
declarations: list[DefDeclaration] = []
references: list[DefReference] = []
index = 0
while index < len(lines):
line = lines[index]
match = None if _is_comment(line) else _HEADER_RE.match(line)
if match is None:
index += 1
continue
kind = _KINDS[match.group(1)]
name = match.group(2)
start = _utf16_length(line[: match.start(2)])
end = start + _utf16_length(name)
rest = line[match.end(2) :]
if kind == FORMAT:
declarations.append(
DefDeclaration(kind, name, index, start, end, index, line.strip(), (("FORMAT", rest.strip()),))
)
index += 1
continue
body = _body(lines, index, rest)
end_line = index if body is None else body[2]
properties: list[tuple[str, str]] = []
if body is not None:
for number in range(body[0], body[2]):
body_line = lines[number]
if _is_comment(body_line) or not body_line.strip():
continue
if kind == BLOCK_TEMPLATE:
element = _ELEMENT_RE.match(body_line)
if element is not None:
element_start = _utf16_length(body_line[: element.start(1)])
references.append(
DefReference(
ADDRESS,
element.group(1),
number,
element_start,
element_start + _utf16_length(element.group(1)),
name,
)
)
else:
prop = _PROPERTY_RE.match(body_line)
if prop is not None:
properties.append((prop.group(1).upper(), prop.group(2)))
declarations.append(
DefDeclaration(
kind,
name,
index,
start,
end,
end_line,
"\n".join(lines[index : end_line + 1]),
tuple(properties),
)
)
index = end_line + 1
return DefDocument(tuple(declarations), tuple(references))
def parse_def_symbols(source: str) -> DefSymbols:
"""Return the BLOCK_TEMPLATE and ADDRESS names of a .def source in declaration order."""
document = parse_def_document(source)
return DefSymbols(
block_templates=_names(_BLOCK_TEMPLATE_RE, source),
addresses=_names(_ADDRESS_RE, source),
block_templates=document.names(BLOCK_TEMPLATE),
addresses=document.names(ADDRESS),
)
def read_def_symbols(path: Path) -> DefSymbols:
def read_def_source(path: Path) -> str:
data = path.read_bytes()
try:
source = data.decode("utf-8-sig")
return data.decode("utf-8-sig")
except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page.
source = data.decode("cp1252")
return parse_def_symbols(source)
return data.decode("cp1252")
def read_def_symbols(path: Path) -> DefSymbols:
return parse_def_symbols(read_def_source(path))
+60
View File
@@ -114,6 +114,49 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp
)
DEF_BLOCK_TEMPLATE = "block_template"
DEF_ADDRESS = "address"
DEF_SYMBOL_KINDS = frozenset({DEF_BLOCK_TEMPLATE, DEF_ADDRESS})
# NX commands taking .def names: command -> (first argument index, last index or
# None for all following arguments, kind).
_DEF_ARGUMENTS: dict[str, tuple[tuple[int, int | None, str], ...]] = {
"MOM_do_template": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_add_to_block_buffer": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_polar_motion": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_force_block": ((1, None, DEF_BLOCK_TEMPLATE),),
"MOM_ask_address_value": ((0, 0, DEF_ADDRESS),),
"MOM_add_to_address_buffer": ((0, 0, DEF_ADDRESS),),
"MOM_enable_address": ((0, None, DEF_ADDRESS),),
"MOM_disable_address": ((0, None, DEF_ADDRESS),),
"MOM_force": ((1, None, DEF_ADDRESS),),
"MOM_suppress": ((1, None, DEF_ADDRESS),),
"MOM_incremental": ((1, None, DEF_ADDRESS),),
}
_DEFINITION_ELEMENT_KINDS = {"ADDRESS": DEF_ADDRESS, "BLOCK": DEF_BLOCK_TEMPLATE}
def def_argument_kinds(command: Command) -> list[tuple[Node, str]]:
"""Return the arguments of ``command`` that name a .def block template or address."""
routine = _static_contents(command.routine)
if routine in {"MOM_ask_definition_element", "MOM_has_definition_element"}:
kind = _DEFINITION_ELEMENT_KINDS.get((_static_contents(command.args[0]) or "").upper()) if command.args else None
return [(command.args[1], kind)] if kind and len(command.args) >= 2 else []
result = []
for first, last, kind in _DEF_ARGUMENTS.get(routine or "", ()):
for position, argument in enumerate(command.args):
if position >= first and (last is None or position <= last):
result.append((argument, kind))
return result
def _def_name(node: Node) -> str | None:
name = _static_contents(node)
if not name or node.contents_pos is None or any(char.isspace() or char in "$[]{}\\\"" for char in name):
return None
return name
def _node_range(node: Node) -> lsp.Range | None:
if node.pos is None or node.end_pos is None:
return None
@@ -436,6 +479,23 @@ def build_file_symbol_index(
if routine:
add_proc(command.routine, routine, scope, is_definition=False)
for node, kind in def_argument_kinds(command):
name = _def_name(node)
if name is None:
continue
line, column = node.contents_pos
occurrences.append(
SymbolOccurrence(
identity=shared(SymbolIdentity(kind=kind, name=name)),
range=lsp.Range(
start=lsp.Position(line=line - 1, character=column - 1),
end=lsp.Position(line=line - 1, character=column - 1 + len(name)),
),
placeholder=name,
symbol_kind=lsp.SymbolKind.Struct if kind == DEF_BLOCK_TEMPLATE else lsp.SymbolKind.Field,
)
)
declaration_nodes = _variable_declaration_nodes(command)
declaration_ids = {id(node) for node in declaration_nodes}
for node in declaration_nodes: