Add a new module that parses PostConfigurator COMMANDBLOCK values (CONF_* set ...) to extract the first word of braced list elements as procedure names with precise line/column spans. - server/src/tools/stored_procs.py: implement stored_command_names(command) which returns (name, line, column) for static/braced list elements. - Integrate into navigation (build_file_symbol_index) to index these names as non-definitions so Go To Definition / Find References can resolve them. - Integrate into semantic highlighting to mark known stored procedures as functions when appropriate. - Add tests (server/tests/python_tests/test_stored_procs.py) covering parsing, goto-definition, references, and highlighting behavior. - Update CHANGELOG to note the new capability. Notes/constraints: - Only static/braced COMMANDBLOCK values (BracedWord) are considered. - Names must match the command-name pattern and are taken from the first word of each list element.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
import enum
|
||
from typing import List
|
||
|
||
import attrs
|
||
from common.load_data import standard_items
|
||
from tclint.commands.plugins import PluginManager
|
||
from tclint.syntax_tree import BareWord, BracedWord, Command, QuotedWord, Visitor
|
||
from tools.stored_procs import stored_command_names
|
||
from tools.variable_names import variable_name
|
||
from tools.tcloo_symbols import class_symbols
|
||
from tools.tcloo_completion import _analyze
|
||
|
||
# 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):
|
||
global _PLUGIN_MANAGER
|
||
if _PLUGIN_MANAGER is None:
|
||
_PLUGIN_MANAGER = PluginManager()
|
||
key = tuple(plugins)
|
||
if key not in _COMMANDS_CACHE:
|
||
_COMMANDS_CACHE[key] = _PLUGIN_MANAGER.get_commands(list(plugins))
|
||
return _COMMANDS_CACHE[key]
|
||
|
||
|
||
class TokenModifier(enum.IntFlag):
|
||
deprecated = enum.auto()
|
||
readonly = enum.auto()
|
||
defaultLibrary = enum.auto()
|
||
definition = enum.auto()
|
||
declaration = enum.auto()
|
||
builtin = enum.auto()
|
||
|
||
|
||
@attrs.define
|
||
class Token:
|
||
line: int
|
||
offset: int
|
||
lenght: int
|
||
|
||
tok_type: str = ""
|
||
tok_modifiers: List[TokenModifier] = attrs.field(factory=list)
|
||
|
||
@property
|
||
def length(self) -> int:
|
||
"""Compatibility alias for misspelled 'lenght' field."""
|
||
return self.lenght
|
||
|
||
|
||
TOKEN_TYPES = [
|
||
"keyword",
|
||
"comment",
|
||
"variable",
|
||
"function",
|
||
"operator",
|
||
"parameter",
|
||
"type",
|
||
"class",
|
||
"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):
|
||
self._commands = _load_commands(plugins)
|
||
self._tokens = []
|
||
self._class_tokens = {}
|
||
self._method_tokens = {}
|
||
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:
|
||
return
|
||
self._tokens.append((position, length, tok_type, modifiers or []))
|
||
|
||
def highlight_classes(self, tree, external_classes=None):
|
||
declarations, references = class_symbols(tree, external_classes)
|
||
for node, modifiers in [
|
||
*((node, [TokenModifier.declaration]) for node in declarations.values()),
|
||
*((node, []) for node in references),
|
||
]:
|
||
line, col = node.contents_pos
|
||
self._class_tokens[(line - 1, col - 1)] = (
|
||
(line - 1, col - 1),
|
||
len(node.contents),
|
||
"class",
|
||
modifiers,
|
||
)
|
||
|
||
def _get_token_info(self, node):
|
||
"""Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren."""
|
||
if not hasattr(node, "pos"):
|
||
return None
|
||
|
||
# Einfacher Fall: Node hat direkten value
|
||
if hasattr(node, "value") and node.value is not None:
|
||
line, col = node.pos
|
||
return (line - 1, col - 1), len(node.value)
|
||
|
||
# CompoundBareWord: versuche erstes Segment
|
||
if hasattr(node, "children") and node.children:
|
||
first_segment = node.children[0]
|
||
if hasattr(first_segment, "value") and first_segment.value is not None and hasattr(first_segment, "pos"):
|
||
line, col = first_segment.pos
|
||
return (line - 1, col - 1), len(first_segment.value)
|
||
|
||
# Fallback: Gesamtlänge aus Positionen berechnen
|
||
if hasattr(node, "end_pos"):
|
||
start_line, start_col = node.pos
|
||
end_line, end_col = node.end_pos
|
||
if start_line == end_line:
|
||
length = end_col - start_col
|
||
return (start_line - 1, start_col - 1), length
|
||
|
||
return None
|
||
|
||
def highlight_methods(self, tree, source, uri, external_classes=None):
|
||
"""Use the same function token as procs for resolved TclOO methods."""
|
||
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
|
||
lines = source.splitlines()
|
||
for info in classes.values():
|
||
for location in info.method_definitions.values():
|
||
if location.uri != uri:
|
||
continue
|
||
start, end = location.range.start, location.range.end
|
||
encoded = lines[start.line].encode("utf-16-le")
|
||
column = len(encoded[:start.character * 2].decode("utf-16-le"))
|
||
length = len(encoded[start.character * 2:end.character * 2].decode("utf-16-le"))
|
||
position = (start.line, column)
|
||
self._method_tokens[position] = (position, length, "function", [TokenModifier.declaration])
|
||
for call in calls:
|
||
node = call.command.args[0]
|
||
if node.contents is None or node.contents_pos is None:
|
||
continue
|
||
line, column = node.contents_pos
|
||
position = (line - 1, column - 1)
|
||
self._method_tokens[position] = (position, len(node.contents), "function", [])
|
||
|
||
def visit_quoted_word(self, word: QuotedWord):
|
||
if not word.contents:
|
||
return
|
||
line, col = word.contents_pos
|
||
self._append_token((line - 1, col - 1), len(word.contents), "string", [])
|
||
|
||
def visit_comment(self, comment):
|
||
if not hasattr(comment, "pos") or comment.pos is None or comment.end_pos is None:
|
||
return
|
||
|
||
start_line, start_col = comment.pos
|
||
end_line, end_col = comment.end_pos
|
||
if start_line != end_line:
|
||
return
|
||
|
||
self._append_token((start_line - 1, start_col - 1), end_col - start_col, "comment", [])
|
||
|
||
def visit_bare_word(self, word: BareWord):
|
||
# Intentionally do not classify bare words as functions here.
|
||
# Function highlighting is handled in visit_command for the routine only,
|
||
# using completion items (custom functions) as the source of truth.
|
||
return
|
||
|
||
def visit_command(self, command: Command):
|
||
routine = command.routine
|
||
|
||
# Stored procedure names in braced lappend arguments use the same
|
||
# highlighting as calls, without treating the literal as executable Tcl.
|
||
if routine.contents in {"lappend", "::lappend"}:
|
||
for argument in command.args:
|
||
if (isinstance(argument, BracedWord)
|
||
and argument.contents in self._custom_function_names | _STANDARD_PROC_NAMES):
|
||
line, col = argument.contents_pos
|
||
self._append_token((line - 1, col - 1), len(argument.contents), "function", [])
|
||
|
||
# Procedures stored in COMMANDBLOCK properties (CONF_x set prop {proc}).
|
||
for stored_name, line, col in stored_command_names(command):
|
||
if stored_name in self._custom_function_names or stored_name in _STANDARD_PROC_NAMES:
|
||
self._append_token((line - 1, col - 1), len(stored_name), "function", [])
|
||
|
||
# Highlight functions (custom or standard) when used as the routine
|
||
name = getattr(routine, "contents", None)
|
||
if name:
|
||
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", [])
|
||
|
||
if routine.contents in {"global", "variable"}:
|
||
line, col = routine.contents_pos
|
||
self._append_token((line - 1, col - 1), len(routine.contents), "keyword", [])
|
||
|
||
for arg in command.args:
|
||
token_info = self._get_token_info(arg)
|
||
if token_info:
|
||
(arg_line, arg_col), length = token_info
|
||
self._append_token((arg_line, arg_col), length, "variable", [])
|
||
|
||
if routine.contents == "puts":
|
||
line, col = routine.contents_pos
|
||
self._append_token((line - 1, col - 1), len(routine.contents), "function", [TokenModifier.builtin])
|
||
if routine.contents in ["set", "append", "lappend"] and command.args:
|
||
first_arg = command.args[0]
|
||
token_info = self._get_token_info(first_arg)
|
||
if first_arg.contents is None:
|
||
name = variable_name(first_arg)
|
||
if name:
|
||
line, col = first_arg.children[0].pos
|
||
token_info = ((line - 1, col - 1), len(name))
|
||
if token_info:
|
||
(line, col), length = token_info
|
||
self._append_token((line, col), length, "variable", [TokenModifier.declaration])
|
||
if routine.contents == "proc" and command.args:
|
||
first_arg = command.args[0]
|
||
if hasattr(first_arg, "pos") and hasattr(first_arg, "value"):
|
||
line, col = first_arg.pos
|
||
self._append_token((line - 1, col - 1), len(first_arg.value), "function", [TokenModifier.declaration])
|
||
|
||
if len(command.args) >= 2:
|
||
param_list = command.args[1]
|
||
|
||
# BracedWord oder Liste erwartet
|
||
if hasattr(param_list, "children"):
|
||
for child in param_list.children:
|
||
# Parameter kann einfaches Wort sein
|
||
if hasattr(child, "value") and child.value is not None:
|
||
line, col = child.pos
|
||
self._append_token((line - 1, col - 1), len(child.value), "parameter", [TokenModifier.declaration])
|
||
|
||
# Parameter mit Default-Wert ist meist eine List (z. B. {arg default})
|
||
elif hasattr(child, "children") and len(child.children) >= 1:
|
||
name_node = child.children[0]
|
||
if hasattr(name_node, "value") and hasattr(name_node, "pos"):
|
||
line, col = name_node.pos
|
||
self._append_token((line - 1, col - 1), len(name_node.value), "parameter", [TokenModifier.declaration])
|
||
if routine.contents == "namespace" and command.args:
|
||
first_arg = command.args[1]
|
||
if hasattr(first_arg, "pos") and first_arg.value is not None:
|
||
line, col = first_arg.pos
|
||
self._append_token((line - 1, col - 1), len(first_arg.value), "class", [])
|
||
|
||
def tokens(self) -> list[Token]:
|
||
"""Encode tokens as described in
|
||
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens.
|
||
"""
|
||
tokens = []
|
||
last_line = 0
|
||
last_col = 0
|
||
overrides = {**self._method_tokens, **self._class_tokens}
|
||
raw_tokens = [token for token in self._tokens if token[0] not in overrides]
|
||
raw_tokens.extend(overrides.values())
|
||
for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]):
|
||
line_delta = line - last_line
|
||
col_delta = col
|
||
if line == last_line:
|
||
col_delta -= last_col
|
||
|
||
tokens.append(Token(line_delta, col_delta, length, tok_type, tok_modifier))
|
||
last_line, last_col = line, col
|
||
|
||
return tokens
|