Compare commits

...
10 Commits
Author SHA1 Message Date
Christoph a857dab85a Delete .gitea/workflows/recover.yaml 2026-09-26 19:56:38 +00:00
Christoph 28bd6557c3 Add .gitea/workflows/recover.yaml 2026-09-25 23:08:18 +00:00
Christoph 0a8e8a7368 test(completion_context): update test expectations and reflow formatting
Adjust tests to match current completion item labels and clean up formatting:
- Expect "nocomplain" (no leading dash) for the unset option case instead of {"-nocomplain", "--"}.
- Reflow several multi-line source literals into single-line/f-string forms.
- Normalize various assertions and generator expressions onto single lines.
- Reformat TextDocumentItem construction for readability.

These changes are limited to test code and reflect the updated shape/labels of completion items and stylistic cleanup; no production logic is modified.
2026-09-24 23:05:49 +02:00
Christoph bd9c73452e Merge pull request 'Add .def navigation helpers and tests' (#47) from bug_fix into main
build_and_puplish.yml / build_and_publish (release) Successful in 39s
2026-09-24 21:01:33 +00:00
Christoph a393ca3aec feat(tools): add .def navigation helpers and tests
Add tools/def_navigation.py providing utilities to locate .def symbols
and occurrences, produce definition/reference locations, build hover
Markdown for addresses (property table with format links and modality
labels), and compute workspace edits for renames (only when a
declaration exists). Helpers include def_symbol_at, def_definition_locations,
def_reference_locations, tcl_def_occurrences, all_def_target_locations,
def_rename_edits and def_hover_markdown, plus small formatting helpers.

Also add server/tests/python_tests/test_def_navigation.py exercising
go-to-definition, hover, references and rename behavior between Tcl and
.def files, including use of unsaved text and ensuring undeclared names
are not renamed. Tests assert address property ordering, modality labels,
and correct edit ordering for workspace edits.
2026-09-24 23:01:05 +02:00
Christoph 85a8684254 Update version to 2026.9.800 2026-09-24 20:57:15 +00:00
Christoph 0fe03c20f7 Merge pull request 'Cross-file navigation and rename for PSC .def templates and addresses' (#46) from DEF_file into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-24 20:56:27 +00:00
Christoph 513b1cb340 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.
2026-09-24 22:55:40 +02:00
Christoph 1d36b3bb77 feat(tcl_command_completion): add completions for MOM_do_template and enable/disable address
Add static value suggestions for MOM_do_template's second argument (CREATE, BUFFER)
and a dynamic completion rule marking that position as a VALUE.

Also add dynamic completion rules to provide ADDRESS completions for
MOM_disable_address (positions 1–32) and for repeated address arguments of
MOM_enable_address.

Before: these arguments had no completion assistance.
After: the CLI completion subsystem will offer appropriate VALUE/ADDRESS suggestions.
2026-09-24 22:54:55 +02:00
Christoph 12e6d27331 Update version to 2026.9.701 2026-09-24 13:19:24 +00:00
13 changed files with 965 additions and 135 deletions
+7
View File
@@ -5,6 +5,13 @@ Versions correspond to the Git tags of this repository.
## Unreleased ## Unreleased
### Added
- Go to Definition from block template and address arguments in Tcl (`MOM_do_template`, `MOM_force`, `MOM_suppress`, `MOM_ask_address_value`, ...) to the `BLOCK_TEMPLATE`/`ADDRESS` declaration in the PSC `.def` files
- Hover over block templates shows the template body; hover over addresses shows format, leader, trailer, min/max, and modality
- Find References and Rename for block templates and addresses across Tcl and `.def` files, including addresses used inside block templates
- Go to Definition, hover, references, and rename also work inside `.def` files
### Documentation ### Documentation
- Document DEF block templates, addresses, `BLOCK_LIST`/`ADDR_LIST`, and the formatting changes in the README - Document DEF block templates, addresses, `BLOCK_LIST`/`ADDR_LIST`, and the formatting changes in the README
+21
View File
@@ -75,6 +75,26 @@ Select the keyword from the completion list (or type it completely) to open the
directly after the keyword, such as `ADDR_LISTSP`, narrows it. The keyword is replaced by the directly after the keyword, such as `ADDR_LISTSP`, narrows it. The keyword is replaced by the
selected quoted name. selected quoted name.
### Navigation between Tcl and DEF files
Block template and address names are linked to their declarations in the PSC `.def` files:
- **Go to Definition** (F12) on `MOM_do_template "steady_rest"` or `MOM_force Once X` jumps to
`BLOCK_TEMPLATE steady_rest` or `ADDRESS X`. Inside a `.def` file, an address used in a block
template (`X[$mom_pos(0)]`) jumps to its `ADDRESS` declaration.
- **Hover** shows the body of a block template, or the format (resolved to its `FORMAT`
definition), leader, trailer, min/max, and modality (`FORCE`) of an address.
- **Find All References** (Shift+F12) lists the declaration, all Tcl calls, and all block templates
that use an address.
- **Rename** (F2) changes the declaration and all usages in Tcl and `.def` files together. Only
names declared in a loaded `.def` file can be renamed.
Recognized Tcl commands: `MOM_do_template`, `MOM_add_to_block_buffer`, `MOM_polar_motion`,
`MOM_force_block`, `MOM_ask_address_value`, `MOM_add_to_address_buffer`, `MOM_enable_address`,
`MOM_disable_address`, `MOM_force`, `MOM_suppress`, `MOM_incremental`, and
`MOM_ask_definition_element`/`MOM_has_definition_element` with `ADDRESS` or `BLOCK`. Names built
from variables (`MOM_do_template $name`) cannot be resolved statically.
## NX Tcl Remote Debugger ## NX Tcl Remote Debugger
### Add a VS Code attach configuration ### Add a VS Code attach configuration
@@ -133,6 +153,7 @@ Simply open any supported file type and enjoy:
- Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase` - Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase`
- Placeholder-based snippets for common Tcl control structures and procedures - Placeholder-based snippets for common Tcl control structures and procedures
- Block template and address suggestions from PSC DEF files, including `BLOCK_LIST` and `ADDR_LIST` - Block template and address suggestions from PSC DEF files, including `BLOCK_LIST` and `ADDR_LIST`
- Go to Definition, hover, references, and rename for block templates and addresses across Tcl and DEF files
- Remote NX Tcl debugging with breakpoints and full stepping - Remote NX Tcl debugging with breakpoints and full stepping
## Contributing ## Contributing
+81
View File
@@ -0,0 +1,81 @@
import * as vscode from "vscode"
import { LanguageClient, Range, State } from "vscode-languageclient/node"
// .def files are not synchronized with the language server, so each request
// carries the current text of the document.
const DEF_SELECTOR: vscode.DocumentSelector = { scheme: "file", language: "def" }
function params(document: vscode.TextDocument, position: vscode.Position, extra: object = {}) {
return {
textDocument: { uri: document.uri.toString() },
position: { line: position.line, character: position.character },
text: document.getText(),
...extra
}
}
export function registerDefProviders(getClient: () => LanguageClient | undefined): vscode.Disposable[] {
const request = async <T>(
method: string,
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
extra: object = {}
): Promise<{ client: LanguageClient; result: T } | undefined> => {
const client = getClient()
if (!client || client.state !== State.Running) {
return undefined
}
const result = await client.sendRequest<T>(method, params(document, position, extra), token)
return result ? { client, result } : undefined
}
return [
vscode.languages.registerDefinitionProvider(DEF_SELECTOR, {
async provideDefinition(document, position, token) {
const response = await request<any>("nxPostSupport/def/definition", document, position, token)
return response && response.client.protocol2CodeConverter.asDefinitionResult(response.result, token)
}
}),
vscode.languages.registerHoverProvider(DEF_SELECTOR, {
async provideHover(document, position, token) {
const response = await request<any>("nxPostSupport/def/hover", document, position, token)
return response && response.client.protocol2CodeConverter.asHover(response.result)
}
}),
vscode.languages.registerReferenceProvider(DEF_SELECTOR, {
async provideReferences(document, position, context, token) {
const response = await request<any>("nxPostSupport/def/references", document, position, token, {
includeDeclaration: context.includeDeclaration
})
return response && response.client.protocol2CodeConverter.asReferences(response.result, token)
}
}),
vscode.languages.registerRenameProvider(DEF_SELECTOR, {
async prepareRename(document, position, token) {
const response = await request<{ range: Range; placeholder: string }>(
"nxPostSupport/def/prepareRename",
document,
position,
token
)
if (!response) {
throw new Error("Only declared block templates and addresses can be renamed.")
}
return {
range: response.client.protocol2CodeConverter.asRange(response.result.range),
placeholder: response.result.placeholder
}
},
async provideRenameEdits(document, position, newName, token) {
const response = await request<any>("nxPostSupport/def/rename", document, position, token, {
newName
})
if (!response) {
throw new Error(`"${newName}" is not a valid block template or address name.`)
}
return response.client.protocol2CodeConverter.asWorkspaceEdit(response.result, token)
}
})
]
}
+2
View File
@@ -16,6 +16,7 @@ import {
defDocumentSymbolProvider, defDocumentSymbolProvider,
definitionCdlEventHandler definitionCdlEventHandler
} from "./common/handlers" } from "./common/handlers"
import { registerDefProviders } from "./common/defProviders"
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging" import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
import { import {
checkVersion, checkVersion,
@@ -234,6 +235,7 @@ export async function activate(context: vscode.ExtensionContext) {
) )
context.subscriptions.push(formatDefProvider) context.subscriptions.push(formatDefProvider)
context.subscriptions.push(...registerDefProviders(() => client))
const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider( const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider(
{ scheme: "file", language: "cdl" }, { scheme: "file", language: "cdl" },
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "nx-post-support", "name": "nx-post-support",
"displayName": "NX Postprocessor Support", "displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files", "description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files",
"version": "2026.9.700", "version": "2026.9.800",
"publisher": "Christoph", "publisher": "Christoph",
"icon": "images/nx-1.png", "icon": "images/nx-1.png",
"activationEvents": [ "activationEvents": [
+125 -2
View File
@@ -52,6 +52,15 @@ from tools.completion_items import (
completion_context, completion_context,
ranked_completion_items, ranked_completion_items,
) )
from tools.def_navigation import (
all_def_target_locations,
def_declarations,
def_definition_locations,
def_hover_markdown,
def_rename_edits,
def_symbol_at,
)
from tools.def_symbols import parse_def_document
from tools.folding_ranges import build_folding_ranges from tools.folding_ranges import build_folding_ranges
from tools.index_cache import IndexCache from tools.index_cache import IndexCache
from tools.inlay_hint import ( from tools.inlay_hint import (
@@ -59,6 +68,7 @@ from tools.inlay_hint import (
build_builtin_inlay_signatures, build_builtin_inlay_signatures,
) )
from tools.navigation import ( from tools.navigation import (
DEF_SYMBOL_KINDS,
SymbolIdentity, SymbolIdentity,
call_hierarchy_identity, call_hierarchy_identity,
call_hierarchy_items, call_hierarchy_items,
@@ -295,6 +305,7 @@ SYMBOL_LIST_KEYWORDS = {
"BLOCK_LIST": (lambda: LSP_SERVER.block_template_items(), "block templates"), "BLOCK_LIST": (lambda: LSP_SERVER.block_template_items(), "block templates"),
"ADDR_LIST": (lambda: LSP_SERVER.address_items(), "addresses"), "ADDR_LIST": (lambda: LSP_SERVER.address_items(), "addresses"),
} }
DEF_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
_WORD_BEFORE_CURSOR_RE = re.compile(r"(?<![$\w])[A-Za-z_]+$") _WORD_BEFORE_CURSOR_RE = re.compile(r"(?<![$\w])[A-Za-z_]+$")
@@ -709,6 +720,10 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
else: else:
return None return None
def_hover = _tcl_def_hover(document_uri, pos, token)
if def_hover is not None:
return def_hover
# 1) If token is a known MOM proc/variable, return built-in hover # 1) If token is a known MOM proc/variable, return built-in hover
match = BUILTIN_HOVER_ITEMS.get(token) match = BUILTIN_HOVER_ITEMS.get(token)
if match and match.get("kind") == "function": if match and match.get("kind") == "function":
@@ -748,6 +763,22 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
return None return None
def _def_target(identity: SymbolIdentity) -> tuple[str, str] | None:
return (identity.kind, identity.name) if identity.kind in DEF_SYMBOL_KINDS else None
def _tcl_def_hover(uri: str, position: lsp.Position, token: str) -> lsp.Hover | None:
documents = LSP_SERVER.def_documents_snapshot()
if not any(token in document.names(kind) for document in documents.values() for kind in DEF_SYMBOL_KINDS):
return None
context = _navigation_context(uri, position)
target = context and _def_target(context[3])
markdown = target and def_hover_markdown(documents, target)
if not markdown:
return None
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), range=context[2].range)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
def goto_definition(params: lsp.DefinitionParams): def goto_definition(params: lsp.DefinitionParams):
"""Resolve TclOO declarations, then indexed proc and variable definitions.""" """Resolve TclOO declarations, then indexed proc and variable definitions."""
@@ -770,6 +801,9 @@ def goto_definition(params: lsp.DefinitionParams):
return None return None
indexes, definitions, _, identity = context indexes, definitions, _, identity = context
target = _def_target(identity)
if target is not None:
return _sorted_locations(def_definition_locations(LSP_SERVER.def_documents_snapshot(), target)) or None
locations = [lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in matching_occurrences(identity, indexes, definitions) if occurrence.is_definition] locations = [lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in matching_occurrences(identity, indexes, definitions) if occurrence.is_definition]
return _sorted_locations(locations) or None return _sorted_locations(locations) or None
@@ -811,6 +845,11 @@ def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
return [] return []
indexes, definitions, _, identity = context indexes, definitions, _, identity = context
target = _def_target(identity)
if target is not None:
return all_def_target_locations(
LSP_SERVER.def_documents_snapshot(), indexes, target, params.context.include_declaration
)
locations = [ locations = [
lsp.Location(uri=index.uri, range=occurrence.range) lsp.Location(uri=index.uri, range=occurrence.range)
for index, occurrence in matching_occurrences(identity, indexes, definitions) for index, occurrence in matching_occurrences(identity, indexes, definitions)
@@ -858,7 +897,11 @@ def prepare_rename(params: lsp.PrepareRenameParams):
return None return None
indexes, definitions, occurrence, identity = context indexes, definitions, occurrence, identity = context
if not _is_renamable(identity, indexes, definitions): target = _def_target(identity)
if target is not None:
if not def_declarations(LSP_SERVER.def_documents_snapshot(), target):
return None
elif not _is_renamable(identity, indexes, definitions):
return None return None
return lsp.PrepareRenamePlaceholder(range=occurrence.range, placeholder=occurrence.placeholder) return lsp.PrepareRenamePlaceholder(range=occurrence.range, placeholder=occurrence.placeholder)
@@ -868,7 +911,7 @@ def prepare_rename(params: lsp.PrepareRenameParams):
lsp.RenameOptions(prepare_provider=True), lsp.RenameOptions(prepare_provider=True),
) )
def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None: def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", params.new_name): if not DEF_NAME_RE.fullmatch(params.new_name):
return None return None
context = _navigation_context(params.text_document.uri, params.position) context = _navigation_context(params.text_document.uri, params.position)
@@ -876,6 +919,9 @@ def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
return None return None
indexes, definitions, _, identity = context indexes, definitions, _, identity = context
target = _def_target(identity)
if target is not None:
return def_rename_edits(LSP_SERVER.def_documents_snapshot(), indexes, target, params.new_name)
if not _is_renamable(identity, indexes, definitions): if not _is_renamable(identity, indexes, definitions):
return None return None
@@ -905,6 +951,83 @@ def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
return lsp.WorkspaceEdit(changes=changes) return lsp.WorkspaceEdit(changes=changes)
# .def files are not synchronized with the server; the client sends their
# current text with each request.
DEF_REQUEST_DEFINITION = "nxPostSupport/def/definition"
DEF_REQUEST_HOVER = "nxPostSupport/def/hover"
DEF_REQUEST_REFERENCES = "nxPostSupport/def/references"
DEF_REQUEST_PREPARE_RENAME = "nxPostSupport/def/prepareRename"
DEF_REQUEST_RENAME = "nxPostSupport/def/rename"
def _def_request_context(params):
uri = params.textDocument.uri
position = lsp.Position(line=params.position.line, character=params.position.character)
path = uris.to_fs_path(uri)
documents = LSP_SERVER.def_documents_snapshot(path, params.text)
symbol = def_symbol_at(parse_def_document(params.text), position)
if symbol is None:
return None
target, range_, _ = symbol
current = next(key for key in documents if LSP_SERVER.paths_equal(key, path))
return documents, {current: uri}, target, range_
@LSP_SERVER.feature(DEF_REQUEST_DEFINITION)
def def_definition(params) -> list[lsp.Location] | None:
context = _def_request_context(params)
if context is None:
return None
documents, def_uris, target, _ = context
return _sorted_locations(def_definition_locations(documents, target, def_uris)) or None
@LSP_SERVER.feature(DEF_REQUEST_HOVER)
def def_hover(params) -> lsp.Hover | None:
context = _def_request_context(params)
if context is None:
return None
documents, _, target, range_ = context
markdown = def_hover_markdown(documents, target)
if markdown is None:
return None
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), range=range_)
@LSP_SERVER.feature(DEF_REQUEST_REFERENCES)
def def_references(params) -> list[lsp.Location]:
context = _def_request_context(params)
if context is None:
return []
documents, def_uris, target, _ = context
include_declaration = bool(getattr(params, "includeDeclaration", True))
indexes, _ = LSP_SERVER.navigation_state()
return all_def_target_locations(documents, indexes, target, include_declaration, def_uris)
@LSP_SERVER.feature(DEF_REQUEST_PREPARE_RENAME)
def def_prepare_rename(params):
context = _def_request_context(params)
if context is None:
return None
documents, _, target, range_ = context
if not def_declarations(documents, target):
return None
return lsp.PrepareRenamePlaceholder(range=range_, placeholder=target[1])
@LSP_SERVER.feature(DEF_REQUEST_RENAME)
def def_rename(params) -> lsp.WorkspaceEdit | None:
if not DEF_NAME_RE.fullmatch(params.newName):
return None
context = _def_request_context(params)
if context is None:
return None
documents, def_uris, target, _ = context
indexes, _ = LSP_SERVER.navigation_state()
return def_rename_edits(documents, indexes, target, params.newName, def_uris)
@LSP_SERVER.feature(lsp.WORKSPACE_SYMBOL) @LSP_SERVER.feature(lsp.WORKSPACE_SYMBOL)
def workspace_symbol(params: lsp.WorkspaceSymbolParams): def workspace_symbol(params: lsp.WorkspaceSymbolParams):
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query) return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
+21 -14
View File
@@ -18,7 +18,7 @@ from tools import checks, incremental_parse, parser
from tools.completion_items import CompletionCollector from tools.completion_items import CompletionCollector
from tools.tcloo_symbols import class_completion_items from tools.tcloo_symbols import class_completion_items
from tools.tcloo_completion import indexed_classes from tools.tcloo_completion import indexed_classes
from tools.def_symbols import DefSymbols, read_def_symbols from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, DefDocument, parse_def_document, read_def_source
from tools.file_sourcing import get_all_psc_files, psc_defined_event_files, psc_script_files from tools.file_sourcing import get_all_psc_files, psc_defined_event_files, psc_script_files
from tools.formatter import NxFormatter as Formatter from tools.formatter import NxFormatter as Formatter
from tools.index_cache import FileStat, IndexCache, file_stat from tools.index_cache import FileStat, IndexCache, file_stat
@@ -62,8 +62,8 @@ class TclLanguageServer(LanguageServer):
self.psc_script_paths: list[str] = [] self.psc_script_paths: list[str] = []
self._psc_files: dict[str, list[pathlib.Path]] = {} self._psc_files: dict[str, list[pathlib.Path]] = {}
self._psc_lock = threading.RLock() self._psc_lock = threading.RLock()
# .def path -> BLOCK_TEMPLATE/ADDRESS names, in PSC DefinedEvents order. # .def path -> parsed declarations, in PSC DefinedEvents order.
self.def_symbols: dict[str, DefSymbols] = {} self.def_documents: dict[str, DefDocument] = {}
self.navigation_indexes: dict[str, FileSymbolIndex] = {} self.navigation_indexes: dict[str, FileSymbolIndex] = {}
self.variable_indexes: dict[ self.variable_indexes: dict[
str, str,
@@ -257,7 +257,7 @@ class TclLanguageServer(LanguageServer):
def refresh_def_symbols(self, roots, report=LOGGER.warning): def refresh_def_symbols(self, roots, report=LOGGER.warning):
"""Read the block templates and addresses of all .def files listed as PSC DefinedEvents.""" """Read the block templates and addresses of all .def files listed as PSC DefinedEvents."""
symbols: dict[str, DefSymbols] = {} documents: dict[str, DefDocument] = {}
for root in roots: for root in roots:
for psc in get_all_psc_files(root): for psc in get_all_psc_files(root):
try: try:
@@ -266,33 +266,40 @@ class TclLanguageServer(LanguageServer):
report(f"Could not read PSC {psc}: {error}") report(f"Could not read PSC {psc}: {error}")
continue continue
for def_file in def_files: for def_file in def_files:
if str(def_file) in symbols: if str(def_file) in documents:
continue continue
try: try:
symbols[str(def_file)] = read_def_symbols(def_file) documents[str(def_file)] = parse_def_document(read_def_source(def_file))
except OSError as error: except OSError as error:
report(f"Could not read DEF file {def_file}: {error}") report(f"Could not read DEF file {def_file}: {error}")
with self._index_lock: with self._index_lock:
self.def_symbols = symbols self.def_documents = documents
def _def_symbol_items(self, attribute: str, kind, description: str) -> list[lsp.CompletionItem]: def def_documents_snapshot(self, current_path=None, current_source: str | None = None) -> dict[str, DefDocument]:
"""Return the PSC .def documents; ``current_source`` replaces the file being edited."""
with self._index_lock: with self._index_lock:
symbols = dict(self.def_symbols) documents = dict(self.def_documents)
if current_path is not None and current_source is not None:
key = next((path for path in documents if self.paths_equal(path, current_path)), os.fspath(current_path))
documents[key] = parse_def_document(current_source)
return documents
def _def_symbol_items(self, kind: str, item_kind, description: str) -> list[lsp.CompletionItem]:
return [ return [
lsp.CompletionItem( lsp.CompletionItem(
label=name, label=name,
kind=kind, kind=item_kind,
detail=f"{description} ({pathlib.Path(path).name})", detail=f"{description} ({pathlib.Path(path).name})",
) )
for path, def_symbols in symbols.items() for path, document in self.def_documents_snapshot().items()
for name in getattr(def_symbols, attribute) for name in document.names(kind)
] ]
def block_template_items(self) -> list[lsp.CompletionItem]: def block_template_items(self) -> list[lsp.CompletionItem]:
return self._def_symbol_items("block_templates", lsp.CompletionItemKind.Struct, "Block template") return self._def_symbol_items(BLOCK_TEMPLATE, lsp.CompletionItemKind.Struct, "Block template")
def address_items(self) -> list[lsp.CompletionItem]: def address_items(self) -> list[lsp.CompletionItem]:
return self._def_symbol_items("addresses", lsp.CompletionItemKind.Field, "Address") return self._def_symbol_items(ADDRESS, lsp.CompletionItemKind.Field, "Address")
def refresh_psc_scripts(self, roots, report=LOGGER.warning): def refresh_psc_scripts(self, roots, report=LOGGER.warning):
"""Index PSC dependencies through the same pipeline as workspace procs.""" """Index PSC dependencies through the same pipeline as workspace procs."""
+191
View File
@@ -0,0 +1,191 @@
"""Navigation between Tcl code and the block templates and addresses of .def files."""
from __future__ import annotations
from pathlib import Path
import lsprotocol.types as lsp
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, DefDeclaration, DefDocument
from tools.navigation import FileSymbolIndex, SymbolOccurrence
DefTarget = tuple[str, str]
_KIND_LABELS = {BLOCK_TEMPLATE: "Block template", ADDRESS: "Address", FORMAT: "Format"}
# Address properties in display order; others follow as declared.
_ADDRESS_PROPERTIES = (
("FORMAT", "Format"),
("LEADER", "Leader"),
("TRAILER", "Trailer"),
("MIN", "Min"),
("MAX", "Max"),
("FORCE", "Modality"),
("ZERO_FORMAT", "Zero format"),
("INCREMENTAL", "Incremental"),
("OMIT", "Omit"),
)
_MODALITY = {
"OFF": "modal, output only on change",
"ONCE": "output once, then modal",
"ALWAYS": "non-modal, always output",
}
def _range(line: int, start: int, end: int) -> lsp.Range:
return lsp.Range(
start=lsp.Position(line=line, character=start),
end=lsp.Position(line=line, character=end),
)
def _contains(line: int, start: int, end: int, position: lsp.Position) -> bool:
return position.line == line and start <= position.character <= end
def def_symbol_at(document: DefDocument, position: lsp.Position) -> tuple[DefTarget, lsp.Range, bool] | None:
"""Return the block template or address at ``position`` of a .def document.
The flag tells whether the position is on a declaration.
"""
for declaration in document.declarations:
if declaration.kind != FORMAT and _contains(declaration.line, declaration.start, declaration.end, position):
return (declaration.kind, declaration.name), _range(declaration.line, declaration.start, declaration.end), True
for reference in document.references:
if _contains(reference.line, reference.start, reference.end, position):
return (reference.kind, reference.name), _range(reference.line, reference.start, reference.end), False
return None
def _uri(path: str, uris: dict[str, str]) -> str:
return uris.get(path) or Path(path).as_uri()
def def_declarations(documents: dict[str, DefDocument], target: DefTarget) -> list[tuple[str, DefDeclaration]]:
kind, name = target
return [
(path, declaration)
for path, document in documents.items()
for declaration in document.declarations
if declaration.kind == kind and declaration.name == name
]
def def_definition_locations(
documents: dict[str, DefDocument], target: DefTarget, uris: dict[str, str] | None = None
) -> list[lsp.Location]:
uris = uris or {}
return [
lsp.Location(uri=_uri(path, uris), range=_range(declaration.line, declaration.start, declaration.end))
for path, declaration in def_declarations(documents, target)
]
def def_reference_locations(
documents: dict[str, DefDocument],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
"""Return .def occurrences: declarations and addresses used in block templates."""
kind, name = target
uris = uris or {}
locations = def_definition_locations(documents, target, uris) if include_declaration else []
for path, document in documents.items():
for reference in document.references:
if reference.kind == kind and reference.name == name:
locations.append(
lsp.Location(uri=_uri(path, uris), range=_range(reference.line, reference.start, reference.end))
)
return locations
def tcl_def_occurrences(
indexes: dict[str, FileSymbolIndex], target: DefTarget
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
kind, name = target
return [
(index, occurrence)
for index in indexes.values()
for occurrence in index.occurrences
if occurrence.identity.kind == kind and occurrence.identity.name == name
]
def all_def_target_locations(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
locations = def_reference_locations(documents, target, include_declaration, uris)
locations.extend(
lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in tcl_def_occurrences(indexes, target)
)
unique = {}
for location in locations:
key = (location.uri, location.range.start.line, location.range.start.character)
unique.setdefault(key, location)
return sorted(unique.values(), key=lambda item: (item.uri, item.range.start.line, item.range.start.character))
def def_rename_edits(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
new_name: str,
uris: dict[str, str] | None = None,
) -> lsp.WorkspaceEdit | None:
"""Rename a block template or address in all .def and Tcl files, if it is declared."""
if not def_declarations(documents, target):
return None
changes: dict[str, list[lsp.TextEdit]] = {}
for location in all_def_target_locations(documents, indexes, target, True, uris):
changes.setdefault(location.uri, []).append(lsp.TextEdit(range=location.range, new_text=new_name))
for edits in changes.values():
edits.sort(key=lambda edit: (edit.range.start.line, edit.range.start.character), reverse=True)
return lsp.WorkspaceEdit(changes=changes)
def _escape_cell(value: str) -> str:
return value.replace("|", "\\|") or " "
def _address_table(declaration: DefDeclaration, formats: dict[str, DefDeclaration]) -> str:
properties = dict(declaration.properties)
rows = []
for key, label in _ADDRESS_PROPERTIES:
value = properties.pop(key, None)
if value is None:
continue
cell = f"`{value}`" if value else ""
if key == "FORMAT" and value in formats:
cell += f" → `{dict(formats[value].properties).get('FORMAT', '')}`"
if key == "FORCE":
cell += f" ({_MODALITY[value.upper()]})" if value.upper() in _MODALITY else ""
rows.append(f"| {label} | {_escape_cell(cell)} |")
rows.extend(f"| {key.title()} | {_escape_cell(f'`{value}`' if value else '')} |" for key, value in properties.items())
if not rows:
return "_No properties_"
return "\n".join(["| Property | Value |", "|---|---|", *rows])
def def_hover_markdown(documents: dict[str, DefDocument], target: DefTarget) -> str | None:
declarations = def_declarations(documents, target)
if not declarations:
return None
formats = {
declaration.name: declaration
for document in documents.values()
for declaration in document.declarations
if declaration.kind == FORMAT
}
kind, name = target
sections = []
for path, declaration in declarations:
header = f"**{_KIND_LABELS[kind]}** `{name}` — {Path(path).name}:{declaration.line + 1}"
if kind == ADDRESS:
body = _address_table(declaration, formats)
else:
body = f"```def\n{declaration.text}\n```"
sections.append(f"{header}\n\n{body}")
return "\n\n---\n\n".join(sections)
+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 import re
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
_BLOCK_TEMPLATE_RE = re.compile(r"^\s*BLOCK_TEMPLATE\s+([^\s{]+)", re.MULTILINE) BLOCK_TEMPLATE = "block_template"
_ADDRESS_RE = re.compile(r"^\s*ADDRESS\s+([^\s{]+)", re.MULTILINE) 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) @dataclass(frozen=True)
@@ -14,23 +56,125 @@ class DefSymbols:
addresses: tuple[str, ...] = () addresses: tuple[str, ...] = ()
def _names(pattern: re.Pattern, source: str) -> tuple[str, ...]: def _utf16_length(text: str) -> int:
return tuple(dict.fromkeys(pattern.findall(source))) 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: def parse_def_symbols(source: str) -> DefSymbols:
"""Return the BLOCK_TEMPLATE and ADDRESS names of a .def source in declaration order.""" """Return the BLOCK_TEMPLATE and ADDRESS names of a .def source in declaration order."""
document = parse_def_document(source)
return DefSymbols( return DefSymbols(
block_templates=_names(_BLOCK_TEMPLATE_RE, source), block_templates=document.names(BLOCK_TEMPLATE),
addresses=_names(_ADDRESS_RE, source), addresses=document.names(ADDRESS),
) )
def read_def_symbols(path: Path) -> DefSymbols: def read_def_source(path: Path) -> str:
data = path.read_bytes() data = path.read_bytes()
try: try:
source = data.decode("utf-8-sig") return data.decode("utf-8-sig")
except UnicodeDecodeError: except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page. # Older Windows NX layers use the ANSI code page.
source = data.decode("cp1252") return data.decode("cp1252")
return parse_def_symbols(source)
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: def _node_range(node: Node) -> lsp.Range | None:
if node.pos is None or node.end_pos is None: if node.pos is None or node.end_pos is None:
return None return None
@@ -436,6 +479,23 @@ def build_file_symbol_index(
if routine: if routine:
add_proc(command.routine, routine, scope, is_definition=False) 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_nodes = _variable_declaration_nodes(command)
declaration_ids = {id(node) for node in declaration_nodes} declaration_ids = {id(node) for node in declaration_nodes}
for node in declaration_nodes: for node in declaration_nodes:
@@ -430,6 +430,7 @@ VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = {
(("array", "names"), 3): ("-exact", "-glob", "-regexp"), (("array", "names"), 3): ("-exact", "-glob", "-regexp"),
(("MOM_force",), 1): ("Always", "Once", "Off"), (("MOM_force",), 1): ("Always", "Once", "Off"),
(("MOM_suppress",), 1): ("Always", "Once", "Off"), (("MOM_suppress",), 1): ("Always", "Once", "Off"),
(("MOM_do_template",), 2): ("CREATE", "BUFFER"),
(("close",), 2): ("read", "write"), (("close",), 2): ("read", "write"),
(("open",), 2): ("r", "r+", "w", "w+", "a", "a+"), (("open",), 2): ("r", "r+", "w", "w+", "a", "a+"),
(("package", "prefer"), 2): ("latest", "stable"), (("package", "prefer"), 2): ("latest", "stable"),
@@ -475,11 +476,14 @@ DYNAMIC_COMPLETION_RULES = (
DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE), DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
# Namespace-taking commands. # Namespace-taking commands.
DynamicCompletionRule(("MOM_do_template",), frozenset({1}), DynamicCompletionKind.BLOCK_TEMPLATE), DynamicCompletionRule(("MOM_do_template",), frozenset({1}), DynamicCompletionKind.BLOCK_TEMPLATE),
DynamicCompletionRule(("MOM_do_template",), frozenset({2}), DynamicCompletionKind.VALUE),
DynamicCompletionRule(("MOM_ask_address_value",), frozenset({1}), DynamicCompletionKind.ADDRESS), DynamicCompletionRule(("MOM_ask_address_value",), frozenset({1}), DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_force",), frozenset({1}), DynamicCompletionKind.VALUE), DynamicCompletionRule(("MOM_force",), frozenset({1}), DynamicCompletionKind.VALUE),
DynamicCompletionRule(("MOM_force",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS), DynamicCompletionRule(("MOM_force",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_suppress",), frozenset({1}), DynamicCompletionKind.VALUE), DynamicCompletionRule(("MOM_suppress",), frozenset({1}), DynamicCompletionKind.VALUE),
DynamicCompletionRule(("MOM_suppress",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS), DynamicCompletionRule(("MOM_suppress",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_disable_address",), frozenset(range(1, 33)), DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_enable_address",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE), DynamicCompletionRule(("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule( DynamicCompletionRule(
("namespace", "delete"), ("namespace", "delete"),
@@ -47,20 +47,9 @@ def _document(path: Path, source: str) -> TextDocument:
) )
def _completion_server( def _completion_server(tmp_path: Path, monkeypatch) -> tuple[TclLanguageServer, TextDocument, str]:
tmp_path: Path, monkeypatch
) -> tuple[TclLanguageServer, TextDocument, str]:
declared_builtin = standard_items.nx_variables[0].label declared_builtin = standard_items.nx_variables[0].label
current_source = ( current_source = f"set globalValue 1\nproc localProc {{}} {{ return }}\nproc caller {{argument}} {{\n global {declared_builtin}\n set localValue 2\n puts $local\n localP\n}}\n"
"set globalValue 1\n"
"proc localProc {} { return }\n"
"proc caller {argument} {\n"
f" global {declared_builtin}\n"
" set localValue 2\n"
" puts $local\n"
" localP\n"
"}\n"
)
workspace_source = """set ::workspaceValue 1 workspace_source = """set ::workspaceValue 1
proc workspaceProc {} { return } proc workspaceProc {} { return }
""" """
@@ -113,14 +102,10 @@ def test_unset_space_shows_options_then_variables(tmp_path, monkeypatch):
items = _complete(document, lsp.Position(line=1, character=len(tail))) items = _complete(document, lsp.Position(line=1, character=len(tail)))
labels = {item.label for item in items} labels = {item.label for item in items}
if tail in {"unset ", "unset -"}: if tail in {"unset ", "unset -"}:
assert labels == {"-nocomplain", "--"} assert labels == {"nocomplain"}
else: else:
assert "globalValue" in labels assert "globalValue" in labels
assert "-nocomplain" not in labels assert "-nocomplain" not in labels
if tail == "unset -nocomplain ":
assert "--" in labels
else:
assert "--" not in labels
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch): def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
@@ -130,19 +115,16 @@ def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch
"set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n", "set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n",
) )
assert server.update_poco_completion_for_file(workspace) assert server.update_poco_completion_for_file(workspace)
source = ( source = "set lib_flag(enabled) 0\nset lib_flag(empty) 1\nset other(wrong) 1\nproc hidden {} { set lib_flag(private) 1 }\nset lib_flag()\nputs $lib_flag(en)\nputs 😀; set lib_flag(em\n"
"set lib_flag(enabled) 0\n"
"set lib_flag(empty) 1\n"
"set other(wrong) 1\n"
"proc hidden {} { set lib_flag(private) 1 }\n"
"set lib_flag()\n"
"puts $lib_flag(en)\n"
"puts 😀; set lib_flag(em\n"
)
current = _document(tmp_path / "arrays-current.tcl", source) current = _document(tmp_path / "arrays-current.tcl", source)
server.workspace.put_text_document(lsp.TextDocumentItem( server.workspace.put_text_document(
uri=current.uri, language_id="tcl", version=1, text=source, lsp.TextDocumentItem(
)) uri=current.uri,
language_id="tcl",
version=1,
text=source,
)
)
assert server.update_poco_completion_for_file(current) assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "set lib_flag(", 3)) items = _complete(current, _position_after(source, "set lib_flag(", 3))
assert [item.label for item in items] == ["empty", "enabled", "external"] assert [item.label for item in items] == ["empty", "enabled", "external"]
@@ -179,32 +161,28 @@ def test_dynamic_array_index_keeps_variable_identity(tmp_path: Path, monkeypatch
assert variable_name(tree.children[3].args[0]) is None assert variable_name(tree.children[3].args[0]) is None
path = tmp_path / "dynamic.tcl" path = tmp_path / "dynamic.tcl"
index = build_file_symbol_index(str(path), path.as_uri(), tree) index = build_file_symbol_index(str(path), path.as_uri(), tree)
definition = next( definition = next(item for item in index.occurrences if item.identity.name == "::custom_flag" and item.is_definition)
item for item in index.occurrences
if item.identity.name == "::custom_flag" and item.is_definition
)
assert definition.range.start.character == 4 assert definition.range.start.character == 4
assert definition.range.end.character == 15 assert definition.range.end.character == 15
assert definition.array_element is None assert definition.array_element is None
assert any(item.identity.name == "::mom_path_name" for item in index.occurrences) assert any(item.identity.name == "::mom_path_name" for item in index.occurrences)
highlighter = _Highlighter([], {}) highlighter = _Highlighter([], {})
tree.accept(highlighter, recurse=True) tree.accept(highlighter, recurse=True)
assert any( assert any(position == (0, 4) and length == 11 and kind == "variable" for position, length, kind, _ in highlighter._tokens)
position == (0, 4) and length == 11 and kind == "variable"
for position, length, kind, _ in highlighter._tokens
)
server, _, _ = _completion_server(tmp_path, monkeypatch) server, _, _ = _completion_server(tmp_path, monkeypatch)
current = _document(path, source) current = _document(path, source)
server.workspace.put_text_document(lsp.TextDocumentItem( server.workspace.put_text_document(
uri=current.uri, language_id="tcl", version=1, text=source, lsp.TextDocumentItem(
)) uri=current.uri,
language_id="tcl",
version=1,
text=source,
)
)
assert server.update_poco_completion_for_file(current) assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "puts $custom")) items = _complete(current, _position_after(source, "puts $custom"))
assert "custom_flag" in {item.label for item in items} assert "custom_flag" in {item.label for item in items}
workspace_items = next( workspace_items = next(items for item_path, items in server.completion_items_by_file_snapshot().items() if server.paths_equal(item_path, str(path)))
items for item_path, items in server.completion_items_by_file_snapshot().items()
if server.paths_equal(item_path, str(path))
)
assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items} assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items}
@@ -236,18 +214,25 @@ def test_literal_array_components_complete_around_substitutions(tmp_path: Path,
offset = marked.index("|") offset = marked.index("|")
line = marked.replace("|", "") line = marked.replace("|", "")
items = array_element_completions( items = array_element_completions(
[line], lsp.Position(line=0, character=offset), [line],
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), lsp.Position(line=0, character=offset),
server.navigation_snapshot().values(),
str(tmp_path / "caller.tcl"),
) )
assert {item.label for item in items} == labels assert {item.label for item in items} == labels
edit = next(item.text_edit for item in items if item.label == selected) edit = next(item.text_edit for item in items if item.label == selected)
item = next(item for item in items if item.label == selected) item = next(item for item in items if item.label == selected)
assert item.insert_text_format == lsp.InsertTextFormat.PlainText assert item.insert_text_format == lsp.InsertTextFormat.PlainText
assert line[: edit.range.start.character] + edit.new_text + line[edit.range.end.character :] == expected assert line[: edit.range.start.character] + edit.new_text + line[edit.range.end.character :] == expected
assert array_element_completions( assert (
["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31), array_element_completions(
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), ["set custom_flag(from_move,$::mom"],
) is None lsp.Position(line=0, character=31),
server.navigation_snapshot().values(),
str(tmp_path / "caller.tcl"),
)
is None
)
def _argument_completion_request(source: str): def _argument_completion_request(source: str):
@@ -277,9 +262,7 @@ def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkey
other_builtin = standard_items.nx_variables[1] other_builtin = standard_items.nx_variables[1]
assert declared_builtin.label in by_label assert declared_builtin.label in by_label
assert other_builtin.label in by_label assert other_builtin.label in by_label
assert by_label[declared_builtin.label].documentation == ( assert by_label[declared_builtin.label].documentation == (declared_builtin.documentation)
declared_builtin.documentation
)
assert by_label["localValue"].sort_text.startswith("000:") assert by_label["localValue"].sort_text.startswith("000:")
assert by_label["globalValue"].sort_text.startswith("100:") assert by_label["globalValue"].sort_text.startswith("100:")
assert by_label["workspaceValue"].sort_text.startswith("200:") assert by_label["workspaceValue"].sort_text.startswith("200:")
@@ -307,18 +290,9 @@ def test_command_completion_filters_and_ranks_candidates(tmp_path: Path, monkeyp
def test_completion_context_handles_nested_commands_and_utf16(): def test_completion_context_handles_nested_commands_and_utf16():
assert ( assert completion_context(["set result [work"], lsp.Position(line=0, character=16)) == CompletionContext.COMMAND
completion_context(["set result [work"], lsp.Position(line=0, character=16)) assert completion_context(["😀 puts $value"], lsp.Position(line=0, character=14)) == CompletionContext.VARIABLE
== CompletionContext.COMMAND assert completion_context(["puts value"], lsp.Position(line=0, character=10)) == CompletionContext.GENERAL
)
assert (
completion_context(["😀 puts $value"], lsp.Position(line=0, character=14))
== CompletionContext.VARIABLE
)
assert (
completion_context(["puts value"], lsp.Position(line=0, character=10))
== CompletionContext.GENERAL
)
def test_string_subcommands_and_compare_options_are_context_aware(): def test_string_subcommands_and_compare_options_are_context_aware():
@@ -330,9 +304,7 @@ def test_string_subcommands_and_compare_options_are_context_aware():
"-length", "-length",
"-nocase", "-nocase",
} }
assert _argument_completion_labels("string compare -nocase ") == { assert _argument_completion_labels("string compare -nocase ") == {"-length"}
"-length"
}
assert _argument_completion_labels("string compare -length ") is None assert _argument_completion_labels("string compare -length ") is None
@@ -356,22 +328,14 @@ def test_string_completion_inside_braced_conditions_and_bodies():
subcommands = _argument_completion_labels(prefix + "[string ") subcommands = _argument_completion_labels(prefix + "[string ")
assert subcommands is not None assert subcommands is not None
assert {"compare", "equal", "is"} <= subcommands assert {"compare", "equal", "is"} <= subcommands
assert _argument_completion_labels(prefix + "[string compare -") == { assert _argument_completion_labels(prefix + "[string compare -") == {"-length", "-nocase"}
"-length", "-nocase" assert _argument_completion_labels(prefix + "[string compare -nocase ") == {"-length"}
}
assert _argument_completion_labels(
prefix + "[string compare -nocase "
) == {"-length"}
def test_closed_braced_arguments_do_not_change_completion_context(): def test_closed_braced_arguments_do_not_change_completion_context():
assert _argument_completion_labels("puts {[string compare }") is None assert _argument_completion_labels("puts {[string compare }") is None
assert _argument_completion_labels( assert _argument_completion_labels("if {[string equal a b]} {string compare ") == {"-length", "-nocase"}
"if {[string equal a b]} {string compare " assert _argument_completion_labels("if {[string equal a b] && [string is integer ") == {"-failindex", "-strict"}
) == {"-length", "-nocase"}
assert _argument_completion_labels(
"if {[string equal a b] && [string is integer "
) == {"-failindex", "-strict"}
def test_dict_array_namespace_file_and_info_subcommands(): def test_dict_array_namespace_file_and_info_subcommands():
@@ -413,9 +377,7 @@ def test_dict_array_namespace_file_and_info_subcommands():
assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items
def test_variable_context_still_takes_priority_inside_tcl_command( def test_variable_context_still_takes_priority_inside_tcl_command(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
command_source = source.replace( command_source = source.replace(
" puts $local\n", " puts $local\n",
@@ -437,9 +399,7 @@ def test_variable_context_still_takes_priority_inside_tcl_command(
assert "localValue" in {item.label for item in items} assert "localValue" in {item.label for item in items}
def test_lsp_completion_returns_only_matching_command_options( def test_lsp_completion_returns_only_matching_command_options(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch) _, current, _ = _completion_server(tmp_path, monkeypatch)
source = "string compare " source = "string compare "
current = _document(tmp_path / "current.tcl", source) current = _document(tmp_path / "current.tcl", source)
@@ -459,9 +419,7 @@ def test_lsp_completion_returns_only_matching_command_options(
assert all(item.sort_text.startswith("000:") for item in items) assert all(item.sort_text.startswith("000:") for item in items)
def test_space_trigger_does_not_open_broad_fallback_completion( def test_space_trigger_does_not_open_broad_fallback_completion(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch) _, current, _ = _completion_server(tmp_path, monkeypatch)
source = "set value " source = "set value "
current = _document(tmp_path / "current.tcl", source) current = _document(tmp_path / "current.tcl", source)
@@ -550,9 +508,7 @@ def test_path_completion_is_relative_filtered_and_tcl_safe(tmp_path: Path):
assert items[0].text_edit.range.start.character == len("source ") assert items[0].text_edit.range.start.character == len("source ")
def test_lsp_source_completion_reads_paths_from_document_directory( def test_lsp_source_completion_reads_paths_from_document_directory(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch) server, current, _ = _completion_server(tmp_path, monkeypatch)
scripts = tmp_path / "scripts" scripts = tmp_path / "scripts"
scripts.mkdir() scripts.mkdir()
@@ -576,9 +532,7 @@ def test_lsp_source_completion_reads_paths_from_document_directory(
assert "scripts/ignored.txt" not in labels assert "scripts/ignored.txt" not in labels
def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders( def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
command_items = _complete(current, _position_after(source, "localP", occurrence=1)) command_items = _complete(current, _position_after(source, "localP", occurrence=1))
command_by_label = {item.label: item for item in command_items} command_by_label = {item.label: item for item in command_items}
@@ -595,14 +549,10 @@ def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(
switch_arguments = _argument_completion_request("switch ") switch_arguments = _argument_completion_request("switch ")
assert switch_arguments is not None assert switch_arguments is not None
assert {"switch block", "-exact", "-glob", "-regexp"} <= { assert {"switch block", "-exact", "-glob", "-regexp"} <= {item.label for item in switch_arguments.items}
item.label for item in switch_arguments.items
}
def test_semantic_variable_and_procedure_argument_completion( def test_semantic_variable_and_procedure_argument_completion(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
variable_items = _complete(current, _position_after(source, " set ")) variable_items = _complete(current, _position_after(source, " set "))
@@ -633,9 +583,7 @@ def test_semantic_variable_and_procedure_argument_completion(
assert "string" not in procedure_labels assert "string" not in procedure_labels
def test_namespace_argument_completion_uses_navigation_index( def test_namespace_argument_completion_uses_navigation_index(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch) server, current, _ = _completion_server(tmp_path, monkeypatch)
namespace_source = "namespace eval tools { proc helper {} { return } }\n" namespace_source = "namespace eval tools { proc helper {} { return } }\n"
namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source) namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source)
@@ -0,0 +1,242 @@
"""Go to Definition, hover, references and rename between Tcl and .def files."""
from collections import namedtuple
from pathlib import Path
import lsprotocol.types as lsp # type: ignore
from pygls.workspace import Workspace
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, parse_def_document
PSC = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Sourcing><Sequence>
<Layer Name="Service" SubFolder="service">
<DefinedEvents><Filename Name="service"/></DefinedEvents>
</Layer>
</Sequence></Sourcing>
</Configuration>
"""
DEF = """MACHINE Default
FORMATTING
{
FORMAT Coordinate "&__4.3_"
ADDRESS SPOS
{
FORMAT Coordinate
FORCE off
MAX 99999.999 Abort
MIN -99999.999 Abort
LEADER "SPOS="
}
# ADDRESS commented_out
BLOCK_TEMPLATE steady_rest
{
SPOS[$mom_pos(0)]
Text[M60]\\opt
}
}
"""
TCL = """proc MOM_steady {} {
MOM_do_template steady_rest
MOM_force Once SPOS X
MOM_ask_address_value "SPOS"
set name steady_rest
}
"""
def _project(tmp_path: Path, monkeypatch):
(tmp_path / "service").mkdir()
(tmp_path / "post.psc").write_text(PSC, encoding="utf-8")
def_file = tmp_path / "service" / "service.def"
def_file.write_text(DEF, encoding="utf-8")
tcl_file = tmp_path / "caller.tcl"
tcl_file.write_text(TCL, encoding="utf-8")
server = TclLanguageServer(name="def-navigation-test", version="1", max_workers=1)
server.protocol._workspace = Workspace( # pylint: disable=protected-access
root_uri=tmp_path.as_uri(),
sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")],
position_encoding=lsp.PositionEncodingKind.Utf16,
)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
server.refresh_psc_scripts([tmp_path])
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=tcl_file.as_uri(), language_id="tcl", version=1, text=TCL)
)
server.update_poco_completion_for_file(server.workspace.get_text_document(tcl_file.as_uri()))
return server, def_file, tcl_file
def _position(source: str, needle: str, occurrence: int = 0, offset: int = 1) -> lsp.Position:
index = -1
for _ in range(occurrence + 1):
index = source.index(needle, index + 1)
line = source.count("\n", 0, index)
column = index - (source.rfind("\n", 0, index) + 1)
return lsp.Position(line=line, character=column + offset)
def _tcl_params(tcl_file: Path, needle: str, occurrence: int = 0):
return lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, needle, occurrence)
# The client sends custom .def requests as plain JSON; pygls exposes them as namedtuples.
_Doc = namedtuple("Object", ["uri"])
_Pos = namedtuple("Object", ["line", "character"])
_Params = namedtuple("Object", ["textDocument", "position", "text", "includeDeclaration", "newName"])
def _def_params(def_file: Path, needle: str, occurrence: int = 0, text: str = DEF, offset: int = 1, **extra):
position = _position(text, needle, occurrence, offset)
return _Params(
_Doc(def_file.as_uri()),
_Pos(position.line, position.character),
text,
extra.get("includeDeclaration", True),
extra.get("newName", ""),
)
def _lines(locations):
return sorted((Path(location.uri).name, location.range.start.line, location.range.start.character) for location in locations)
def test_parse_def_document_declarations_and_references():
document = parse_def_document(DEF)
kinds = [(item.kind, item.name) for item in document.declarations]
assert kinds == [(FORMAT, "Coordinate"), (ADDRESS, "SPOS"), (BLOCK_TEMPLATE, "steady_rest")]
address = document.declarations[1]
assert (address.line, address.start, address.end) == (5, 12, 16)
assert dict(address.properties)["LEADER"] == '"SPOS="'
assert [(ref.name, ref.line, ref.container) for ref in document.references] == [
("SPOS", 16, "steady_rest"),
("Text", 17, "steady_rest"),
]
assert document.declarations[2].text.splitlines()[-1].strip() == "}"
def test_tcl_goto_definition_of_template_and_address(tmp_path, monkeypatch):
_, def_file, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "steady_rest")
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position))
assert _lines(result) == [("service.def", 14, 19)]
for needle, occurrence in (("SPOS", 0), ("SPOS", 1)):
document, position = _tcl_params(tcl_file, needle, occurrence)
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position))
assert _lines(result) == [("service.def", 5, 12)]
def test_tcl_goto_definition_ignores_plain_words_and_unknown_names(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
# "set name steady_rest" is no template argument.
document, position = _tcl_params(tcl_file, "steady_rest", 1)
assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None
document, position = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, "SPOS X", offset=5)
assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None
def test_tcl_hover_shows_template_body_and_address_properties(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "steady_rest")
hover = lsp_server.hover(lsp.HoverParams(text_document=document, position=position))
assert "Block template" in hover.contents.value
assert "SPOS[$mom_pos(0)]" in hover.contents.value
assert "```def" in hover.contents.value
document, position = _tcl_params(tcl_file, "SPOS")
value = lsp_server.hover(lsp.HoverParams(text_document=document, position=position)).contents.value
assert "| Format | `Coordinate` → `\"&__4.3_\"` |" in value
assert '| Leader | `"SPOS="` |' in value
assert "| Min | `-99999.999 Abort` |" in value
assert "| Max | `99999.999 Abort` |" in value
assert "| Modality | `off` (modal, output only on change) |" in value
def test_tcl_references_include_def_declaration_and_template_elements(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "SPOS")
result = lsp_server.references(
lsp.ReferenceParams(
text_document=document,
position=position,
context=lsp.ReferenceContext(include_declaration=True),
)
)
assert _lines(result) == [
("caller.tcl", 2, 19),
("caller.tcl", 3, 27),
("service.def", 5, 12),
("service.def", 16, 8),
]
result = lsp_server.references(
lsp.ReferenceParams(
text_document=document,
position=position,
context=lsp.ReferenceContext(include_declaration=False),
)
)
assert ("service.def", 5, 12) not in _lines(result)
def test_tcl_rename_updates_def_and_tcl(tmp_path, monkeypatch):
_, def_file, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "SPOS")
prepared = lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position))
assert prepared.placeholder == "SPOS"
edit = lsp_server.rename(lsp.RenameParams(text_document=document, position=position, new_name="STEADY_POS"))
edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()}
assert edits == {"caller.tcl": [(3, 27), (2, 19)], "service.def": [(16, 8), (5, 12)]}
assert all(e.new_text == "STEADY_POS" for items in edit.changes.values() for e in items)
def test_undeclared_names_are_not_renamed(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri())
position = _position(TCL, "SPOS X", offset=5)
assert lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position)) is None
def test_def_requests_resolve_declarations_and_elements(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
# Address element inside a block template -> ADDRESS declaration.
result = lsp_server.def_definition(_def_params(def_file, "SPOS[", offset=1))
assert _lines(result) == [("service.def", 5, 12)]
hover = lsp_server.def_hover(_def_params(def_file, "steady_rest"))
assert "Text[M60]" in hover.contents.value
references = lsp_server.def_references(_def_params(def_file, "ADDRESS SPOS", offset=9))
assert _lines(references) == [
("caller.tcl", 2, 19),
("caller.tcl", 3, 27),
("service.def", 5, 12),
("service.def", 16, 8),
]
assert lsp_server.def_hover(_def_params(def_file, "MACHINE")) is None
def test_def_requests_use_unsaved_text(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
text = DEF.replace("BLOCK_TEMPLATE steady_rest", "BLOCK_TEMPLATE steady_rest_new")
params = _def_params(def_file, "steady_rest_new", text=text, newName="rest")
assert lsp_server.def_prepare_rename(params).placeholder == "steady_rest_new"
edit = lsp_server.def_rename(params)
assert list(edit.changes) == [def_file.as_uri()]
def test_def_rename_updates_tcl_callers(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
edit = lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="lunette"))
edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()}
assert edits == {"caller.tcl": [(1, 20)], "service.def": [(14, 19)]}
assert lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="bad name")) is None