diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f9bf93..6f354d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased - Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers +- Add document highlights for procedure and variable occurrences +- Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols - Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support - Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files - Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops diff --git a/README.md b/README.md index be68a47..16c2d78 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ A comprehensive VS Code extension providing language support and remote debuggin - **Auto-completion** - Context-aware code completion for faster development - **Signature Help** - Shows parameters and documentation for custom and NX procedures - **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers +- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor +- **Context-aware Completion** - Prioritizes local symbols and suggests variables or commands based on cursor context - **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process ## Supported File Types @@ -98,6 +100,8 @@ Simply open any supported file type and enjoy: - Hover information - Signature help while entering procedure arguments - Incoming and outgoing call hierarchy for custom procedures and MOM event handlers +- Document-wide highlights for procedure and variable occurrences +- Context-aware completion with local symbols ranked before workspace and built-in symbols - Remote NX Tcl debugging with breakpoints and full stepping ## Contributing diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 5179ead..5693410 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -44,6 +44,7 @@ from common.load_data import standard_items from lsp_tclserver import TclLanguageServer from pygls import uris from pygls.workspace.text_document import TextDocument +from tools.completion_items import completion_context, ranked_completion_items from tools.folding_ranges import build_folding_ranges from tools.inlay_hint import ( InlayHintGenerator, @@ -54,6 +55,7 @@ from tools.navigation import ( call_hierarchy_identity, call_hierarchy_items, definition_identities, + document_highlights, incoming_call_hierarchy, matching_occurrences, outgoing_call_hierarchy, @@ -87,9 +89,9 @@ STATIC_COMPLETION_ITEMS = tuple( + standard_items.nx_procs + standard_items.nx_variables ) -STATIC_COMPLETION_KEYS = frozenset( - (item.label, getattr(item, "kind", None)) for item in STATIC_COMPLETION_ITEMS -) +STATIC_VARIABLE_ITEMS = { + item.label: item for item in standard_items.nx_variables +} BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures( standard_items.json_data.get("MOM_procs", []) ) @@ -246,55 +248,63 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): return lsp.FullDocumentDiagnosticReport(items=diagnostics, result_id=result_id) -@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) +@LSP_SERVER.feature( + lsp.TEXT_DOCUMENT_COMPLETION, + lsp.CompletionOptions(trigger_characters=["$"]), +) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: - from tools.completion_items import BUILTIN_VAR_LABELS - doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - - workspace_items = LSP_SERVER.completion_items_snapshot() tree = LSP_SERVER.get_tree(doc) globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document( doc, tree ) - # Always include globals (excluding built-ins) - dynamic_items = [] - for name in sorted(globals_set): - if name not in BUILTIN_VAR_LABELS: - dynamic_items.append( - lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable) + position = params.position + local_names: set[str] = set() + for proc_range in proc_ranges: + end_line = proc_range.end_line or proc_range.start_line + if proc_range.start_line <= position.line <= end_line: + local_names.update(procs_locals.get(proc_range.name, set())) + break + + candidates: list[tuple[int, lsp.CompletionItem]] = [] + for name in sorted(local_names - globals_set): + candidates.append( + ( + 0, + lsp.CompletionItem( + label=name, + kind=lsp.CompletionItemKind.Variable, + detail="Local variable", + ), ) + ) - # Include proc-local variables when cursor is inside that proc - pos = params.position - if pos is not None: - for pr in proc_ranges: - if pr.start_line <= pos.line <= (pr.end_line or pr.start_line): - for name in sorted(procs_locals.get(pr.name, set())): - # Exclude built-ins and globals to avoid duplication - if name not in BUILTIN_VAR_LABELS and name not in globals_set: - dynamic_items.append( - lsp.CompletionItem( - label=name, kind=lsp.CompletionItemKind.Variable - ) - ) - break + for name in sorted(globals_set): + item = STATIC_VARIABLE_ITEMS.get(name) + if item is None: + item = lsp.CompletionItem( + label=name, + kind=lsp.CompletionItemKind.Variable, + detail="Workspace variable", + ) + candidates.append( + ( + 100, + item, + ) + ) - # Merge with de-duplication. Each file keeps its complete index, so a proc - # declared in multiple files must only appear once in the completion list. - merged: list[lsp.CompletionItem] = list(STATIC_COMPLETION_ITEMS) - seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set( - STATIC_COMPLETION_KEYS - ) - for it in (*workspace_items, *dynamic_items): - key = (it.label, getattr(it, "kind", None)) - if key in seen_items: - continue - seen_items.add(key) - merged.append(it) + filepath = str(pathlib.Path(uris.to_fs_path(doc.uri))) + items_by_file = LSP_SERVER.completion_items_by_file_snapshot() + for item_path in sorted(items_by_file, key=lambda path: path.casefold()): + priority = 100 if LSP_SERVER.paths_equal(item_path, filepath) else 200 + candidates.extend((priority, item) for item in items_by_file[item_path]) - return lsp.CompletionList(is_incomplete=False, items=merged) + candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS) + context = completion_context(LSP_SERVER.get_lines(doc), position) + items = ranked_completion_items(candidates, context) + return lsp.CompletionList(is_incomplete=False, items=items) @LSP_SERVER.feature( @@ -548,6 +558,20 @@ def references(params: lsp.ReferenceParams) -> list[lsp.Location]: return _sorted_locations(locations) +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT) +def document_highlight(params: lsp.DocumentHighlightParams): + context = _navigation_context(params.text_document.uri, params.position) + if context is None: + return [] + + indexes, definitions, _, identity = context + filepath = str(pathlib.Path(uris.to_fs_path(params.text_document.uri))) + index = indexes.get(filepath) + if index is None: + return [] + return document_highlights(index, identity, definitions) + + def _is_renamable( identity: SymbolIdentity, indexes, @@ -744,6 +768,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: legend=semantic_tokens_legend, full=True, range=False ), definition_provider=True, + document_highlight_provider=True, references_provider=True, rename_provider=lsp.RenameOptions(prepare_provider=True), workspace_symbol_provider=True, diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 544bc0a..165e8dc 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -170,6 +170,15 @@ class TclLanguageServer(LanguageServer): self._workspace_completion_cache = (self._index_generation, items) return items + def completion_items_by_file_snapshot( + self, + ) -> dict[str, tuple[lsp.CompletionItem, ...]]: + """Return completion items grouped by source file for request ranking.""" + with self._index_lock: + return { + path: tuple(items) for path, items in self.poco_completion.items() + } + def custom_function_names_snapshot(self) -> frozenset[str]: """Return custom completion labels for semantic highlighting.""" with self._index_lock: diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index e4f3839..3d5fd96 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -1,3 +1,10 @@ +from __future__ import annotations + +import copy +import re +from collections.abc import Iterable, Sequence +from enum import Enum + import lsprotocol.types as lsp from common.load_data import standard_items from tclint.syntax_tree import BareWord, Command, List, Visitor @@ -6,6 +13,86 @@ BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables} BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs} +class CompletionContext(Enum): + VARIABLE = "variable" + COMMAND = "command" + GENERAL = "general" + + +VARIABLE_KINDS = { + lsp.CompletionItemKind.Variable, + lsp.CompletionItemKind.Field, + lsp.CompletionItemKind.Constant, +} +COMMAND_KINDS = { + lsp.CompletionItemKind.Function, + lsp.CompletionItemKind.Method, + lsp.CompletionItemKind.Constructor, + lsp.CompletionItemKind.Keyword, +} + +_VARIABLE_PREFIX_RE = re.compile(r"(? int: + """Translate an LSP UTF-16 character offset into a Python string offset.""" + if utf16_offset <= 0: + return 0 + + units = 0 + for offset, character in enumerate(line): + units += 2 if ord(character) > 0xFFFF else 1 + if units >= utf16_offset: + return offset + 1 + return len(line) + + +def completion_context( + source_lines: Sequence[str], position: lsp.Position +) -> CompletionContext: + if position.line < 0 or position.line >= len(source_lines): + return CompletionContext.GENERAL + + line = source_lines[position.line] + prefix = line[: _codepoint_offset(line, position.character)] + if _VARIABLE_PREFIX_RE.search(prefix): + return CompletionContext.VARIABLE + if _COMMAND_PREFIX_RE.search(prefix): + return CompletionContext.COMMAND + return CompletionContext.GENERAL + + +def _is_allowed(item: lsp.CompletionItem, context: CompletionContext) -> bool: + if context == CompletionContext.VARIABLE: + return item.kind in VARIABLE_KINDS + if context == CompletionContext.COMMAND: + return item.kind in COMMAND_KINDS + return True + + +def ranked_completion_items( + candidates: Iterable[tuple[int, lsp.CompletionItem]], + context: CompletionContext, +) -> list[lsp.CompletionItem]: + """Filter, de-duplicate, and rank completion candidates for one request.""" + items = [] + seen: set[tuple[str, lsp.CompletionItemKind | None]] = set() + for sequence, (priority, item) in enumerate(candidates): + if not _is_allowed(item, context): + continue + + key = (item.label, item.kind) + if key in seen: + continue + seen.add(key) + + ranked = copy.copy(item) + ranked.sort_text = f"{priority:03d}:{item.label.casefold()}:{sequence:06d}" + items.append(ranked) + return items + + class CompletionItems: def __init__(self): self._custom_functions: list[lsp.CompletionItem] = [] @@ -77,9 +164,17 @@ class CompletionCollector(Visitor): # Collect global variables declared with: global var1 var2 ... elif routine.contents == "global" and command.args: for arg in command.args: - if isinstance(arg, BareWord) and getattr(arg, "value", None): - if arg.value not in BUILTIN_VAR_LABELS: - self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable)) + if ( + isinstance(arg, BareWord) + and getattr(arg, "value", None) + and arg.value not in BUILTIN_VAR_LABELS + ): + self._append_unique( + lsp.CompletionItem( + label=arg.value, + kind=lsp.CompletionItemKind.Variable, + ) + ) # Collect variables set with explicit global namespace: set ::var_name ... elif routine.contents == "set" and command.args: diff --git a/server/src/tools/navigation.py b/server/src/tools/navigation.py index 8c07148..462e8ac 100644 --- a/server/src/tools/navigation.py +++ b/server/src/tools/navigation.py @@ -522,6 +522,37 @@ def matching_occurrences( return matches +def document_highlights( + index: FileSymbolIndex, + identity: SymbolIdentity, + definitions: set[SymbolIdentity], +) -> list[lsp.DocumentHighlight]: + """Return all occurrences of one symbol in the active document.""" + highlights = [] + for occurrence in index.occurrences: + if resolve_identity(occurrence, definitions) != identity: + continue + + kind = lsp.DocumentHighlightKind.Text + if identity.kind == "variable": + kind = ( + lsp.DocumentHighlightKind.Write + if occurrence.is_definition + else lsp.DocumentHighlightKind.Read + ) + highlights.append(lsp.DocumentHighlight(range=occurrence.range, kind=kind)) + + return sorted( + highlights, + key=lambda highlight: ( + highlight.range.start.line, + highlight.range.start.character, + highlight.range.end.line, + highlight.range.end.character, + ), + ) + + def workspace_symbols( indexes: dict[str, FileSymbolIndex], query: str ) -> list[lsp.SymbolInformation]: diff --git a/server/tests/python_tests/test_completion_context.py b/server/tests/python_tests/test_completion_context.py new file mode 100644 index 0000000..3148ae7 --- /dev/null +++ b/server/tests/python_tests/test_completion_context.py @@ -0,0 +1,152 @@ +import sys +from pathlib import Path + +THIS_DIR = Path(__file__).parent +SRC_DIR = THIS_DIR.parent.parent / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +import lsp_server +import lsprotocol.types as lsp # type: ignore +from common.load_data import standard_items +from lsp_tclserver import TclLanguageServer +from pygls.workspace import Workspace +from pygls.workspace.text_document import TextDocument +from tools.completion_items import ( + COMMAND_KINDS, + VARIABLE_KINDS, + CompletionContext, + completion_context, +) + + +def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position: + offset = -1 + for _ in range(occurrence + 1): + offset = source.index(token, offset + 1) + offset += len(token) + before = source[:offset] + return lsp.Position( + line=before.count("\n"), + character=offset - (before.rfind("\n") + 1), + ) + + +def _document(path: Path, source: str) -> TextDocument: + return TextDocument( + uri=path.as_uri(), + source=source, + version=1, + language_id="tcl", + ) + + +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" + ) + workspace_source = """set ::workspaceValue 1 +proc workspaceProc {} { return } +""" + current = _document(tmp_path / "current.tcl", current_source) + workspace = _document(tmp_path / "workspace.tcl", workspace_source) + server = TclLanguageServer(name="completion-test", version="1", max_workers=1) + server.protocol._workspace = Workspace( # pylint: disable=protected-access + root_uri=None, + sync_kind=lsp.TextDocumentSyncKind.Incremental, + workspace_folders=[], + position_encoding=lsp.PositionEncodingKind.Utf16, + ) + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=1, + text=current_source, + ) + ) + assert server.update_poco_completion_for_file(current) + assert server.update_poco_completion_for_file(workspace) + monkeypatch.setattr(lsp_server, "LSP_SERVER", server) + return server, current, current_source + + +def _complete(document: TextDocument, position: lsp.Position): + return lsp_server.on_completion( + lsp.CompletionParams( + text_document=lsp.TextDocumentIdentifier(uri=document.uri), + position=position, + ) + ).items + + +def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch): + _, current, source = _completion_server(tmp_path, monkeypatch) + items = _complete(current, _position_after(source, "$local")) + by_label = {item.label: item for item in items} + + assert items + assert all(item.kind in VARIABLE_KINDS for item in items) + assert "localValue" in by_label + assert "globalValue" in by_label + assert "workspaceValue" in by_label + assert "localProc" not in by_label + assert "workspaceProc" not in by_label + assert "puts" not in by_label + + declared_builtin = standard_items.nx_variables[0] + 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["localValue"].sort_text.startswith("000:") + assert by_label["globalValue"].sort_text.startswith("100:") + assert by_label["workspaceValue"].sort_text.startswith("200:") + assert by_label[declared_builtin.label].sort_text.startswith("100:") + assert by_label[other_builtin.label].sort_text.startswith("300:") + + +def test_command_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch): + _, current, source = _completion_server(tmp_path, monkeypatch) + items = _complete(current, _position_after(source, "localP", occurrence=1)) + by_label = {item.label: item for item in items} + + assert items + assert all(item.kind in COMMAND_KINDS for item in items) + assert "localProc" in by_label + assert "workspaceProc" in by_label + assert "MOM_abort" in by_label + assert "puts" in by_label + assert "localValue" not in by_label + assert "globalValue" not in by_label + assert "workspaceValue" not in by_label + assert by_label["localProc"].sort_text.startswith("100:") + assert by_label["workspaceProc"].sort_text.startswith("200:") + assert by_label["MOM_abort"].sort_text.startswith("300:") + + +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 + ) diff --git a/server/tests/python_tests/test_navigation.py b/server/tests/python_tests/test_navigation.py index e696c85..69d41b7 100644 --- a/server/tests/python_tests/test_navigation.py +++ b/server/tests/python_tests/test_navigation.py @@ -401,3 +401,62 @@ def test_call_hierarchy_item_uses_whole_proc_range(tmp_path: Path): assert items[0].selection_range.start == lsp.Position(line=0, character=5) assert items[0].range.start == lsp.Position(line=0, character=0) assert items[0].range.end.line == 2 + + +def test_document_highlight_marks_local_reads_and_writes(tmp_path: Path, monkeypatch): + source = """proc first {} { + set value 1 + puts $value + incr value +} +proc second {} { + set value 2 + puts $value +} +""" + document = _document(tmp_path / "highlights.tcl", source) + server = TclLanguageServer(name="highlight-test", version="1", max_workers=1) + assert server.update_poco_completion_for_file(document) + monkeypatch.setattr(lsp_server, "LSP_SERVER", server) + position = _position(source, "$value") + + highlights = lsp_server.document_highlight( + lsp.DocumentHighlightParams( + text_document=lsp.TextDocumentIdentifier(uri=document.uri), + position=lsp.Position(position.line, position.character + 1), + ) + ) + + assert len(highlights) == 3 + assert [highlight.kind for highlight in highlights] == [ + lsp.DocumentHighlightKind.Write, + lsp.DocumentHighlightKind.Read, + lsp.DocumentHighlightKind.Write, + ] + assert all(_range_text(source, highlight.range) == "value" for highlight in highlights) + + +def test_document_highlight_marks_proc_definition_and_calls(tmp_path: Path, monkeypatch): + source = """proc target {} { return } +proc caller {} { + target + target +} +""" + document = _document(tmp_path / "proc_highlights.tcl", source) + server = TclLanguageServer(name="highlight-test", version="1", max_workers=1) + assert server.update_poco_completion_for_file(document) + monkeypatch.setattr(lsp_server, "LSP_SERVER", server) + + highlights = lsp_server.document_highlight( + lsp.DocumentHighlightParams( + text_document=lsp.TextDocumentIdentifier(uri=document.uri), + position=_position(source, "target"), + ) + ) + + assert len(highlights) == 3 + assert all( + highlight.kind == lsp.DocumentHighlightKind.Text + for highlight in highlights + )