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
+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: