feat(navigation): add symbol index and LSP navigation features
The changes introduce a Tcl symbol index powering LSP navigation features across the workspace. A navigation API exposes snapshots and update hooks, enabling goto-definition, references, and rename using the index. Background indexing now watches Tcl files and rebuilds the index to stay in sync. - Add Tcl symbol index and navigation snapshot API - Wire go-to-definition, references, and rename using the index - Watch Tcl files and refresh the index in the background
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import BareWord, Command, List, Node, Script, VarSub
|
||||
|
||||
|
||||
ROOT_NAMESPACE = "::"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolIdentity:
|
||||
kind: str
|
||||
name: str
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolOccurrence:
|
||||
identity: SymbolIdentity
|
||||
range: lsp.Range
|
||||
placeholder: str
|
||||
is_definition: bool = False
|
||||
symbol_kind: lsp.SymbolKind = lsp.SymbolKind.Variable
|
||||
container_name: str | None = None
|
||||
fallback_identity: SymbolIdentity | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSymbolIndex:
|
||||
path: str
|
||||
uri: str
|
||||
occurrences: tuple[SymbolOccurrence, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Scope:
|
||||
filepath: str
|
||||
namespace: str = ROOT_NAMESPACE
|
||||
proc_name: str | None = None
|
||||
global_variables: tuple[tuple[str, str], ...] = ()
|
||||
namespace_variables: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
def _without_array_index(name: str) -> str:
|
||||
return name.split("(", 1)[0]
|
||||
|
||||
|
||||
def _basename(name: str) -> str:
|
||||
return _without_array_index(name).rsplit("::", 1)[-1]
|
||||
|
||||
|
||||
def _qualify(name: str, namespace: str) -> str:
|
||||
name = _without_array_index(name)
|
||||
if name.startswith("::"):
|
||||
return name
|
||||
if namespace == ROOT_NAMESPACE:
|
||||
return f"::{name}"
|
||||
return f"{namespace}::{name}"
|
||||
|
||||
|
||||
def _namespace_of(qualified_name: str) -> str:
|
||||
parent = qualified_name.rsplit("::", 1)[0]
|
||||
return parent or ROOT_NAMESPACE
|
||||
|
||||
|
||||
def _display_name(identity: SymbolIdentity) -> str:
|
||||
if identity.kind == "variable" and identity.scope is not None:
|
||||
return identity.name
|
||||
return identity.name.removeprefix("::")
|
||||
|
||||
|
||||
def _container_name(identity: SymbolIdentity) -> str | None:
|
||||
if identity.scope is not None:
|
||||
_, _, proc_name = identity.scope.partition("::proc::")
|
||||
return proc_name.removeprefix("::") or None
|
||||
|
||||
qualified = identity.name.removeprefix("::")
|
||||
if "::" not in qualified:
|
||||
return None
|
||||
return qualified.rsplit("::", 1)[0]
|
||||
|
||||
|
||||
def _static_contents(node: Node | None) -> str | None:
|
||||
value = getattr(node, "contents", None)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp.Range:
|
||||
if variable_sub:
|
||||
line, column = node.pos
|
||||
column += 2 if getattr(node, "braced", False) else 1
|
||||
else:
|
||||
position = getattr(node, "contents_pos", None) or node.pos
|
||||
line, column = position
|
||||
|
||||
normalized = _without_array_index(raw_name)
|
||||
prefix_length = normalized.rfind("::") + 2 if "::" in normalized else 0
|
||||
start_character = column - 1 + prefix_length
|
||||
name = normalized[prefix_length:]
|
||||
return lsp.Range(
|
||||
start=lsp.Position(line=line - 1, character=start_character),
|
||||
end=lsp.Position(line=line - 1, character=start_character + len(name)),
|
||||
)
|
||||
|
||||
|
||||
def _proc_identity(raw_name: str, namespace: str) -> SymbolIdentity:
|
||||
return SymbolIdentity(kind="proc", name=_qualify(raw_name, namespace))
|
||||
|
||||
|
||||
def _proc_fallback(raw_name: str, namespace: str) -> SymbolIdentity | None:
|
||||
if raw_name.startswith("::") or "::" in raw_name or namespace == ROOT_NAMESPACE:
|
||||
return None
|
||||
return SymbolIdentity(kind="proc", name=_qualify(raw_name, ROOT_NAMESPACE))
|
||||
|
||||
|
||||
def _variable_identity(raw_name: str, scope: _Scope) -> SymbolIdentity:
|
||||
normalized = _without_array_index(raw_name)
|
||||
if normalized.startswith("::"):
|
||||
return SymbolIdentity(kind="variable", name=normalized)
|
||||
|
||||
if "::" in normalized:
|
||||
return SymbolIdentity(
|
||||
kind="variable", name=_qualify(normalized, scope.namespace)
|
||||
)
|
||||
|
||||
if scope.proc_name is None:
|
||||
return SymbolIdentity(
|
||||
kind="variable", name=_qualify(normalized, scope.namespace)
|
||||
)
|
||||
|
||||
for alias, target in scope.global_variables + scope.namespace_variables:
|
||||
if normalized == alias:
|
||||
return SymbolIdentity(kind="variable", name=target)
|
||||
|
||||
return SymbolIdentity(
|
||||
kind="variable",
|
||||
name=normalized,
|
||||
scope=f"{scope.filepath}::proc::{scope.proc_name}",
|
||||
)
|
||||
|
||||
|
||||
def _variable_command_nodes(command: Command) -> list[tuple[Node, bool]]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "set" and command.args:
|
||||
return [(command.args[0], len(command.args) >= 2)]
|
||||
if routine in {"incr", "append", "lappend"} and command.args:
|
||||
return [(command.args[0], True)]
|
||||
if routine == "lset" and command.args:
|
||||
return [(command.args[0], False)]
|
||||
if routine == "unset":
|
||||
return [
|
||||
(argument, False)
|
||||
for argument in command.args
|
||||
if not (_static_contents(argument) or "").startswith("-")
|
||||
]
|
||||
if routine == "array" and len(command.args) >= 2:
|
||||
return [
|
||||
(command.args[1], _static_contents(command.args[0]) == "set")
|
||||
]
|
||||
if routine == "dict" and len(command.args) >= 2:
|
||||
subcommand = _static_contents(command.args[0])
|
||||
if subcommand in {"set", "unset", "append", "incr", "lappend", "update", "with"}:
|
||||
return [
|
||||
(
|
||||
command.args[1],
|
||||
subcommand in {"set", "append", "incr", "lappend"},
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _binding_nodes(node: Node) -> list[Node]:
|
||||
if isinstance(node, List):
|
||||
return list(node.children)
|
||||
return [node]
|
||||
|
||||
|
||||
def _variable_binding_nodes(command: Command) -> list[Node]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine in {"foreach", "lmap"} and len(command.args) >= 3:
|
||||
return [
|
||||
variable
|
||||
for variable_list in command.args[:-1:2]
|
||||
for variable in _binding_nodes(variable_list)
|
||||
]
|
||||
if routine == "lassign" and len(command.args) >= 2:
|
||||
return list(command.args[1:])
|
||||
if routine == "catch" and len(command.args) >= 2:
|
||||
return list(command.args[1:3])
|
||||
if (
|
||||
routine == "dict"
|
||||
and command.args
|
||||
and _static_contents(command.args[0]) == "update"
|
||||
):
|
||||
return list(command.args[3:-1:2])
|
||||
return []
|
||||
|
||||
|
||||
def _variable_declaration_nodes(command: Command) -> list[Node]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "global":
|
||||
return list(command.args)
|
||||
if routine == "variable":
|
||||
return list(command.args[::2])
|
||||
return []
|
||||
|
||||
|
||||
def _scan_proc_imports(
|
||||
node: Node, namespace: str
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
global_variables: dict[str, str] = {}
|
||||
namespace_variables: dict[str, str] = {}
|
||||
|
||||
def walk(current: Node) -> None:
|
||||
if isinstance(current, Command):
|
||||
routine = _static_contents(current.routine)
|
||||
if routine == "proc":
|
||||
return
|
||||
if routine == "global":
|
||||
for argument in current.args:
|
||||
name = _static_contents(argument)
|
||||
if name:
|
||||
global_variables[_basename(name)] = _qualify(
|
||||
name, ROOT_NAMESPACE
|
||||
)
|
||||
elif routine == "variable":
|
||||
for argument in current.args[::2]:
|
||||
name = _static_contents(argument)
|
||||
if name:
|
||||
namespace_variables[_basename(name)] = _qualify(
|
||||
name, namespace
|
||||
)
|
||||
|
||||
for child in getattr(current, "children", []):
|
||||
walk(child)
|
||||
|
||||
walk(node)
|
||||
return global_variables, namespace_variables
|
||||
|
||||
|
||||
def build_file_symbol_index(
|
||||
filepath: str, uri: str, tree: Node
|
||||
) -> FileSymbolIndex:
|
||||
occurrences: list[SymbolOccurrence] = []
|
||||
|
||||
def add_proc(
|
||||
node: Node,
|
||||
raw_name: str,
|
||||
scope: _Scope,
|
||||
*,
|
||||
is_definition: bool,
|
||||
) -> None:
|
||||
identity = _proc_identity(raw_name, scope.namespace)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=identity,
|
||||
fallback_identity=(
|
||||
None
|
||||
if is_definition
|
||||
else _proc_fallback(raw_name, scope.namespace)
|
||||
),
|
||||
range=_name_range(node, raw_name),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Function,
|
||||
container_name=_container_name(identity),
|
||||
)
|
||||
)
|
||||
|
||||
def add_variable(
|
||||
node: Node,
|
||||
raw_name: str,
|
||||
scope: _Scope,
|
||||
*,
|
||||
is_definition: bool,
|
||||
variable_sub: bool = False,
|
||||
identity: SymbolIdentity | None = None,
|
||||
) -> None:
|
||||
symbol_identity = identity or _variable_identity(raw_name, scope)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=symbol_identity,
|
||||
range=_name_range(node, raw_name, variable_sub=variable_sub),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Variable,
|
||||
container_name=_container_name(symbol_identity),
|
||||
)
|
||||
)
|
||||
|
||||
def walk_embedded(node: Node, scope: _Scope) -> None:
|
||||
if isinstance(node, Script):
|
||||
walk_script(node, scope)
|
||||
return
|
||||
if isinstance(node, Command):
|
||||
walk_command(node, scope)
|
||||
return
|
||||
if isinstance(node, VarSub):
|
||||
raw_name = getattr(node, "value", None)
|
||||
if isinstance(raw_name, str):
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=False,
|
||||
variable_sub=True,
|
||||
)
|
||||
|
||||
for child in getattr(node, "children", []):
|
||||
walk_embedded(child, scope)
|
||||
|
||||
def walk_proc(command: Command, scope: _Scope) -> None:
|
||||
if len(command.args) < 3:
|
||||
return
|
||||
raw_name = _static_contents(command.args[0])
|
||||
body = command.args[2]
|
||||
if raw_name is None or not isinstance(body, Script):
|
||||
return
|
||||
|
||||
add_proc(command.args[0], raw_name, scope, is_definition=True)
|
||||
proc_identity = _proc_identity(raw_name, scope.namespace)
|
||||
proc_namespace = _namespace_of(proc_identity.name)
|
||||
global_variables, namespace_variables = _scan_proc_imports(
|
||||
body, proc_namespace
|
||||
)
|
||||
proc_scope = _Scope(
|
||||
filepath=filepath,
|
||||
namespace=proc_namespace,
|
||||
proc_name=proc_identity.name,
|
||||
global_variables=tuple(sorted(global_variables.items())),
|
||||
namespace_variables=tuple(sorted(namespace_variables.items())),
|
||||
)
|
||||
|
||||
parameters = command.args[1]
|
||||
for parameter in getattr(parameters, "children", []):
|
||||
parameter_node = parameter
|
||||
if isinstance(parameter, List) and parameter.children:
|
||||
parameter_node = parameter.children[0]
|
||||
parameter_name = _static_contents(parameter_node)
|
||||
if parameter_name:
|
||||
add_variable(
|
||||
parameter_node,
|
||||
parameter_name,
|
||||
proc_scope,
|
||||
is_definition=True,
|
||||
)
|
||||
|
||||
walk_script(body, proc_scope)
|
||||
|
||||
def walk_namespace(command: Command, scope: _Scope) -> bool:
|
||||
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
|
||||
return False
|
||||
raw_name = _static_contents(command.args[1])
|
||||
body = command.args[2]
|
||||
if raw_name is None or not isinstance(body, Script):
|
||||
return False
|
||||
|
||||
namespace = _qualify(raw_name, scope.namespace)
|
||||
identity = SymbolIdentity(kind="namespace", name=namespace)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=identity,
|
||||
range=_name_range(command.args[1], raw_name),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=True,
|
||||
symbol_kind=lsp.SymbolKind.Namespace,
|
||||
container_name=_container_name(identity),
|
||||
)
|
||||
)
|
||||
walk_script(
|
||||
body,
|
||||
_Scope(filepath=filepath, namespace=namespace),
|
||||
)
|
||||
return True
|
||||
|
||||
def walk_command(command: Command, scope: _Scope) -> None:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "proc":
|
||||
walk_proc(command, scope)
|
||||
return
|
||||
if routine == "namespace" and walk_namespace(command, scope):
|
||||
return
|
||||
|
||||
if routine:
|
||||
add_proc(command.routine, routine, scope, is_definition=False)
|
||||
|
||||
declaration_nodes = _variable_declaration_nodes(command)
|
||||
declaration_ids = {id(node) for node in declaration_nodes}
|
||||
for node in declaration_nodes:
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
is_definition = routine == "variable" and scope.proc_name is None
|
||||
if routine == "global":
|
||||
identity = SymbolIdentity(
|
||||
kind="variable",
|
||||
name=_qualify(raw_name, ROOT_NAMESPACE),
|
||||
)
|
||||
else:
|
||||
identity = SymbolIdentity(
|
||||
kind="variable",
|
||||
name=_qualify(raw_name, scope.namespace),
|
||||
)
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=is_definition,
|
||||
identity=identity,
|
||||
)
|
||||
|
||||
for node, is_definition in _variable_command_nodes(command):
|
||||
if id(node) in declaration_ids:
|
||||
continue
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=is_definition,
|
||||
)
|
||||
|
||||
for node in _variable_binding_nodes(command):
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
add_variable(node, raw_name, scope, is_definition=True)
|
||||
|
||||
for argument in command.args:
|
||||
walk_embedded(argument, scope)
|
||||
|
||||
def walk_script(script: Node, scope: _Scope) -> None:
|
||||
for child in getattr(script, "children", []):
|
||||
walk_embedded(child, scope)
|
||||
|
||||
walk_script(tree, _Scope(filepath=filepath))
|
||||
return FileSymbolIndex(path=filepath, uri=uri, occurrences=tuple(occurrences))
|
||||
|
||||
|
||||
def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]:
|
||||
return {
|
||||
occurrence.identity
|
||||
for index in indexes.values()
|
||||
for occurrence in index.occurrences
|
||||
if occurrence.is_definition
|
||||
}
|
||||
|
||||
|
||||
def resolve_identity(
|
||||
occurrence: SymbolOccurrence, definitions: set[SymbolIdentity]
|
||||
) -> SymbolIdentity:
|
||||
if occurrence.identity in definitions or occurrence.fallback_identity is None:
|
||||
return occurrence.identity
|
||||
if occurrence.fallback_identity in definitions:
|
||||
return occurrence.fallback_identity
|
||||
return occurrence.identity
|
||||
|
||||
|
||||
def symbol_at_position(
|
||||
index: FileSymbolIndex,
|
||||
position: lsp.Position,
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> tuple[SymbolOccurrence, SymbolIdentity] | None:
|
||||
for occurrence in index.occurrences:
|
||||
start = occurrence.range.start
|
||||
end = occurrence.range.end
|
||||
if (
|
||||
position.line == start.line == end.line
|
||||
and start.character <= position.character < end.character
|
||||
):
|
||||
return occurrence, resolve_identity(occurrence, definitions)
|
||||
return None
|
||||
|
||||
|
||||
def matching_occurrences(
|
||||
identity: SymbolIdentity,
|
||||
indexes: dict[str, FileSymbolIndex],
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
|
||||
matches = []
|
||||
for index in indexes.values():
|
||||
for occurrence in index.occurrences:
|
||||
if resolve_identity(occurrence, definitions) == identity:
|
||||
matches.append((index, occurrence))
|
||||
return matches
|
||||
|
||||
|
||||
def workspace_symbols(
|
||||
indexes: dict[str, FileSymbolIndex], query: str
|
||||
) -> list[lsp.SymbolInformation]:
|
||||
query = query.casefold()
|
||||
results = []
|
||||
seen = set()
|
||||
for index in indexes.values():
|
||||
for occurrence in index.occurrences:
|
||||
identity = occurrence.identity
|
||||
if not occurrence.is_definition:
|
||||
continue
|
||||
if identity.kind == "variable" and identity.scope is not None:
|
||||
continue
|
||||
|
||||
name = _display_name(identity)
|
||||
if query and query not in name.casefold():
|
||||
continue
|
||||
|
||||
key = identity
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append(
|
||||
lsp.SymbolInformation(
|
||||
name=name,
|
||||
kind=occurrence.symbol_kind,
|
||||
location=lsp.Location(uri=index.uri, range=occurrence.range),
|
||||
container_name=occurrence.container_name,
|
||||
)
|
||||
)
|
||||
return sorted(results, key=lambda symbol: symbol.name.casefold())
|
||||
Reference in New Issue
Block a user