refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user