feat(lsp): add context-aware completion ranking and document highlights

Adds context-aware completion ranking and document highlights for Tcl.
Adds completion_context to distinguish variables and commands.
Wires per-file completion snapshots and ranking into the flow.
Adds document_highlight provider support and tests for highlights.

- Context-aware ranking of completion items using per-file snapshots
- Document highlight provider wired into initialization and tests
- Tests for completion context, ranking, and document highlights
This commit is contained in:
Christoph Brandau
2026-09-03 09:19:50 +02:00
parent 41d117fe2c
commit 89add18273
8 changed files with 421 additions and 44 deletions
+66 -41
View File
@@ -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,
+9
View File
@@ -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:
+98 -3
View File
@@ -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"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
_COMMAND_PREFIX_RE = re.compile(r"(?:^|[;\[\{])\s*[^\s;\[\]\{\}]*$")
def _codepoint_offset(line: str, utf16_offset: int) -> 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:
+31
View File
@@ -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]: