From 0a8e8a7368c094d5b6e8c298ad4709cc3d43c723 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 24 Sep 2026 23:05:49 +0200 Subject: [PATCH 1/4] 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. --- .../python_tests/test_completion_context.py | 160 ++++++------------ 1 file changed, 54 insertions(+), 106 deletions(-) diff --git a/server/tests/python_tests/test_completion_context.py b/server/tests/python_tests/test_completion_context.py index a58b5d9..333e29f 100644 --- a/server/tests/python_tests/test_completion_context.py +++ b/server/tests/python_tests/test_completion_context.py @@ -47,20 +47,9 @@ def _document(path: Path, source: str) -> TextDocument: ) -def _completion_server( - tmp_path: Path, monkeypatch -) -> tuple[TclLanguageServer, TextDocument, str]: +def _completion_server(tmp_path: Path, monkeypatch) -> tuple[TclLanguageServer, TextDocument, str]: declared_builtin = standard_items.nx_variables[0].label - current_source = ( - "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" - ) + 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" workspace_source = """set ::workspaceValue 1 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))) labels = {item.label for item in items} if tail in {"unset ", "unset -"}: - assert labels == {"-nocomplain", "--"} + assert labels == {"nocomplain"} else: assert "globalValue" 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): @@ -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", ) assert server.update_poco_completion_for_file(workspace) - source = ( - "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" - ) + 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" current = _document(tmp_path / "arrays-current.tcl", source) - server.workspace.put_text_document(lsp.TextDocumentItem( - uri=current.uri, language_id="tcl", version=1, text=source, - )) + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=1, + text=source, + ) + ) assert server.update_poco_completion_for_file(current) items = _complete(current, _position_after(source, "set lib_flag(", 3)) 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 path = tmp_path / "dynamic.tcl" index = build_file_symbol_index(str(path), path.as_uri(), tree) - definition = next( - item for item in index.occurrences - if item.identity.name == "::custom_flag" and item.is_definition - ) + definition = next(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.end.character == 15 assert definition.array_element is None assert any(item.identity.name == "::mom_path_name" for item in index.occurrences) highlighter = _Highlighter([], {}) tree.accept(highlighter, recurse=True) - assert any( - position == (0, 4) and length == 11 and kind == "variable" - for position, length, kind, _ in highlighter._tokens - ) + assert any(position == (0, 4) and length == 11 and kind == "variable" for position, length, kind, _ in highlighter._tokens) server, _, _ = _completion_server(tmp_path, monkeypatch) current = _document(path, source) - server.workspace.put_text_document(lsp.TextDocumentItem( - uri=current.uri, language_id="tcl", version=1, text=source, - )) + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=1, + text=source, + ) + ) assert server.update_poco_completion_for_file(current) items = _complete(current, _position_after(source, "puts $custom")) assert "custom_flag" in {item.label for item in items} - workspace_items = next( - items for item_path, items in server.completion_items_by_file_snapshot().items() - if server.paths_equal(item_path, str(path)) - ) + workspace_items = next(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} @@ -236,18 +214,25 @@ def test_literal_array_components_complete_around_substitutions(tmp_path: Path, offset = marked.index("|") line = marked.replace("|", "") items = array_element_completions( - [line], lsp.Position(line=0, character=offset), - server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), + [line], + lsp.Position(line=0, character=offset), + server.navigation_snapshot().values(), + str(tmp_path / "caller.tcl"), ) assert {item.label for item in items} == labels edit = next(item.text_edit 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 line[:edit.range.start.character] + edit.new_text + line[edit.range.end.character:] == expected - assert array_element_completions( - ["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31), - server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), - ) is None + assert line[: edit.range.start.character] + edit.new_text + line[edit.range.end.character :] == expected + assert ( + array_element_completions( + ["set custom_flag(from_move,$::mom"], + lsp.Position(line=0, character=31), + server.navigation_snapshot().values(), + str(tmp_path / "caller.tcl"), + ) + is None + ) 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] assert declared_builtin.label in by_label assert other_builtin.label in by_label - assert by_label[declared_builtin.label].documentation == ( - declared_builtin.documentation - ) + assert by_label[declared_builtin.label].documentation == (declared_builtin.documentation) assert by_label["localValue"].sort_text.startswith("000:") assert by_label["globalValue"].sort_text.startswith("100:") 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(): - assert ( - completion_context(["set result [work"], lsp.Position(line=0, character=16)) - == CompletionContext.COMMAND - ) - 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 - ) + assert completion_context(["set result [work"], lsp.Position(line=0, character=16)) == CompletionContext.COMMAND + 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(): @@ -330,9 +304,7 @@ def test_string_subcommands_and_compare_options_are_context_aware(): "-length", "-nocase", } - assert _argument_completion_labels("string compare -nocase ") == { - "-length" - } + assert _argument_completion_labels("string compare -nocase ") == {"-length"} 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 ") assert subcommands is not None assert {"compare", "equal", "is"} <= subcommands - assert _argument_completion_labels(prefix + "[string compare -") == { - "-length", "-nocase" - } - assert _argument_completion_labels( - prefix + "[string compare -nocase " - ) == {"-length"} + assert _argument_completion_labels(prefix + "[string compare -") == {"-length", "-nocase"} + assert _argument_completion_labels(prefix + "[string compare -nocase ") == {"-length"} def test_closed_braced_arguments_do_not_change_completion_context(): assert _argument_completion_labels("puts {[string compare }") is None - assert _argument_completion_labels( - "if {[string equal a b]} {string compare " - ) == {"-length", "-nocase"} - assert _argument_completion_labels( - "if {[string equal a b] && [string is integer " - ) == {"-failindex", "-strict"} + assert _argument_completion_labels("if {[string equal a b]} {string compare ") == {"-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(): @@ -413,9 +377,7 @@ def test_dict_array_namespace_file_and_info_subcommands(): assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items -def test_variable_context_still_takes_priority_inside_tcl_command( - tmp_path: Path, monkeypatch -): +def test_variable_context_still_takes_priority_inside_tcl_command(tmp_path: Path, monkeypatch): _, current, source = _completion_server(tmp_path, monkeypatch) command_source = source.replace( " 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} -def test_lsp_completion_returns_only_matching_command_options( - tmp_path: Path, monkeypatch -): +def test_lsp_completion_returns_only_matching_command_options(tmp_path: Path, monkeypatch): _, current, _ = _completion_server(tmp_path, monkeypatch) source = "string compare " 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) -def test_space_trigger_does_not_open_broad_fallback_completion( - tmp_path: Path, monkeypatch -): +def test_space_trigger_does_not_open_broad_fallback_completion(tmp_path: Path, monkeypatch): _, current, _ = _completion_server(tmp_path, monkeypatch) source = "set value " 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 ") -def test_lsp_source_completion_reads_paths_from_document_directory( - tmp_path: Path, monkeypatch -): +def test_lsp_source_completion_reads_paths_from_document_directory(tmp_path: Path, monkeypatch): server, current, _ = _completion_server(tmp_path, monkeypatch) scripts = tmp_path / "scripts" scripts.mkdir() @@ -576,9 +532,7 @@ def test_lsp_source_completion_reads_paths_from_document_directory( assert "scripts/ignored.txt" not in labels -def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders( - tmp_path: Path, monkeypatch -): +def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(tmp_path: Path, monkeypatch): _, current, source = _completion_server(tmp_path, monkeypatch) command_items = _complete(current, _position_after(source, "localP", occurrence=1)) 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 ") assert switch_arguments is not None - assert {"switch block", "-exact", "-glob", "-regexp"} <= { - item.label for item in switch_arguments.items - } + assert {"switch block", "-exact", "-glob", "-regexp"} <= {item.label for item in switch_arguments.items} -def test_semantic_variable_and_procedure_argument_completion( - tmp_path: Path, monkeypatch -): +def test_semantic_variable_and_procedure_argument_completion(tmp_path: Path, monkeypatch): _, current, source = _completion_server(tmp_path, monkeypatch) 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 -def test_namespace_argument_completion_uses_navigation_index( - tmp_path: Path, monkeypatch -): +def test_namespace_argument_completion_uses_navigation_index(tmp_path: Path, monkeypatch): server, current, _ = _completion_server(tmp_path, monkeypatch) namespace_source = "namespace eval tools { proc helper {} { return } }\n" namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source) From 17fb79a3469e590fad90c0b82ba5ab072c6a7d71 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Sep 2026 07:58:56 +0200 Subject: [PATCH 2/4] feat(lsp): support completion item resolve to show .def previews Enable completion item resolution so selecting a completion can populate documentation with a preview of the corresponding .def declaration. - Turn on resolve_provider in the completion options. - Implement on_completion_resolve to attach Markdown documentation computed by def_hover_markdown when the completion item's data contains a ("def" -> [kind, name]) payload. - Include that "def" payload when generating def-related completion items. - Add tests that verify block-template and address-list items resolve to Markdown previews. Before: def-related completion items had no documentation on resolve. After: selecting such items will return a Markdown preview of the .def entry. --- server/src/lsp_server.py | 13 ++++++++++++- server/src/lsp_tclserver.py | 1 + server/tests/python_tests/test_def_symbols.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 778365d..d903008 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -287,7 +287,7 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): @LSP_SERVER.feature( lsp.TEXT_DOCUMENT_COMPLETION, - lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","]), + lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","], resolve_provider=True), ) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: result = _on_completion(params) @@ -300,6 +300,17 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: return result +@LSP_SERVER.feature(lsp.COMPLETION_ITEM_RESOLVE) +def on_completion_resolve(item: lsp.CompletionItem) -> lsp.CompletionItem: + """Show the .def declaration of a selected block template/address, as on hover.""" + target = item.data.get("def") if isinstance(item.data, dict) else None + if item.documentation is None and isinstance(target, list) and len(target) == 2: + markdown = def_hover_markdown(LSP_SERVER.def_documents_snapshot(), tuple(target)) + if markdown: + item.documentation = lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown) + return item + + # Keywords that expand to all loaded .def names: keyword -> (items, description). SYMBOL_LIST_KEYWORDS = { "BLOCK_LIST": (lambda: LSP_SERVER.block_template_items(), "block templates"), diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 15489cc..35db8b4 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -290,6 +290,7 @@ class TclLanguageServer(LanguageServer): label=name, kind=item_kind, detail=f"{description} ({pathlib.Path(path).name})", + data={"def": [kind, name]}, ) for path, document in self.def_documents_snapshot().items() for name in document.names(kind) diff --git a/server/tests/python_tests/test_def_symbols.py b/server/tests/python_tests/test_def_symbols.py index 6e703bb..f9df77e 100644 --- a/server/tests/python_tests/test_def_symbols.py +++ b/server/tests/python_tests/test_def_symbols.py @@ -227,6 +227,25 @@ def test_block_list_shows_all_templates_quoted(tmp_path, monkeypatch): assert all(item.filter_text.startswith("BLOCK_LIST") for item in items) +def test_block_list_item_resolves_to_template_preview(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + items = _complete(server, tmp_path, "set a 1\n BLOCK_LIST") + assert all(item.documentation is None for item in items) + item = next(item for item in items if item.label == "steady_rest") + resolved = lsp_server.on_completion_resolve(item) + assert resolved.documentation.kind == lsp.MarkupKind.Markdown + assert "**Block template** `steady_rest`" in resolved.documentation.value + assert "Text[M60]" in resolved.documentation.value + + +def test_address_list_item_resolves_to_address_table(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + items = _complete(server, tmp_path, "set a 1\n ADDR_LIST") + item = next(item for item in items if item.label == "SPOS") + resolved = lsp_server.on_completion_resolve(item) + assert "| Format | `Coordinate` |" in resolved.documentation.value + + def test_block_list_ignores_variables_and_other_words(tmp_path, monkeypatch): server, _ = _project(tmp_path, monkeypatch) for source in ("set x $BLOCK_LIST", "set x MY_BLOCK_LIST", "set x steady"): From 5b027a2717ada69f3c59853b952d8f14c8d84810 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Sep 2026 08:14:59 +0200 Subject: [PATCH 3/4] feat(def_flow): recognize .def names propagated via variables and wrapper procs Add a new def_flow analysis module that follows .def block template and address names through local variables and proc parameters, and a wrapper-table builder to resolve proc arguments that forward .def names. Derived names are resolved only for hover/definition (not for rename). Integrate this into the LSP: - lsp_server: add _word_at and _tcl_def_symbol helpers; fallback to derived_def_symbol when direct NX-argument navigation fails for hover, goto-definition and references; return proper ranges for hover. - lsp_tclserver: cache and expose a def_wrapper_table built from index def_flows (with cache invalidation on index generation). Also add unit tests for def_flow and update CHANGELOG to note hover/ definition and completion preview improvements for derived names. --- CHANGELOG.md | 2 + server/src/lsp_server.py | 55 +++- server/src/lsp_tclserver.py | 14 + server/src/tools/def_flow.py | 340 +++++++++++++++++++++ server/src/tools/navigation.py | 68 ++--- server/tests/python_tests/test_def_flow.py | 182 +++++++++++ 6 files changed, 612 insertions(+), 49 deletions(-) create mode 100644 server/src/tools/def_flow.py create mode 100644 server/tests/python_tests/test_def_flow.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 39d63d7..7500888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Versions correspond to the Git tags of this repository. - 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 +- Hover and Go to Definition also recognize block template and address names that reach an NX command through a local variable (`set`, `lappend`, `list`, `foreach`) or through the parameter of a custom proc such as `LIB_SPF_call_cycle "absolute_mode"`, including nested wrapper procs; same-named strings without such a path are not recognized, and these derived names are not renamed +- Completion items for block templates and addresses (`BLOCK_LIST`, `ADDR_LIST`, `MOM_do_template`, ...) show the same preview as the hover ### Documentation diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index d903008..9db0875 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -60,6 +60,7 @@ from tools.def_navigation import ( def_rename_edits, def_symbol_at, ) +from tools.def_flow import derived_def_symbol from tools.def_symbols import parse_def_document from tools.folding_ranges import build_folding_ranges from tools.index_cache import IndexCache @@ -778,16 +779,53 @@ 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: +def _word_at(uri: str, position: lsp.Position) -> str | None: + document = LSP_SERVER.workspace.get_text_document(uri) + try: + line = LSP_SERVER.get_lines(document)[position.line] + except IndexError: + return None + for match in re.finditer(r"\b\w+\b", line): + if match.start() <= position.character <= match.end(): + return match.group(0) + return None + + +def _tcl_def_symbol(uri: str, position: lsp.Position, token: str | None): + """The .def target at ``position``: a direct NX command argument or a derived name. + + Returns (documents, target, range) or 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): + # Cheap guard: only names declared in a .def file are analyzed at all. + kinds = {kind for kind in DEF_SYMBOL_KINDS if token and any(token in document.names(kind) for document in documents.values())} + if not kinds: return None context = _navigation_context(uri, position) target = context and _def_target(context[3]) - markdown = target and def_hover_markdown(documents, target) + if target: + return documents, target, context[2].range + try: + tree = LSP_SERVER.get_tree(LSP_SERVER.workspace.get_text_document(uri)) + except TclSyntaxError: + return None + derived = derived_def_symbol(tree, position, LSP_SERVER.def_wrapper_table()) + if derived is None or derived[1] != token: + return None + found, name, range_ = derived + kind = next((kind for kind in sorted(found & kinds) if def_declarations(documents, (kind, name))), None) + return (documents, (kind, name), range_) if kind else None + + +def _tcl_def_hover(uri: str, position: lsp.Position, token: str) -> lsp.Hover | None: + symbol = _tcl_def_symbol(uri, position, token) + if symbol is None: + return None + documents, target, range_ = symbol + markdown = 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) + return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), range=range_) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION) @@ -809,7 +847,8 @@ def goto_definition(params: lsp.DefinitionParams): return [target] context = _navigation_context(params.text_document.uri, params.position) if context is None: - return None + symbol = _tcl_def_symbol(params.text_document.uri, params.position, _word_at(params.text_document.uri, params.position)) + return symbol and _sorted_locations(def_definition_locations(symbol[0], symbol[1])) or None indexes, definitions, _, identity = context target = _def_target(identity) @@ -853,7 +892,11 @@ def _sorted_locations(locations: list[lsp.Location]) -> list[lsp.Location]: def references(params: lsp.ReferenceParams) -> list[lsp.Location]: context = _navigation_context(params.text_document.uri, params.position) if context is None: - return [] + symbol = _tcl_def_symbol(params.text_document.uri, params.position, _word_at(params.text_document.uri, params.position)) + if symbol is None: + return [] + indexes, _ = LSP_SERVER.navigation_state() + return all_def_target_locations(symbol[0], indexes, symbol[1], params.context.include_declaration) indexes, definitions, _, identity = context target = _def_target(identity) diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 35db8b4..500c53c 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -18,6 +18,7 @@ from tools import checks, incremental_parse, parser from tools.completion_items import CompletionCollector from tools.tcloo_symbols import class_completion_items from tools.tcloo_completion import indexed_classes +from tools.def_flow import WrapperTable, build_wrapper_table 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.formatter import NxFormatter as Formatter @@ -97,6 +98,7 @@ class TclLanguageServer(LanguageServer): self._definition_identities_cache: tuple[ int, frozenset[SymbolIdentity] ] = (-1, frozenset()) + self._def_wrapper_cache: tuple[int, WrapperTable] = (-1, {}) self._proc_metadata_cache: dict[ str, tuple[int, dict[str, list[str]], dict[str, str]] ] = {} @@ -213,6 +215,7 @@ class TclLanguageServer(LanguageServer): self._workspace_completion_cache = (-1, ()) self._custom_function_names_cache = (-1, frozenset()) self._definition_identities_cache = (-1, frozenset()) + self._def_wrapper_cache = (-1, {}) self._proc_metadata_cache.clear() self._custom_inlay_cache.clear() @@ -630,6 +633,17 @@ class TclLanguageServer(LanguageServer): ) return dict(self.navigation_indexes), definitions + def def_wrapper_table(self) -> WrapperTable: + """Proc arguments that take .def names, cached by index generation.""" + with self._index_lock: + generation, table = self._def_wrapper_cache + if generation != self._index_generation: + table = build_wrapper_table( + flow for index in self.navigation_indexes.values() for flow in index.def_flows + ) + self._def_wrapper_cache = (self._index_generation, table) + return table + def _begin_index_update(self, filepath: str, version: int | None) -> int | None: with self._index_lock: indexed_version = self._index_versions.get(filepath) diff --git a/server/src/tools/def_flow.py b/server/src/tools/def_flow.py new file mode 100644 index 0000000..7b218da --- /dev/null +++ b/server/src/tools/def_flow.py @@ -0,0 +1,340 @@ +"""Follow .def block template and address names through variables and procs. + +A name is only a .def symbol where it provably reaches an NX command taking one: +directly as an argument (``MOM_do_template steady_rest``), through a variable +of the same scope (``set t steady_rest; MOM_do_template $t``) or through the +parameter of a proc that passes it on (``LIB_SPF_call_cycle absolute_mode``). +Derived names are resolved for hover and definition only, never renamed. +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from collections.abc import Iterable, Iterator + +import lsprotocol.types as lsp +from tclint.syntax_tree import BracedWord, Command, CommandSub, List, Node, QuotedWord, Script, VarSub +from tools.tcl_command_completion import TCL_COMMAND_NAMES + +ROOT_NAMESPACE = "::" +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_COMMANDS = frozenset({"MOM_ask_definition_element", "MOM_has_definition_element"}) +_DEFINITION_ELEMENT_KINDS = {"ADDRESS": DEF_ADDRESS, "BLOCK": DEF_BLOCK_TEMPLATE} + +# Commands that never pass a .def name on to a proc parameter. +_NON_FORWARDING = ( + frozenset(TCL_COMMAND_NAMES) + | frozenset(_DEF_ARGUMENTS) + | _DEFINITION_ELEMENT_COMMANDS + | frozenset({ + "set", "unset", "puts", "expr", "return", "incr", "append", "lappend", "list", "lindex", "lrange", + "llength", "lsearch", "lsort", "lreverse", "lassign", "concat", "join", "split", "format", "regsub", + "regexp", "string", "if", "while", "for", "foreach", "lmap", "switch", "catch", "eval", "uplevel", + "upvar", "global", "variable", "info", "array", "dict", "subst", "error", "proc", "namespace", + }) +) +# Commands returning (elements of) their first argument's list value. +_LIST_ACCESSORS = frozenset({"lindex", "lrange", "lsort", "lreverse", "lsearch"}) +_LIST_WORD_RE = re.compile(r'"([^"\s{}]*)"|([^\s"{}]+)') + +# ("def", kind) or ("call", routine, argument index) where a variable ends up. +FlowTarget = tuple +# Fact: ("sink", variable, target) or ("edge", destination, source variable). +FlowFact = tuple +# Per proc: ((parameter index, ("def", kind) | ("call", qualified, fallback, index)), ...) +ProcDefFlows = tuple[tuple[int, tuple], ...] +WrapperTable = dict[str, dict[int, frozenset[str]]] + + +def static_contents(node: Node | None) -> str | None: + value = getattr(node, "contents", None) + return value if isinstance(value, str) else None + + +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 _DEFINITION_ELEMENT_COMMANDS: + 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 qualify(name: str, namespace: str) -> str: + if name.startswith("::"): + return name + return f"::{name}" if namespace == ROOT_NAMESPACE else f"{namespace}::{name}" + + +def _variable_reference(node: Node) -> str | None: + """Name of the scalar variable ``node`` consists of: ``$v`` or ``"$v"``.""" + if isinstance(node, QuotedWord) and len(node.children) == 1: + node = node.children[0] + if isinstance(node, VarSub) and isinstance(node.value, str) and "(" not in node.value: + return node.value + return None + + +def _value_sources(node: Node) -> list[str]: + """Variables whose value or list elements ``node`` copies.""" + variable = _variable_reference(node) + if variable is not None: + return [variable] + if isinstance(node, CommandSub) and len(node.children) == 1 and isinstance(node.children[0], Command): + inner = node.children[0] + routine = static_contents(inner.routine) + if routine in _LIST_ACCESSORS and inner.args: + return _value_sources(inner.args[0]) + if routine in {"list", "concat"}: + return [source for argument in inner.args for source in _value_sources(argument)] + return [] + + +def _bound_names(node: Node) -> list[str]: + nodes = node.children if isinstance(node, List) else [node] + names = [static_contents(child) for child in nodes] + if len(names) == 1 and names[0] and " " in names[0]: + return names[0].split() + return [name for name in names if name] + + +def command_flow_facts(command: Command) -> list[FlowFact]: + """Facts on how ``command`` moves variable values towards .def arguments.""" + routine = static_contents(command.routine) + args = command.args + facts: list[FlowFact] = [ + ("sink", variable, ("def", kind)) + for node, kind in def_argument_kinds(command) + if (variable := _variable_reference(node)) is not None + ] + if routine == "set" and len(args) == 2: + destination = static_contents(args[0]) + if destination: + facts.extend(("edge", destination, source) for source in _value_sources(args[1])) + elif routine == "lappend" and args: + destination = static_contents(args[0]) + if destination: + facts.extend(("edge", destination, source) for argument in args[1:] for source in _value_sources(argument)) + elif routine in {"foreach", "lmap"} and len(args) >= 3: + for position in range(0, len(args) - 1, 2): + sources = _value_sources(args[position + 1]) + facts.extend(("edge", name, source) for name in _bound_names(args[position]) for source in sources) + elif routine == "lassign" and args: + sources = _value_sources(args[0]) + facts.extend(("edge", name, source) for node in args[1:] if (name := static_contents(node)) for source in sources) + elif routine and routine not in _NON_FORWARDING: + facts.extend( + ("sink", variable, ("call", routine, position)) + for position, argument in enumerate(args) + if (variable := _variable_reference(argument)) is not None + ) + return facts + + +def solve_flow(facts: Iterable[FlowFact]) -> dict[str, set[FlowTarget]]: + """Map each variable to the .def arguments and proc parameters it reaches.""" + targets: dict[str, set[FlowTarget]] = defaultdict(set) + sources: dict[str, set[str]] = defaultdict(set) + for fact in facts: + if fact[0] == "sink": + targets[fact[1]].add(fact[2]) + elif fact[1] != fact[2]: + sources[fact[1]].add(fact[2]) + pending = [variable for variable in targets if variable in sources] + while pending: + destination = pending.pop() + for source in sources.get(destination, ()): + before = len(targets[source]) + targets[source] |= targets[destination] + if len(targets[source]) != before: + pending.append(source) + return targets + + +def proc_def_flows(facts: Iterable[FlowFact], parameters: list[str], namespace: str) -> ProcDefFlows: + """Where the parameters of a proc end up, with qualified callee names.""" + targets = solve_flow(facts) + flows = [] + for position, parameter in enumerate(parameters): + if parameter == "args" and position == len(parameters) - 1: + break + for target in targets.get(parameter, ()): + if target[0] == "call": + target = ("call", qualify(target[1], namespace), qualify(target[1], ROOT_NAMESPACE), target[2]) + flows.append((position, target)) + return tuple(sorted(flows)) + + +def build_wrapper_table(procs: Iterable[tuple[str, ProcDefFlows]]) -> WrapperTable: + """Resolve which proc arguments take .def names, following nested wrappers.""" + kinds: dict[str, dict[int, set[str]]] = defaultdict(lambda: defaultdict(set)) + calls = [] + for proc, flows in procs: + for position, target in flows: + if target[0] == "def": + kinds[proc][position].add(target[1]) + else: + calls.append((proc, position, target[1], target[2], target[3])) + changed = True + while changed: + changed = False + for proc, position, callee, fallback, callee_position in calls: + entry = kinds.get(callee) or kinds.get(fallback) + found = entry.get(callee_position) if entry else None + if found and not found <= kinds[proc][position]: + kinds[proc][position] |= found + changed = True + return { + proc: {position: frozenset(names) for position, names in positions.items() if names} + for proc, positions in kinds.items() + if any(positions.values()) + } + + +def _wrapper_kinds(table: WrapperTable, routine: str, position: int, namespace: str) -> frozenset[str]: + entry = table.get(qualify(routine, namespace)) or table.get(qualify(routine, ROOT_NAMESPACE)) + return entry.get(position, frozenset()) if entry else frozenset() + + +def _target_kinds(target: FlowTarget, table: WrapperTable, namespace: str) -> frozenset[str]: + if target[0] == "def": + return frozenset({target[1]}) + return _wrapper_kinds(table, target[1], target[2], namespace) + + +def _scope_commands(script: Node) -> Iterator[Command]: + """Commands of one scope, without the bodies of procs defined in it.""" + for child in getattr(script, "children", []): + if isinstance(child, Command): + yield child + if static_contents(child.routine) == "proc": + continue + yield from _scope_commands(child) + + +def _contains(node: Node, point: tuple[int, int]) -> bool: + return node.pos is not None and node.end_pos is not None and node.pos <= point < node.end_pos + + +def _path_at(tree: Node, point: tuple[int, int]) -> list[Node]: + path = [tree] + while True: + child = next((child for child in getattr(path[-1], "children", []) if _contains(child, point)), None) + if child is None: + return path + path.append(child) + + +def _literal_at(node: Node, point: tuple[int, int]) -> tuple[str, lsp.Range] | None: + """The single name ``node`` holds, or the list element of a braced word at ``point``.""" + if isinstance(node, BracedWord): + contents = static_contents(node) + if contents is None or node.contents_pos is None: + return None + line, column = node.contents_pos + for match in _LIST_WORD_RE.finditer(contents): + start = match.start(1) if match.group(1) is not None else match.start(2) + name = match.group(1) if match.group(1) is not None else match.group(2) + before = contents[:start] + element_line = line + before.count("\n") + element_column = (start - before.rfind("\n") if "\n" in before else column + start) + if element_line == point[0] and element_column <= point[1] < element_column + len(name): + return name, _range(element_line, element_column, name) + return None + name = def_name(node) + if name is None: + return None + line, column = node.contents_pos + return name, _range(line, column, name) + + +def _range(line: int, column: int, name: str) -> lsp.Range: + return lsp.Range( + start=lsp.Position(line=line - 1, character=column - 1), + end=lsp.Position(line=line - 1, character=column - 1 + len(name)), + ) + + +def _literal_targets(commands: list[Command], values: list[Node]) -> tuple[list[str], list[FlowTarget]]: + """Variables and proc arguments a literal flows into; ``values`` are its enclosing words.""" + command, value = commands[-1], values[-1] + routine = static_contents(command.routine) + args = list(command.args) + position = next((index for index, argument in enumerate(args) if argument is value), None) + if position is None: + return [], [] + if routine in {"list", "concat"} and len(commands) >= 2 and isinstance(values[-2], CommandSub): + return _literal_targets(commands[:-1], values[:-1]) + if routine == "set" and position == 1: + destination = static_contents(args[0]) + return ([destination] if destination else []), [] + if routine == "lappend" and position >= 1: + destination = static_contents(args[0]) + return ([destination] if destination else []), [] + if routine in {"foreach", "lmap"} and position % 2 == 1 and position < len(args) - 1: + return _bound_names(args[position - 1]), [] + if routine and routine not in _NON_FORWARDING: + return [], [("call", routine, position)] + return [], [] + + +def derived_def_symbol( + tree: Node, position: lsp.Position, table: WrapperTable +) -> tuple[frozenset[str], str, lsp.Range] | None: + """Kinds, name and range of a literal that reaches a .def argument indirectly.""" + point = (position.line + 1, position.character + 1) + path = _path_at(tree, point) + commands: list[Command] = [] + values: list[Node] = [] + scope: Node = tree + namespace = ROOT_NAMESPACE + for parent, child in zip(path, path[1:]): + if isinstance(parent, Command): + commands.append(parent) + values.append(child) + if static_contents(parent.routine) == "proc" and len(parent.args) >= 3 and child is parent.args[2]: + scope = child + name = qualify(static_contents(parent.args[0]) or "", ROOT_NAMESPACE) + namespace = name.rsplit("::", 1)[0] or ROOT_NAMESPACE + if not commands or isinstance(values[-1], Script): + return None + literal = _literal_at(values[-1], point) + if literal is None: + return None + variables, targets = _literal_targets(commands, values) + if variables: + scope_targets = solve_flow(fact for command in _scope_commands(scope) for fact in command_flow_facts(command)) + targets.extend(target for variable in variables for target in scope_targets.get(variable, ())) + kinds = frozenset(kind for target in targets for kind in _target_kinds(target, table, namespace)) + return (kinds, *literal) if kinds else None diff --git a/server/src/tools/navigation.py b/server/src/tools/navigation.py index 09a4758..2b29f63 100644 --- a/server/src/tools/navigation.py +++ b/server/src/tools/navigation.py @@ -5,6 +5,16 @@ from pathlib import Path import lsprotocol.types as lsp from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub +from tools.def_flow import ( # noqa: F401 (re-exported) + DEF_ADDRESS, + DEF_BLOCK_TEMPLATE, + DEF_SYMBOL_KINDS, + ProcDefFlows, + command_flow_facts, + def_argument_kinds, + proc_def_flows, +) +from tools.def_flow import def_name as _def_name from tools.variable_names import array_key_parts, variable_name ROOT_NAMESPACE = "::" @@ -39,6 +49,8 @@ class FileSymbolIndex: uri: str occurrences: tuple[SymbolOccurrence, ...] document_range: lsp.Range | None = None + # Procs whose parameters reach .def arguments: (qualified proc name, flows). + def_flows: tuple[tuple[str, ProcDefFlows], ...] = () @dataclass(frozen=True) @@ -114,49 +126,6 @@ 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 @@ -311,6 +280,9 @@ def build_file_symbol_index( # Most occurrences repeat a few identities; sharing one object per identity # keeps the index (and its persistent cache) small. identities: dict[SymbolIdentity, SymbolIdentity] = {} + def_flows: list[tuple[str, ProcDefFlows]] = [] + # Flow facts of the procs being walked, innermost last. + flow_facts: list[list] = [] def shared(identity: SymbolIdentity | None) -> SymbolIdentity | None: return None if identity is None else identities.setdefault(identity, identity) @@ -427,11 +399,13 @@ def build_file_symbol_index( ) parameters = command.args[1] + parameter_names = [] for parameter in getattr(parameters, "children", []): parameter_node = parameter if isinstance(parameter, List) and parameter.children: parameter_node = parameter.children[0] parameter_name = _static_contents(parameter_node) + parameter_names.append(parameter_name or "") if parameter_name: add_variable( parameter_node, @@ -440,7 +414,12 @@ def build_file_symbol_index( is_definition=True, ) + flow_facts.append([]) walk_script(body, proc_scope) + facts = flow_facts.pop() + flows = proc_def_flows(facts, parameter_names, proc_namespace) if facts else () + if flows: + def_flows.append((proc_identity.name, flows)) def walk_namespace(command: Command, scope: _Scope) -> bool: if len(command.args) < 3 or _static_contents(command.args[0]) != "eval": @@ -478,6 +457,8 @@ def build_file_symbol_index( if routine: add_proc(command.routine, routine, scope, is_definition=False) + if flow_facts: + flow_facts[-1].extend(command_flow_facts(command)) for node, kind in def_argument_kinds(command): name = _def_name(node) @@ -550,6 +531,7 @@ def build_file_symbol_index( uri=uri, occurrences=tuple(occurrences), document_range=_node_range(tree), + def_flows=tuple(def_flows), ) diff --git a/server/tests/python_tests/test_def_flow.py b/server/tests/python_tests/test_def_flow.py new file mode 100644 index 0000000..891dc50 --- /dev/null +++ b/server/tests/python_tests/test_def_flow.py @@ -0,0 +1,182 @@ +"""Block templates and addresses reaching NX commands through variables and procs.""" + +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_flow import build_wrapper_table + +PSC = """ + + + + + + + +""" + +DEF = """MACHINE Default + +FORMATTING +{ + ADDRESS SPOS + { + FORMAT Coordinate + } + BLOCK_TEMPLATE steady_rest + { + Text[M60] + } + BLOCK_TEMPLATE absolute_mode + { + Text[G90] + } +} +""" + +LIBRARY = """proc LIB_call_cycle {cycle {prefix ""}} { + set block $cycle ; regsub -all "," $block "_" block + if {[catch {set line [MOM_do_template $block CREATE]} err]} { + return + } +} +proc LIB_outer {mode name} { + LIB_call_cycle $name +} +proc LIB_force {address} { + MOM_force Once $address +} +proc LIB_log {message} { + puts $message +} +""" + +CALLER = """LIB_call_cycle "absolute_mode" +LIB_outer on steady_rest +LIB_force SPOS +LIB_log steady_rest +puts steady_rest +proc local {} { + set t "steady_rest" + MOM_do_template $t + set unused absolute_mode + foreach b {"steady_rest" absolute_mode} { MOM_do_template $b } + set l [list "absolute_mode"] + lappend l steady_rest + foreach x $l { LIB_call_cycle $x } +} +""" + + +def _project(tmp_path: Path, monkeypatch): + (tmp_path / "service").mkdir() + (tmp_path / "post.psc").write_text(PSC, encoding="utf-8") + (tmp_path / "service" / "service.def").write_text(DEF, encoding="utf-8") + library = tmp_path / "library.tcl" + library.write_text(LIBRARY, encoding="utf-8") + caller = tmp_path / "caller.tcl" + caller.write_text(CALLER, encoding="utf-8") + + server = TclLanguageServer(name="def-flow-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]) + for path, text in ((library, LIBRARY), (caller, CALLER)): + server.workspace.put_text_document(lsp.TextDocumentItem(uri=path.as_uri(), language_id="tcl", version=1, text=text)) + server.update_poco_completion_for_file(server.workspace.get_text_document(path.as_uri())) + return server, caller + + +def _position(needle: str, occurrence: int = 0) -> lsp.Position: + index = -1 + for _ in range(occurrence + 1): + index = CALLER.index(needle, index + 1) + line = CALLER.count("\n", 0, index) + return lsp.Position(line=line, character=index - (CALLER.rfind("\n", 0, index) + 1) + 1) + + +def _hover(caller: Path, needle: str, occurrence: int = 0): + return lsp_server.hover( + lsp.HoverParams(text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position(needle, occurrence)) + ) + + +def _definition(caller: Path, needle: str, occurrence: int = 0): + return lsp_server.goto_definition( + lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position(needle, occurrence)) + ) + + +def _hover_title(hover) -> str | None: + return hover and hover.contents.value.split("\n", 1)[0] + + +def test_wrapper_table_follows_parameters_through_nested_procs(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + table = server.def_wrapper_table() + assert table["::LIB_call_cycle"] == {0: frozenset({"block_template"})} + assert table["::LIB_outer"] == {1: frozenset({"block_template"})} + assert table["::LIB_force"] == {0: frozenset({"address"})} + assert "::LIB_log" not in table + + +def test_build_wrapper_table_stops_on_recursion(): + flows = [ + ("::a", ((0, ("call", "::b", "::b", 0)),)), + ("::b", ((0, ("call", "::a", "::a", 0)), (0, ("def", "address")))), + ] + assert build_wrapper_table(flows) == {"::a": {0: frozenset({"address"})}, "::b": {0: frozenset({"address"})}} + + +def test_literal_argument_of_wrapper_proc_is_a_template(tmp_path, monkeypatch): + _, caller = _project(tmp_path, monkeypatch) + hover = _hover(caller, "absolute_mode") + assert _hover_title(hover).startswith("**Block template** `absolute_mode`") + assert (hover.range.start.line, hover.range.start.character, hover.range.end.character) == (0, 16, 29) + [location] = _definition(caller, "absolute_mode") + assert Path(location.uri).name == "service.def" + assert location.range.start.line == 12 + + +def test_nested_wrapper_and_address_wrapper(tmp_path, monkeypatch): + _, caller = _project(tmp_path, monkeypatch) + assert _hover_title(_hover(caller, "steady_rest")).startswith("**Block template** `steady_rest`") + assert _hover_title(_hover(caller, "SPOS")).startswith("**Address** `SPOS`") + + +def test_same_name_without_flow_is_not_a_template(tmp_path, monkeypatch): + _, caller = _project(tmp_path, monkeypatch) + for needle, occurrence in (("steady_rest", 1), ("steady_rest", 2), ("absolute_mode", 1)): + assert _hover(caller, needle, occurrence) is None, (needle, occurrence) + assert _definition(caller, needle, occurrence) is None, (needle, occurrence) + + +def test_literals_flowing_through_local_variables(tmp_path, monkeypatch): + _, caller = _project(tmp_path, monkeypatch) + cases = ( + ("steady_rest", 3), # set t "steady_rest"; MOM_do_template $t + ("steady_rest", 4), # foreach b {"steady_rest" ...} + ("absolute_mode", 2), # foreach b {... absolute_mode} + ("absolute_mode", 3), # set l [list "absolute_mode"]; foreach x $l { LIB_call_cycle $x } + ("steady_rest", 5), # lappend l steady_rest + ) + for needle, occurrence in cases: + assert _hover_title(_hover(caller, needle, occurrence)).startswith(f"**Block template** `{needle}`"), occurrence + assert _definition(caller, needle, occurrence), (needle, occurrence) + + +def test_derived_names_are_not_renamed(tmp_path, monkeypatch): + _, caller = _project(tmp_path, monkeypatch) + params = lsp.PrepareRenameParams( + text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position("absolute_mode") + ) + assert lsp_server.prepare_rename(params) is None From 3f9091c71382b587c3ced769482ec6fe5abd0f7b Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Fri, 25 Sep 2026 08:15:30 +0200 Subject: [PATCH 4/4] ci: add test workflow to run Python and Node jobs Add a Gitea workflow that runs on pull requests and pushes to main. It defines two jobs: - Python: sets up Python 3.12, installs pytest, and runs language-server tests under server/tests/python_tests with PYTHONPATH=libs. - Node: sets up Node 20, installs npm deps (root and client), runs extension tests (node --test test/) and builds the extension (npm run package). Workflow file added at .gitea/workflows/tests.yml. --- .gitea/workflows/tests.yml | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .gitea/workflows/tests.yml diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..518dc82 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -0,0 +1,39 @@ +name: Tests +on: + pull_request: + push: + branches: [main] +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install pytest + run: pip install pytest + - name: Run language server tests + working-directory: server + # The server's dependencies are bundled in server/libs. + env: + PYTHONPATH: libs + run: python -m pytest tests/python_tests -q + node: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install NodeJS + uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install NPM Packages + run: | + npm ci + cd ./client + npm ci + - name: Run extension tests + run: node --test test/ + - name: Build extension + run: npm run package