refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s

This change adds cached and incremental analysis for the LSP
server to improve responsiveness. The client now debounces
diagnostic updates to avoid excessive recomputation. The
server introduces per-document line caches and various
caches for completions, inlay hints, and metadata to
support faster, incremental updates.

- Debounce diagnostics on text changes to reduce noise.
- Add caches for completions, inlay hints, and metadata.
- Introduce incremental analysis with per-document line caches.
This commit is contained in:
Christoph Brandau
2026-08-19 13:41:53 +02:00
parent ecb50be2b8
commit af195a577b
11 changed files with 616 additions and 181 deletions
+9 -4
View File
@@ -1,8 +1,9 @@
from tclint.syntax_tree import Visitor, Command, BareWord, List
import lsprotocol.types as lsp
from common.load_data import standard_items
from tclint.syntax_tree import BareWord, Command, List, Visitor
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 CompletionItems:
@@ -22,6 +23,7 @@ class CompletionCollector(Visitor):
def __init__(self):
super().__init__()
self._custom_functions: list[lsp.CompletionItem] = []
self._custom_function_keys: set[tuple[str, lsp.CompletionItemKind | None]] = set()
self._proc_signatures = {}
@property
@@ -34,8 +36,11 @@ class CompletionCollector(Visitor):
def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan
if not any(ci.label == item.label for ci in self._custom_functions):
self._custom_functions.append(item)
key = (item.label, item.kind)
if key in self._custom_function_keys:
return
self._custom_function_keys.add(key)
self._custom_functions.append(item)
def visit_command(self, command: Command):
routine = command.routine
@@ -46,7 +51,7 @@ class CompletionCollector(Visitor):
if not getattr(first_arg, "value", None):
return
if any(item.label == first_arg.value for item in standard_items.nx_procs):
if first_arg.value in BUILTIN_PROC_LABELS:
return
# Record proc name as a completion item
+50 -14
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
import os
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
import lsprotocol.types as lsp
from tclint.syntax_tree import Command, VarSub, Visitor
from tools.navigation import FileSymbolIndex
@@ -30,21 +30,22 @@ def _normalized_path(path: str) -> str:
return os.path.normcase(os.path.abspath(path))
def _definition_location(
index: FileSymbolIndex | None, proc_name: str
) -> lsp.Location | None:
def _definition_locations(
index: FileSymbolIndex | None,
) -> dict[str, lsp.Location]:
if index is None:
return None
return {}
basename = proc_name.removeprefix("::").rsplit("::", 1)[-1]
for occurrence in reversed(index.occurrences):
locations = {}
for occurrence in index.occurrences:
if (
occurrence.is_definition
and occurrence.identity.kind == "proc"
and occurrence.placeholder == basename
):
return lsp.Location(uri=index.uri, range=occurrence.range)
return None
locations[occurrence.placeholder] = lsp.Location(
uri=index.uri, range=occurrence.range
)
return locations
def build_custom_inlay_signatures(
@@ -63,7 +64,7 @@ def build_custom_inlay_signatures(
result: dict[str, InlayHintSignature] = {}
for path in paths:
docs = docs_by_path.get(path, {})
index = indexes_by_path.get(path)
definition_locations = _definition_locations(indexes_by_path.get(path))
for proc_name, parameter_names in signatures_by_path[path].items():
parameters = tuple(
InlayHintParameter(
@@ -79,7 +80,9 @@ def build_custom_inlay_signatures(
parameters=parameters,
display_label=" ".join([proc_name, *parameter_names]),
documentation=docs.get(proc_name),
location=_definition_location(index, proc_name),
location=definition_locations.get(
proc_name.removeprefix("::").rsplit("::", 1)[-1]
),
)
return result
@@ -147,19 +150,52 @@ class InlayHintGenerator(Visitor):
def __init__(
self,
source: str,
proc_signatures: dict[str, InlayHintSignature],
proc_signatures: Mapping[str, InlayHintSignature],
*,
source_lines: Sequence[str] | None = None,
requested_range: lsp.Range | None = None,
parameter_names: str = "all",
suppress_when_argument_matches_name: bool = True,
):
self.source_lines = source.splitlines()
self.source_lines = (
source_lines if source_lines is not None else source.splitlines()
)
self.proc_signatures = proc_signatures
self.requested_range = requested_range
self.parameter_names = parameter_names
self.suppress_when_argument_matches_name = suppress_when_argument_matches_name
self.hints: list[lsp.InlayHint] = []
def _node_intersects_requested_range(self, node) -> bool:
if self.requested_range is None:
return True
start = getattr(node, "pos", None)
end = getattr(node, "end_pos", None)
if start is None or end is None:
return True
node_start_line = start[0] - 1
node_end_line = end[0] - 1
return not (
node_end_line < self.requested_range.start.line
or node_start_line > self.requested_range.end.line
)
def generate(self, tree) -> list[lsp.InlayHint]:
"""Walk only syntax-tree branches overlapping the requested editor range."""
self.hints.clear()
def walk(node) -> None:
if not self._node_intersects_requested_range(node):
return
if isinstance(node, Command):
self.visit_command(node)
for child in getattr(node, "children", []):
walk(child)
walk(tree)
return self.hints
def _position(self, line: int, column: int) -> lsp.Position:
line_index = line - 1
character_index = column - 1
+17 -41
View File
@@ -1,8 +1,9 @@
import re
from typing import Dict, List
from tclint.syntax_tree import Visitor, Command
from tools.parser import CustomParser
from tclint.syntax_tree import Command, Visitor
PROC_DECLARATION_RE = re.compile(r"^\s*proc\s+([^\s\{]+)")
def _strip_comment_prefix(line: str) -> str:
@@ -135,43 +136,18 @@ def build_proc_docs(tree, source_text: str) -> Dict[str, str]:
return extractor.docs
def is_proc_declaration_position(source_text: str, line_zero_based: int, char_zero_based: int) -> bool:
def is_proc_declaration_line(line: str, char_zero_based: int) -> bool:
"""Return True if the position is on the proc name on this source line."""
match = PROC_DECLARATION_RE.match(line)
return bool(match and match.start(1) <= char_zero_based <= match.end(1))
def is_proc_declaration_position(
source_text: str, line_zero_based: int, char_zero_based: int
) -> bool:
"""Return True if the position is on a proc name within its declaration."""
parser = CustomParser()
tree = parser.parse(source_text)
# Walk commands to find 'proc' declarations and check if position intersects the name arg
class _DeclFinder(Visitor):
def __init__(self):
self.is_decl = False
def visit_command(self, command: Command):
if self.is_decl:
return
routine = getattr(command.routine, "contents", None)
if routine != "proc" or not command.args:
return
name_node = command.args[0]
if not hasattr(name_node, "pos"):
return
# Calculate range for the name token
try:
start_line, start_col = name_node.pos
end_line, end_col = getattr(name_node, "end_pos", name_node.pos)
except Exception:
return
if start_line - 1 == line_zero_based:
length = 0
if hasattr(name_node, "value") and name_node.value is not None:
length = len(name_node.value)
elif hasattr(name_node, "contents") and name_node.contents is not None:
length = len(name_node.contents)
if length:
start_c = start_col - 1
end_c = start_c + length
if start_c <= char_zero_based <= end_c:
self.is_decl = True
finder = _DeclFinder()
tree.accept(finder, recurse=True)
return finder.is_decl
try:
line = source_text.splitlines()[line_zero_based]
except IndexError:
return False
return is_proc_declaration_line(line, char_zero_based)
+17 -9
View File
@@ -1,17 +1,17 @@
import enum
from typing import List
from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord
from tclint.commands.plugins import PluginManager
import attrs
from common.load_data import standard_items
import lsprotocol.types as lsp
from tclint.commands.plugins import PluginManager
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
# the builtin command set on every call. Semantic tokens are requested often, so
# cache the manager and the resolved commands per plugin set.
_PLUGIN_MANAGER = None
_COMMANDS_CACHE = {}
_STANDARD_PROC_NAMES = frozenset(item.label for item in standard_items.nx_procs)
def _load_commands(plugins):
@@ -60,13 +60,23 @@ TOKEN_TYPES = [
"string",
"parameter",
]
TOKEN_TYPE_INDEX = {}
for _token_index, _token_name in enumerate(TOKEN_TYPES):
TOKEN_TYPE_INDEX.setdefault(_token_name, _token_index)
class _Highlighter(Visitor):
def __init__(self, plugins, custom_functions: dict[str : list[lsp.CompletionItem]]):
def __init__(self, plugins, custom_functions):
self._commands = _load_commands(plugins)
self._tokens = []
self.custom_functions = custom_functions
if isinstance(custom_functions, dict):
self._custom_function_names = frozenset(
item.label
for items in custom_functions.values()
for item in items
)
else:
self._custom_function_names = frozenset(custom_functions)
def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None):
if position is None or length <= 0:
@@ -129,9 +139,7 @@ class _Highlighter(Visitor):
# Highlight functions (custom or standard) when used as the routine
name = getattr(routine, "contents", None)
if name:
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items)
in_standard = any(item.label == name for item in standard_items.nx_procs)
if in_custom or in_standard:
if name in self._custom_function_names or name in _STANDARD_PROC_NAMES:
line, col = routine.contents_pos
self._append_token((line - 1, col - 1), len(name), "function", [])