From da9091fa38b65a7cbbea816c936b6c279ba0bc38 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 24 Sep 2026 13:57:19 +0200 Subject: [PATCH] feat: complete BLOCK_LIST/ADDR_LIST keywords and space comments Add keyword-driven completions for BLOCK_LIST and ADDR_LIST in the LSP server. - While typing a prefix of these keywords the server returns the keyword(s) as incomplete snippet suggestions that trigger a re-request. - Once the keyword is typed the server replaces it with a full list of the corresponding loaded block templates or addresses (quoted), and sets each item's filter_text to include the keyword so further typing narrows results. - Integrate this flow into on_completion and factor the symbol-list logic into a helper that returns either incomplete keyword suggestions or the completed symbol list. Also implement NxFormatter.format_comment to ensure a single space after a leading '#' for comments that don't already start with whitespace, while leaving sequences of '#' (separators), shebangs, and already-spaced comments unchanged. This affects both standalone and inline comments. Add/rename tests to cover the new completions and comment-spacing behavior. --- server/src/lsp_server.py | 65 +++++++++++++++++++ server/src/tools/formatter.py | 8 +++ server/tests/python_tests/test_def_symbols.py | 60 +++++++++++++++++ ...st_format_uplevel.py => test_formatter.py} | 27 +++++++- 4 files changed, 159 insertions(+), 1 deletion(-) rename server/tests/python_tests/{test_format_uplevel.py => test_formatter.py} (65%) diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index a321e08..08c1cbc 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -85,6 +85,7 @@ from tools.tcl_command_completion import ( TCL_COMMAND_ITEMS, TCL_COMMAND_NAMES, DynamicCompletionKind, + line_prefix_at_position, path_completion_items, tcl_argument_completion, ) @@ -279,6 +280,66 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","]), ) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: + result = _on_completion(params) + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + symbol_list = _symbol_list_completion(LSP_SERVER.get_lines(doc), params.position) + if symbol_list is not None and symbol_list.is_incomplete: + # Re-request while typing so the full list appears at BLOCK_LIST/ADDR_LIST. + result.items = [*symbol_list.items, *result.items] + result.is_incomplete = True + return result + + +# Keywords that expand to all loaded .def names: keyword -> (items, description). +SYMBOL_LIST_KEYWORDS = { + "BLOCK_LIST": (lambda: LSP_SERVER.block_template_items(), "block templates"), + "ADDR_LIST": (lambda: LSP_SERVER.address_items(), "addresses"), +} +_WORD_BEFORE_CURSOR_RE = re.compile(r"(? lsp.CompletionList | None: + """Complete BLOCK_LIST/ADDR_LIST to all loaded block templates/addresses. + + While the typed word is a prefix of a keyword, an incomplete list with the + keyword itself is returned. Once the keyword is typed, the complete list of + quoted names replaces it. + """ + line_prefix = line_prefix_at_position(source_lines, position) + match = _WORD_BEFORE_CURSOR_RE.search(line_prefix or "") + if match is None: + return None + word = match.group() + for keyword, (symbol_items, _) in SYMBOL_LIST_KEYWORDS.items(): + if not word.startswith(keyword): + continue + items = [] + ranked = ranked_completion_items(((0, item) for item in symbol_items()), CompletionContext.GENERAL) + for item in ranked: + item = _quoted_item(item, source_lines, position, word) + # Keep every name visible for the typed keyword; text after it narrows. + item.filter_text = f"{keyword}{item.label}" + items.append(item) + return lsp.CompletionList(is_incomplete=False, items=items) + + keywords = [ + lsp.CompletionItem( + label=keyword, + kind=lsp.CompletionItemKind.Snippet, + detail=f"Show all loaded {description}", + insert_text=keyword, + sort_text=f"000:{keyword.casefold()}", + command=lsp.Command(title=f"Show {description}", command="editor.action.triggerSuggest"), + ) + for keyword, (_, description) in SYMBOL_LIST_KEYWORDS.items() + if keyword.startswith(word) + ] + if keywords: + return lsp.CompletionList(is_incomplete=True, items=keywords) + return None + + +def _on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) position = params.position source_lines = LSP_SERVER.get_lines(doc) @@ -302,6 +363,10 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: return lsp.CompletionList(is_incomplete=False, items=array_items) context = completion_context(source_lines, position) + symbol_list = _symbol_list_completion(source_lines, position) + if symbol_list is not None and symbol_list.is_incomplete is False: + return symbol_list + # Variable completion wins inside command arguments. Otherwise prefer the # narrow command grammar when the cursor is at a known subcommand/option. argument_completion = None diff --git a/server/src/tools/formatter.py b/server/src/tools/formatter.py index f7b8984..e05a595 100644 --- a/server/src/tools/formatter.py +++ b/server/src/tools/formatter.py @@ -12,6 +12,14 @@ class NxFormatter(BaseFormatter): while leaving all other formatting behavior unchanged. """ + def format_comment(self, comment) -> List[str]: # type: ignore[override] + # "#Comment" -> "# Comment". Leave "#", "##..." separators, "#!" and + # comments that already start with whitespace untouched. + value = comment.value + if value and not value[0].isspace() and value[0] not in "#!": + value = " " + value + return [f"#{value}"] + def format_braced_expression(self, expr) -> List[str]: # type: ignore[override] # This method mirrors BaseFormatter.format_braced_expression but inserts # a line continuation (" \") between continuation lines similar to diff --git a/server/tests/python_tests/test_def_symbols.py b/server/tests/python_tests/test_def_symbols.py index b7afc86..6e703bb 100644 --- a/server/tests/python_tests/test_def_symbols.py +++ b/server/tests/python_tests/test_def_symbols.py @@ -198,3 +198,63 @@ def test_typed_quotes_are_replaced_not_doubled(tmp_path, monkeypatch): ) ).items assert _edit(items, "SPOS") == ('"SPOS"', (22, 26)) + + +def test_block_list_prefix_offers_keyword_and_stays_incomplete(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + uri = (tmp_path / "caller.tcl").as_uri() + source = "MOM_do_template BLOCK_L" + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source) + ) + result = lsp_server.on_completion( + lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=0, character=len(source)), + ) + ) + assert result.is_incomplete + keyword = result.items[0] + assert keyword.label == "BLOCK_LIST" + assert keyword.command.command == "editor.action.triggerSuggest" + + +def test_block_list_shows_all_templates_quoted(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + items = _complete(server, tmp_path, "set a 1\n BLOCK_LIST") + assert [item.label for item in items] == ["external_subprogram", "steady_rest"] + assert _edit(items, "steady_rest") == ('"steady_rest"', (4, 14)) + assert all(item.filter_text.startswith("BLOCK_LIST") for item in items) + + +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"): + labels = [item.label for item in _complete(server, tmp_path, source)] + assert "BLOCK_LIST" not in labels + + +def test_addr_list_shows_all_addresses_quoted(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + items = _complete(server, tmp_path, "MOM_force Once ADDR_LIST") + assert [item.label for item in items] == ["SPOS", "X"] + assert _edit(items, "SPOS") == ('"SPOS"', (15, 24)) + assert all(item.filter_text.startswith("ADDR_LIST") for item in items) + + +def test_addr_list_prefix_offers_keyword(tmp_path, monkeypatch): + server, _ = _project(tmp_path, monkeypatch) + uri = (tmp_path / "caller.tcl").as_uri() + source = "ADDR" + server.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source) + ) + result = lsp_server.on_completion( + lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=0, character=len(source)), + ) + ) + assert result.is_incomplete + assert result.items[0].label == "ADDR_LIST" + assert "BLOCK_LIST" not in {item.label for item in result.items} diff --git a/server/tests/python_tests/test_format_uplevel.py b/server/tests/python_tests/test_formatter.py similarity index 65% rename from server/tests/python_tests/test_format_uplevel.py rename to server/tests/python_tests/test_formatter.py index 3532b7c..9c45d5d 100644 --- a/server/tests/python_tests/test_format_uplevel.py +++ b/server/tests/python_tests/test_formatter.py @@ -1,4 +1,4 @@ -"""Formatting of uplevel bodies.""" +"""Formatting with NxFormatter.""" from tclint.format import FormatterOpts from tools.formatter import NxFormatter @@ -31,3 +31,28 @@ def test_uplevel_without_level_and_with_variable_level(): assert _format(source) == ( "uplevel {\n\tset x 1\n}\nuplevel $lvl {\n\tset y 2\n}\nuplevel set z 3\n" ) + + +def test_comment_gets_space_after_hash(): + source = ( + "#Comment\n" + "# already spaced\n" + "#\tTabbed\n" + "#\n" + "##########\n" + "set x 1 ;#inline\n" + "proc a {} {\n" + "\t#nested\n" + "}\n" + ) + assert _format(source) == ( + "# Comment\n" + "# already spaced\n" + "#\tTabbed\n" + "#\n" + "##########\n" + "set x 1 ;# inline\n" + "proc a {} {\n" + "\t# nested\n" + "}\n" + ) -- 2.54.0