Merge pull request 'New features' (#33) from new_features into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
build_and_puplish.yml / build_and_publish (release) Successful in 34s
Reviewed-on: #33
This commit was merged in pull request #33.
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers
|
||||||
|
- Add document highlights for procedure and variable occurrences
|
||||||
|
- Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols
|
||||||
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
|
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
|
||||||
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
|
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
|
||||||
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
|
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ A comprehensive VS Code extension providing language support and remote debuggin
|
|||||||
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
|
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
|
||||||
- **Auto-completion** - Context-aware code completion for faster development
|
- **Auto-completion** - Context-aware code completion for faster development
|
||||||
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
|
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
|
||||||
|
- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers
|
||||||
|
- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor
|
||||||
|
- **Context-aware Completion** - Prioritizes local symbols and suggests variables or commands based on cursor context
|
||||||
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
|
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
|
||||||
|
|
||||||
## Supported File Types
|
## Supported File Types
|
||||||
@@ -96,6 +99,9 @@ Simply open any supported file type and enjoy:
|
|||||||
- Code formatting (Format Document command)
|
- Code formatting (Format Document command)
|
||||||
- Hover information
|
- Hover information
|
||||||
- Signature help while entering procedure arguments
|
- Signature help while entering procedure arguments
|
||||||
|
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
|
||||||
|
- Document-wide highlights for procedure and variable occurrences
|
||||||
|
- Context-aware completion with local symbols ranked before workspace and built-in symbols
|
||||||
- Remote NX Tcl debugging with breakpoints and full stepping
|
- Remote NX Tcl debugging with breakpoints and full stepping
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
+110
-41
@@ -44,6 +44,7 @@ from common.load_data import standard_items
|
|||||||
from lsp_tclserver import TclLanguageServer
|
from lsp_tclserver import TclLanguageServer
|
||||||
from pygls import uris
|
from pygls import uris
|
||||||
from pygls.workspace.text_document import TextDocument
|
from pygls.workspace.text_document import TextDocument
|
||||||
|
from tools.completion_items import completion_context, ranked_completion_items
|
||||||
from tools.folding_ranges import build_folding_ranges
|
from tools.folding_ranges import build_folding_ranges
|
||||||
from tools.inlay_hint import (
|
from tools.inlay_hint import (
|
||||||
InlayHintGenerator,
|
InlayHintGenerator,
|
||||||
@@ -51,8 +52,13 @@ from tools.inlay_hint import (
|
|||||||
)
|
)
|
||||||
from tools.navigation import (
|
from tools.navigation import (
|
||||||
SymbolIdentity,
|
SymbolIdentity,
|
||||||
|
call_hierarchy_identity,
|
||||||
|
call_hierarchy_items,
|
||||||
definition_identities,
|
definition_identities,
|
||||||
|
document_highlights,
|
||||||
|
incoming_call_hierarchy,
|
||||||
matching_occurrences,
|
matching_occurrences,
|
||||||
|
outgoing_call_hierarchy,
|
||||||
symbol_at_position,
|
symbol_at_position,
|
||||||
workspace_symbols,
|
workspace_symbols,
|
||||||
)
|
)
|
||||||
@@ -83,9 +89,9 @@ STATIC_COMPLETION_ITEMS = tuple(
|
|||||||
+ standard_items.nx_procs
|
+ standard_items.nx_procs
|
||||||
+ standard_items.nx_variables
|
+ standard_items.nx_variables
|
||||||
)
|
)
|
||||||
STATIC_COMPLETION_KEYS = frozenset(
|
STATIC_VARIABLE_ITEMS = {
|
||||||
(item.label, getattr(item, "kind", None)) for item in STATIC_COMPLETION_ITEMS
|
item.label: item for item in standard_items.nx_variables
|
||||||
)
|
}
|
||||||
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(
|
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(
|
||||||
standard_items.json_data.get("MOM_procs", [])
|
standard_items.json_data.get("MOM_procs", [])
|
||||||
)
|
)
|
||||||
@@ -242,55 +248,63 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
|||||||
return lsp.FullDocumentDiagnosticReport(items=diagnostics, result_id=result_id)
|
return lsp.FullDocumentDiagnosticReport(items=diagnostics, result_id=result_id)
|
||||||
|
|
||||||
|
|
||||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION)
|
@LSP_SERVER.feature(
|
||||||
|
lsp.TEXT_DOCUMENT_COMPLETION,
|
||||||
|
lsp.CompletionOptions(trigger_characters=["$"]),
|
||||||
|
)
|
||||||
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||||
from tools.completion_items import BUILTIN_VAR_LABELS
|
|
||||||
|
|
||||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||||
|
|
||||||
workspace_items = LSP_SERVER.completion_items_snapshot()
|
|
||||||
tree = LSP_SERVER.get_tree(doc)
|
tree = LSP_SERVER.get_tree(doc)
|
||||||
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
|
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
|
||||||
doc, tree
|
doc, tree
|
||||||
)
|
)
|
||||||
|
|
||||||
# Always include globals (excluding built-ins)
|
position = params.position
|
||||||
dynamic_items = []
|
local_names: set[str] = set()
|
||||||
for name in sorted(globals_set):
|
for proc_range in proc_ranges:
|
||||||
if name not in BUILTIN_VAR_LABELS:
|
end_line = proc_range.end_line or proc_range.start_line
|
||||||
dynamic_items.append(
|
if proc_range.start_line <= position.line <= end_line:
|
||||||
lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable)
|
local_names.update(procs_locals.get(proc_range.name, set()))
|
||||||
|
break
|
||||||
|
|
||||||
|
candidates: list[tuple[int, lsp.CompletionItem]] = []
|
||||||
|
for name in sorted(local_names - globals_set):
|
||||||
|
candidates.append(
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
lsp.CompletionItem(
|
||||||
|
label=name,
|
||||||
|
kind=lsp.CompletionItemKind.Variable,
|
||||||
|
detail="Local variable",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Include proc-local variables when cursor is inside that proc
|
for name in sorted(globals_set):
|
||||||
pos = params.position
|
item = STATIC_VARIABLE_ITEMS.get(name)
|
||||||
if pos is not None:
|
if item is None:
|
||||||
for pr in proc_ranges:
|
item = lsp.CompletionItem(
|
||||||
if pr.start_line <= pos.line <= (pr.end_line or pr.start_line):
|
label=name,
|
||||||
for name in sorted(procs_locals.get(pr.name, set())):
|
kind=lsp.CompletionItemKind.Variable,
|
||||||
# Exclude built-ins and globals to avoid duplication
|
detail="Workspace variable",
|
||||||
if name not in BUILTIN_VAR_LABELS and name not in globals_set:
|
)
|
||||||
dynamic_items.append(
|
candidates.append(
|
||||||
lsp.CompletionItem(
|
(
|
||||||
label=name, kind=lsp.CompletionItemKind.Variable
|
100,
|
||||||
)
|
item,
|
||||||
)
|
)
|
||||||
break
|
)
|
||||||
|
|
||||||
# Merge with de-duplication. Each file keeps its complete index, so a proc
|
filepath = str(pathlib.Path(uris.to_fs_path(doc.uri)))
|
||||||
# declared in multiple files must only appear once in the completion list.
|
items_by_file = LSP_SERVER.completion_items_by_file_snapshot()
|
||||||
merged: list[lsp.CompletionItem] = list(STATIC_COMPLETION_ITEMS)
|
for item_path in sorted(items_by_file, key=lambda path: path.casefold()):
|
||||||
seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set(
|
priority = 100 if LSP_SERVER.paths_equal(item_path, filepath) else 200
|
||||||
STATIC_COMPLETION_KEYS
|
candidates.extend((priority, item) for item in items_by_file[item_path])
|
||||||
)
|
|
||||||
for it in (*workspace_items, *dynamic_items):
|
|
||||||
key = (it.label, getattr(it, "kind", None))
|
|
||||||
if key in seen_items:
|
|
||||||
continue
|
|
||||||
seen_items.add(key)
|
|
||||||
merged.append(it)
|
|
||||||
|
|
||||||
return lsp.CompletionList(is_incomplete=False, items=merged)
|
candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS)
|
||||||
|
context = completion_context(LSP_SERVER.get_lines(doc), position)
|
||||||
|
items = ranked_completion_items(candidates, context)
|
||||||
|
return lsp.CompletionList(is_incomplete=False, items=items)
|
||||||
|
|
||||||
|
|
||||||
@LSP_SERVER.feature(
|
@LSP_SERVER.feature(
|
||||||
@@ -544,6 +558,20 @@ def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
|
|||||||
return _sorted_locations(locations)
|
return _sorted_locations(locations)
|
||||||
|
|
||||||
|
|
||||||
|
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT)
|
||||||
|
def document_highlight(params: lsp.DocumentHighlightParams):
|
||||||
|
context = _navigation_context(params.text_document.uri, params.position)
|
||||||
|
if context is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
indexes, definitions, _, identity = context
|
||||||
|
filepath = str(pathlib.Path(uris.to_fs_path(params.text_document.uri)))
|
||||||
|
index = indexes.get(filepath)
|
||||||
|
if index is None:
|
||||||
|
return []
|
||||||
|
return document_highlights(index, identity, definitions)
|
||||||
|
|
||||||
|
|
||||||
def _is_renamable(
|
def _is_renamable(
|
||||||
identity: SymbolIdentity,
|
identity: SymbolIdentity,
|
||||||
indexes,
|
indexes,
|
||||||
@@ -629,6 +657,45 @@ def workspace_symbol(params: lsp.WorkspaceSymbolParams):
|
|||||||
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
|
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
|
||||||
|
|
||||||
|
|
||||||
|
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_PREPARE_CALL_HIERARCHY)
|
||||||
|
def prepare_call_hierarchy(params: lsp.CallHierarchyPrepareParams):
|
||||||
|
context = _navigation_context(params.text_document.uri, params.position)
|
||||||
|
if context is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
indexes, _, _, identity = context
|
||||||
|
items = call_hierarchy_items(identity, indexes)
|
||||||
|
return items or None
|
||||||
|
|
||||||
|
|
||||||
|
@LSP_SERVER.feature(lsp.CALL_HIERARCHY_INCOMING_CALLS)
|
||||||
|
def incoming_calls(params: lsp.CallHierarchyIncomingCallsParams):
|
||||||
|
identity = call_hierarchy_identity(params.item)
|
||||||
|
if identity is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
indexes = LSP_SERVER.navigation_snapshot()
|
||||||
|
return incoming_call_hierarchy(
|
||||||
|
identity,
|
||||||
|
indexes,
|
||||||
|
definition_identities(indexes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@LSP_SERVER.feature(lsp.CALL_HIERARCHY_OUTGOING_CALLS)
|
||||||
|
def outgoing_calls(params: lsp.CallHierarchyOutgoingCallsParams):
|
||||||
|
identity = call_hierarchy_identity(params.item)
|
||||||
|
if identity is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
indexes = LSP_SERVER.navigation_snapshot()
|
||||||
|
return outgoing_call_hierarchy(
|
||||||
|
identity,
|
||||||
|
indexes,
|
||||||
|
definition_identities(indexes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# **********************************************************
|
# **********************************************************
|
||||||
# Linting features end here
|
# Linting features end here
|
||||||
# **********************************************************
|
# **********************************************************
|
||||||
@@ -701,9 +768,11 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
|
|||||||
legend=semantic_tokens_legend, full=True, range=False
|
legend=semantic_tokens_legend, full=True, range=False
|
||||||
),
|
),
|
||||||
definition_provider=True,
|
definition_provider=True,
|
||||||
|
document_highlight_provider=True,
|
||||||
references_provider=True,
|
references_provider=True,
|
||||||
rename_provider=lsp.RenameOptions(prepare_provider=True),
|
rename_provider=lsp.RenameOptions(prepare_provider=True),
|
||||||
workspace_symbol_provider=True,
|
workspace_symbol_provider=True,
|
||||||
|
call_hierarchy_provider=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ class TclLanguageServer(LanguageServer):
|
|||||||
self._workspace_completion_cache = (self._index_generation, items)
|
self._workspace_completion_cache = (self._index_generation, items)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
def completion_items_by_file_snapshot(
|
||||||
|
self,
|
||||||
|
) -> dict[str, tuple[lsp.CompletionItem, ...]]:
|
||||||
|
"""Return completion items grouped by source file for request ranking."""
|
||||||
|
with self._index_lock:
|
||||||
|
return {
|
||||||
|
path: tuple(items) for path, items in self.poco_completion.items()
|
||||||
|
}
|
||||||
|
|
||||||
def custom_function_names_snapshot(self) -> frozenset[str]:
|
def custom_function_names_snapshot(self) -> frozenset[str]:
|
||||||
"""Return custom completion labels for semantic highlighting."""
|
"""Return custom completion labels for semantic highlighting."""
|
||||||
with self._index_lock:
|
with self._index_lock:
|
||||||
|
|||||||
@@ -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
|
import lsprotocol.types as lsp
|
||||||
from common.load_data import standard_items
|
from common.load_data import standard_items
|
||||||
from tclint.syntax_tree import BareWord, Command, List, Visitor
|
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}
|
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:
|
class CompletionItems:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._custom_functions: list[lsp.CompletionItem] = []
|
self._custom_functions: list[lsp.CompletionItem] = []
|
||||||
@@ -77,9 +164,17 @@ class CompletionCollector(Visitor):
|
|||||||
# Collect global variables declared with: global var1 var2 ...
|
# Collect global variables declared with: global var1 var2 ...
|
||||||
elif routine.contents == "global" and command.args:
|
elif routine.contents == "global" and command.args:
|
||||||
for arg in command.args:
|
for arg in command.args:
|
||||||
if isinstance(arg, BareWord) and getattr(arg, "value", None):
|
if (
|
||||||
if arg.value not in BUILTIN_VAR_LABELS:
|
isinstance(arg, BareWord)
|
||||||
self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable))
|
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 ...
|
# Collect variables set with explicit global namespace: set ::var_name ...
|
||||||
elif routine.contents == "set" and command.args:
|
elif routine.contents == "set" and command.args:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import lsprotocol.types as lsp
|
import lsprotocol.types as lsp
|
||||||
from tclint.syntax_tree import BareWord, Command, List, Node, Script, VarSub
|
from tclint.syntax_tree import Command, List, Node, Script, VarSub
|
||||||
|
|
||||||
|
|
||||||
ROOT_NAMESPACE = "::"
|
ROOT_NAMESPACE = "::"
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ class SymbolOccurrence:
|
|||||||
symbol_kind: lsp.SymbolKind = lsp.SymbolKind.Variable
|
symbol_kind: lsp.SymbolKind = lsp.SymbolKind.Variable
|
||||||
container_name: str | None = None
|
container_name: str | None = None
|
||||||
fallback_identity: SymbolIdentity | None = None
|
fallback_identity: SymbolIdentity | None = None
|
||||||
|
caller: SymbolIdentity | None = None
|
||||||
|
declaration_range: lsp.Range | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -32,6 +34,7 @@ class FileSymbolIndex:
|
|||||||
path: str
|
path: str
|
||||||
uri: str
|
uri: str
|
||||||
occurrences: tuple[SymbolOccurrence, ...]
|
occurrences: tuple[SymbolOccurrence, ...]
|
||||||
|
document_range: lsp.Range | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -105,6 +108,18 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_range(node: Node) -> lsp.Range | None:
|
||||||
|
if node.pos is None or node.end_pos is None:
|
||||||
|
return None
|
||||||
|
return lsp.Range(
|
||||||
|
start=lsp.Position(line=node.pos[0] - 1, character=node.pos[1] - 1),
|
||||||
|
end=lsp.Position(
|
||||||
|
line=node.end_pos[0] - 1,
|
||||||
|
character=node.end_pos[1] - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _proc_identity(raw_name: str, namespace: str) -> SymbolIdentity:
|
def _proc_identity(raw_name: str, namespace: str) -> SymbolIdentity:
|
||||||
return SymbolIdentity(kind="proc", name=_qualify(raw_name, namespace))
|
return SymbolIdentity(kind="proc", name=_qualify(raw_name, namespace))
|
||||||
|
|
||||||
@@ -251,8 +266,16 @@ def build_file_symbol_index(
|
|||||||
scope: _Scope,
|
scope: _Scope,
|
||||||
*,
|
*,
|
||||||
is_definition: bool,
|
is_definition: bool,
|
||||||
|
declaration_range: lsp.Range | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
identity = _proc_identity(raw_name, scope.namespace)
|
identity = _proc_identity(raw_name, scope.namespace)
|
||||||
|
caller = None
|
||||||
|
if not is_definition:
|
||||||
|
caller = (
|
||||||
|
SymbolIdentity(kind="proc", name=scope.proc_name)
|
||||||
|
if scope.proc_name is not None
|
||||||
|
else SymbolIdentity(kind="file", name=filepath)
|
||||||
|
)
|
||||||
occurrences.append(
|
occurrences.append(
|
||||||
SymbolOccurrence(
|
SymbolOccurrence(
|
||||||
identity=identity,
|
identity=identity,
|
||||||
@@ -266,6 +289,8 @@ def build_file_symbol_index(
|
|||||||
is_definition=is_definition,
|
is_definition=is_definition,
|
||||||
symbol_kind=lsp.SymbolKind.Function,
|
symbol_kind=lsp.SymbolKind.Function,
|
||||||
container_name=_container_name(identity),
|
container_name=_container_name(identity),
|
||||||
|
caller=caller,
|
||||||
|
declaration_range=declaration_range,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,7 +344,13 @@ def build_file_symbol_index(
|
|||||||
if raw_name is None or not isinstance(body, Script):
|
if raw_name is None or not isinstance(body, Script):
|
||||||
return
|
return
|
||||||
|
|
||||||
add_proc(command.args[0], raw_name, scope, is_definition=True)
|
add_proc(
|
||||||
|
command.args[0],
|
||||||
|
raw_name,
|
||||||
|
scope,
|
||||||
|
is_definition=True,
|
||||||
|
declaration_range=_node_range(command),
|
||||||
|
)
|
||||||
proc_identity = _proc_identity(raw_name, scope.namespace)
|
proc_identity = _proc_identity(raw_name, scope.namespace)
|
||||||
proc_namespace = _namespace_of(proc_identity.name)
|
proc_namespace = _namespace_of(proc_identity.name)
|
||||||
global_variables, namespace_variables = _scan_proc_imports(
|
global_variables, namespace_variables = _scan_proc_imports(
|
||||||
@@ -435,7 +466,12 @@ def build_file_symbol_index(
|
|||||||
walk_embedded(child, scope)
|
walk_embedded(child, scope)
|
||||||
|
|
||||||
walk_script(tree, _Scope(filepath=filepath))
|
walk_script(tree, _Scope(filepath=filepath))
|
||||||
return FileSymbolIndex(path=filepath, uri=uri, occurrences=tuple(occurrences))
|
return FileSymbolIndex(
|
||||||
|
path=filepath,
|
||||||
|
uri=uri,
|
||||||
|
occurrences=tuple(occurrences),
|
||||||
|
document_range=_node_range(tree),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]:
|
def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]:
|
||||||
@@ -486,6 +522,37 @@ def matching_occurrences(
|
|||||||
return matches
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def document_highlights(
|
||||||
|
index: FileSymbolIndex,
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
definitions: set[SymbolIdentity],
|
||||||
|
) -> list[lsp.DocumentHighlight]:
|
||||||
|
"""Return all occurrences of one symbol in the active document."""
|
||||||
|
highlights = []
|
||||||
|
for occurrence in index.occurrences:
|
||||||
|
if resolve_identity(occurrence, definitions) != identity:
|
||||||
|
continue
|
||||||
|
|
||||||
|
kind = lsp.DocumentHighlightKind.Text
|
||||||
|
if identity.kind == "variable":
|
||||||
|
kind = (
|
||||||
|
lsp.DocumentHighlightKind.Write
|
||||||
|
if occurrence.is_definition
|
||||||
|
else lsp.DocumentHighlightKind.Read
|
||||||
|
)
|
||||||
|
highlights.append(lsp.DocumentHighlight(range=occurrence.range, kind=kind))
|
||||||
|
|
||||||
|
return sorted(
|
||||||
|
highlights,
|
||||||
|
key=lambda highlight: (
|
||||||
|
highlight.range.start.line,
|
||||||
|
highlight.range.start.character,
|
||||||
|
highlight.range.end.line,
|
||||||
|
highlight.range.end.character,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def workspace_symbols(
|
def workspace_symbols(
|
||||||
indexes: dict[str, FileSymbolIndex], query: str
|
indexes: dict[str, FileSymbolIndex], query: str
|
||||||
) -> list[lsp.SymbolInformation]:
|
) -> list[lsp.SymbolInformation]:
|
||||||
@@ -517,3 +584,215 @@ def workspace_symbols(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return sorted(results, key=lambda symbol: symbol.name.casefold())
|
return sorted(results, key=lambda symbol: symbol.name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
_CALL_HIERARCHY_DATA_KIND = "nx-post-support.call-hierarchy"
|
||||||
|
|
||||||
|
|
||||||
|
def _call_hierarchy_data(identity: SymbolIdentity) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"source": _CALL_HIERARCHY_DATA_KIND,
|
||||||
|
"kind": identity.kind,
|
||||||
|
"name": identity.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def call_hierarchy_identity(item: lsp.CallHierarchyItem) -> SymbolIdentity | None:
|
||||||
|
"""Restore the symbol identity carried by a call hierarchy item."""
|
||||||
|
data = item.data
|
||||||
|
if (
|
||||||
|
not isinstance(data, dict)
|
||||||
|
or data.get("source") != _CALL_HIERARCHY_DATA_KIND
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
kind = data.get("kind")
|
||||||
|
name = data.get("name")
|
||||||
|
if kind not in {"proc", "file"} or not isinstance(name, str):
|
||||||
|
return None
|
||||||
|
return SymbolIdentity(kind=kind, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def _proc_definitions(
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
) -> dict[SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]]]:
|
||||||
|
definitions: dict[
|
||||||
|
SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]]
|
||||||
|
] = {}
|
||||||
|
for index in indexes.values():
|
||||||
|
for occurrence in index.occurrences:
|
||||||
|
if occurrence.is_definition and occurrence.identity.kind == "proc":
|
||||||
|
definitions.setdefault(occurrence.identity, []).append(
|
||||||
|
(index, occurrence)
|
||||||
|
)
|
||||||
|
return definitions
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_proc_definition(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
proc_definitions: dict[
|
||||||
|
SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]]
|
||||||
|
],
|
||||||
|
) -> tuple[FileSymbolIndex, SymbolOccurrence] | None:
|
||||||
|
matches = proc_definitions.get(identity, [])
|
||||||
|
if len(matches) != 1:
|
||||||
|
return None
|
||||||
|
return matches[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _file_item(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
) -> lsp.CallHierarchyItem | None:
|
||||||
|
if identity.kind != "file":
|
||||||
|
return None
|
||||||
|
index = indexes.get(identity.name)
|
||||||
|
if index is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
range_ = index.document_range or lsp.Range(
|
||||||
|
start=lsp.Position(line=0, character=0),
|
||||||
|
end=lsp.Position(line=0, character=0),
|
||||||
|
)
|
||||||
|
selection_range = lsp.Range(start=range_.start, end=range_.start)
|
||||||
|
return lsp.CallHierarchyItem(
|
||||||
|
name=Path(index.path).name,
|
||||||
|
kind=lsp.SymbolKind.File,
|
||||||
|
uri=index.uri,
|
||||||
|
range=range_,
|
||||||
|
selection_range=selection_range,
|
||||||
|
detail=str(Path(index.path).parent),
|
||||||
|
data=_call_hierarchy_data(identity),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _call_hierarchy_item(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
proc_definitions: dict[
|
||||||
|
SymbolIdentity, list[tuple[FileSymbolIndex, SymbolOccurrence]]
|
||||||
|
],
|
||||||
|
) -> lsp.CallHierarchyItem | None:
|
||||||
|
if identity.kind == "file":
|
||||||
|
return _file_item(identity, indexes)
|
||||||
|
|
||||||
|
definition = _unique_proc_definition(identity, proc_definitions)
|
||||||
|
if definition is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
index, occurrence = definition
|
||||||
|
basename = _basename(identity.name)
|
||||||
|
symbol_kind = (
|
||||||
|
lsp.SymbolKind.Event
|
||||||
|
if basename.startswith("MOM_")
|
||||||
|
else lsp.SymbolKind.Function
|
||||||
|
)
|
||||||
|
return lsp.CallHierarchyItem(
|
||||||
|
name=_display_name(identity),
|
||||||
|
kind=symbol_kind,
|
||||||
|
uri=index.uri,
|
||||||
|
range=occurrence.declaration_range or occurrence.range,
|
||||||
|
selection_range=occurrence.range,
|
||||||
|
detail=Path(index.path).name,
|
||||||
|
data=_call_hierarchy_data(identity),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def call_hierarchy_items(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
) -> list[lsp.CallHierarchyItem]:
|
||||||
|
"""Build the hierarchy item for one unambiguous workspace procedure."""
|
||||||
|
item = _call_hierarchy_item(identity, indexes, _proc_definitions(indexes))
|
||||||
|
return [item] if item is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def _range_key(range_: lsp.Range) -> tuple[int, int, int, int]:
|
||||||
|
return (
|
||||||
|
range_.start.line,
|
||||||
|
range_.start.character,
|
||||||
|
range_.end.line,
|
||||||
|
range_.end.character,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _item_key(item: lsp.CallHierarchyItem) -> tuple[str, str, int, int]:
|
||||||
|
return (
|
||||||
|
item.name.casefold(),
|
||||||
|
item.uri,
|
||||||
|
item.selection_range.start.line,
|
||||||
|
item.selection_range.start.character,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def incoming_call_hierarchy(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
definitions: set[SymbolIdentity],
|
||||||
|
) -> list[lsp.CallHierarchyIncomingCall]:
|
||||||
|
"""Return statically resolved workspace procedures that call ``identity``."""
|
||||||
|
proc_definitions = _proc_definitions(indexes)
|
||||||
|
if _unique_proc_definition(identity, proc_definitions) is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
grouped: dict[SymbolIdentity, list[lsp.Range]] = {}
|
||||||
|
for _, occurrence in matching_occurrences(identity, indexes, definitions):
|
||||||
|
if occurrence.is_definition or occurrence.caller is None:
|
||||||
|
continue
|
||||||
|
caller = occurrence.caller
|
||||||
|
if caller.kind == "proc" and (
|
||||||
|
_unique_proc_definition(caller, proc_definitions) is None
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
grouped.setdefault(caller, []).append(occurrence.range)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for caller, ranges in grouped.items():
|
||||||
|
item = _call_hierarchy_item(caller, indexes, proc_definitions)
|
||||||
|
if item is not None:
|
||||||
|
results.append(
|
||||||
|
lsp.CallHierarchyIncomingCall(
|
||||||
|
from_=item,
|
||||||
|
from_ranges=sorted(ranges, key=_range_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(results, key=lambda call: _item_key(call.from_))
|
||||||
|
|
||||||
|
|
||||||
|
def outgoing_call_hierarchy(
|
||||||
|
identity: SymbolIdentity,
|
||||||
|
indexes: dict[str, FileSymbolIndex],
|
||||||
|
definitions: set[SymbolIdentity],
|
||||||
|
) -> list[lsp.CallHierarchyOutgoingCall]:
|
||||||
|
"""Return statically resolved workspace procedures called by ``identity``."""
|
||||||
|
proc_definitions = _proc_definitions(indexes)
|
||||||
|
if identity.kind == "proc":
|
||||||
|
if _unique_proc_definition(identity, proc_definitions) is None:
|
||||||
|
return []
|
||||||
|
elif identity.kind == "file":
|
||||||
|
if identity.name not in indexes:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
grouped: dict[SymbolIdentity, list[lsp.Range]] = {}
|
||||||
|
for index in indexes.values():
|
||||||
|
for occurrence in index.occurrences:
|
||||||
|
if occurrence.is_definition or occurrence.caller != identity:
|
||||||
|
continue
|
||||||
|
callee = resolve_identity(occurrence, definitions)
|
||||||
|
if _unique_proc_definition(callee, proc_definitions) is None:
|
||||||
|
continue
|
||||||
|
grouped.setdefault(callee, []).append(occurrence.range)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for callee, ranges in grouped.items():
|
||||||
|
item = _call_hierarchy_item(callee, indexes, proc_definitions)
|
||||||
|
if item is not None:
|
||||||
|
results.append(
|
||||||
|
lsp.CallHierarchyOutgoingCall(
|
||||||
|
to=item,
|
||||||
|
from_ranges=sorted(ranges, key=_range_key),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sorted(results, key=lambda call: _item_key(call.to))
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
THIS_DIR = Path(__file__).parent
|
||||||
|
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||||
|
if str(SRC_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
|
import lsp_server
|
||||||
|
import lsprotocol.types as lsp # type: ignore
|
||||||
|
from common.load_data import standard_items
|
||||||
|
from lsp_tclserver import TclLanguageServer
|
||||||
|
from pygls.workspace import Workspace
|
||||||
|
from pygls.workspace.text_document import TextDocument
|
||||||
|
from tools.completion_items import (
|
||||||
|
COMMAND_KINDS,
|
||||||
|
VARIABLE_KINDS,
|
||||||
|
CompletionContext,
|
||||||
|
completion_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position:
|
||||||
|
offset = -1
|
||||||
|
for _ in range(occurrence + 1):
|
||||||
|
offset = source.index(token, offset + 1)
|
||||||
|
offset += len(token)
|
||||||
|
before = source[:offset]
|
||||||
|
return lsp.Position(
|
||||||
|
line=before.count("\n"),
|
||||||
|
character=offset - (before.rfind("\n") + 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _document(path: Path, source: str) -> TextDocument:
|
||||||
|
return TextDocument(
|
||||||
|
uri=path.as_uri(),
|
||||||
|
source=source,
|
||||||
|
version=1,
|
||||||
|
language_id="tcl",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _completion_server(
|
||||||
|
tmp_path: Path, monkeypatch
|
||||||
|
) -> tuple[TclLanguageServer, TextDocument, str]:
|
||||||
|
declared_builtin = standard_items.nx_variables[0].label
|
||||||
|
current_source = (
|
||||||
|
"set globalValue 1\n"
|
||||||
|
"proc localProc {} { return }\n"
|
||||||
|
"proc caller {argument} {\n"
|
||||||
|
f" global {declared_builtin}\n"
|
||||||
|
" set localValue 2\n"
|
||||||
|
" puts $local\n"
|
||||||
|
" localP\n"
|
||||||
|
"}\n"
|
||||||
|
)
|
||||||
|
workspace_source = """set ::workspaceValue 1
|
||||||
|
proc workspaceProc {} { return }
|
||||||
|
"""
|
||||||
|
current = _document(tmp_path / "current.tcl", current_source)
|
||||||
|
workspace = _document(tmp_path / "workspace.tcl", workspace_source)
|
||||||
|
server = TclLanguageServer(name="completion-test", version="1", max_workers=1)
|
||||||
|
server.protocol._workspace = Workspace( # pylint: disable=protected-access
|
||||||
|
root_uri=None,
|
||||||
|
sync_kind=lsp.TextDocumentSyncKind.Incremental,
|
||||||
|
workspace_folders=[],
|
||||||
|
position_encoding=lsp.PositionEncodingKind.Utf16,
|
||||||
|
)
|
||||||
|
server.workspace.put_text_document(
|
||||||
|
lsp.TextDocumentItem(
|
||||||
|
uri=current.uri,
|
||||||
|
language_id="tcl",
|
||||||
|
version=1,
|
||||||
|
text=current_source,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert server.update_poco_completion_for_file(current)
|
||||||
|
assert server.update_poco_completion_for_file(workspace)
|
||||||
|
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||||
|
return server, current, current_source
|
||||||
|
|
||||||
|
|
||||||
|
def _complete(document: TextDocument, position: lsp.Position):
|
||||||
|
return lsp_server.on_completion(
|
||||||
|
lsp.CompletionParams(
|
||||||
|
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||||
|
position=position,
|
||||||
|
)
|
||||||
|
).items
|
||||||
|
|
||||||
|
|
||||||
|
def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch):
|
||||||
|
_, current, source = _completion_server(tmp_path, monkeypatch)
|
||||||
|
items = _complete(current, _position_after(source, "$local"))
|
||||||
|
by_label = {item.label: item for item in items}
|
||||||
|
|
||||||
|
assert items
|
||||||
|
assert all(item.kind in VARIABLE_KINDS for item in items)
|
||||||
|
assert "localValue" in by_label
|
||||||
|
assert "globalValue" in by_label
|
||||||
|
assert "workspaceValue" in by_label
|
||||||
|
assert "localProc" not in by_label
|
||||||
|
assert "workspaceProc" not in by_label
|
||||||
|
assert "puts" not in by_label
|
||||||
|
|
||||||
|
declared_builtin = standard_items.nx_variables[0]
|
||||||
|
other_builtin = standard_items.nx_variables[1]
|
||||||
|
assert declared_builtin.label in by_label
|
||||||
|
assert other_builtin.label in by_label
|
||||||
|
assert by_label[declared_builtin.label].documentation == (
|
||||||
|
declared_builtin.documentation
|
||||||
|
)
|
||||||
|
assert by_label["localValue"].sort_text.startswith("000:")
|
||||||
|
assert by_label["globalValue"].sort_text.startswith("100:")
|
||||||
|
assert by_label["workspaceValue"].sort_text.startswith("200:")
|
||||||
|
assert by_label[declared_builtin.label].sort_text.startswith("100:")
|
||||||
|
assert by_label[other_builtin.label].sort_text.startswith("300:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch):
|
||||||
|
_, current, source = _completion_server(tmp_path, monkeypatch)
|
||||||
|
items = _complete(current, _position_after(source, "localP", occurrence=1))
|
||||||
|
by_label = {item.label: item for item in items}
|
||||||
|
|
||||||
|
assert items
|
||||||
|
assert all(item.kind in COMMAND_KINDS for item in items)
|
||||||
|
assert "localProc" in by_label
|
||||||
|
assert "workspaceProc" in by_label
|
||||||
|
assert "MOM_abort" in by_label
|
||||||
|
assert "puts" in by_label
|
||||||
|
assert "localValue" not in by_label
|
||||||
|
assert "globalValue" not in by_label
|
||||||
|
assert "workspaceValue" not in by_label
|
||||||
|
assert by_label["localProc"].sort_text.startswith("100:")
|
||||||
|
assert by_label["workspaceProc"].sort_text.startswith("200:")
|
||||||
|
assert by_label["MOM_abort"].sort_text.startswith("300:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_completion_context_handles_nested_commands_and_utf16():
|
||||||
|
assert (
|
||||||
|
completion_context(["set result [work"], lsp.Position(line=0, character=16))
|
||||||
|
== CompletionContext.COMMAND
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
completion_context(["😀 puts $value"], lsp.Position(line=0, character=14))
|
||||||
|
== CompletionContext.VARIABLE
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
completion_context(["puts value"], lsp.Position(line=0, character=10))
|
||||||
|
== CompletionContext.GENERAL
|
||||||
|
)
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
THIS_DIR = Path(__file__).parent
|
THIS_DIR = Path(__file__).parent
|
||||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||||
if str(SRC_DIR) not in sys.path:
|
if str(SRC_DIR) not in sys.path:
|
||||||
sys.path.insert(0, str(SRC_DIR))
|
sys.path.insert(0, str(SRC_DIR))
|
||||||
|
|
||||||
import lsprotocol.types as lsp # type: ignore
|
|
||||||
from pygls.workspace.text_document import TextDocument
|
|
||||||
|
|
||||||
import lsp_server
|
import lsp_server
|
||||||
|
import lsprotocol.types as lsp # type: ignore
|
||||||
from lsp_tclserver import TclLanguageServer
|
from lsp_tclserver import TclLanguageServer
|
||||||
|
from lsprotocol.converters import get_converter
|
||||||
|
from pygls.workspace.text_document import TextDocument
|
||||||
from tools.navigation import (
|
from tools.navigation import (
|
||||||
SymbolIdentity,
|
SymbolIdentity,
|
||||||
build_file_symbol_index,
|
build_file_symbol_index,
|
||||||
|
call_hierarchy_identity,
|
||||||
|
call_hierarchy_items,
|
||||||
definition_identities,
|
definition_identities,
|
||||||
|
incoming_call_hierarchy,
|
||||||
matching_occurrences,
|
matching_occurrences,
|
||||||
|
outgoing_call_hierarchy,
|
||||||
symbol_at_position,
|
symbol_at_position,
|
||||||
workspace_symbols,
|
workspace_symbols,
|
||||||
)
|
)
|
||||||
@@ -269,3 +272,191 @@ def test_duplicate_proc_definition_cannot_be_renamed(tmp_path: Path, monkeypatch
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_hierarchy_tracks_cross_file_event_calls(tmp_path: Path, monkeypatch):
|
||||||
|
library_source = """proc leaf {} { return }
|
||||||
|
namespace eval shop {
|
||||||
|
proc middle {} {
|
||||||
|
::leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
event_source = """proc MOM_linear_move {} {
|
||||||
|
::shop::middle
|
||||||
|
::shop::middle
|
||||||
|
puts done
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
bootstrap_source = "::shop::middle\n"
|
||||||
|
library = _document(tmp_path / "library.tcl", library_source)
|
||||||
|
event = _document(tmp_path / "event.tcl", event_source)
|
||||||
|
bootstrap = _document(tmp_path / "bootstrap.tcl", bootstrap_source)
|
||||||
|
server = TclLanguageServer(
|
||||||
|
name="call-hierarchy-test", version="1", max_workers=1
|
||||||
|
)
|
||||||
|
assert server.update_poco_completion_for_file(library)
|
||||||
|
assert server.update_poco_completion_for_file(event)
|
||||||
|
assert server.update_poco_completion_for_file(bootstrap)
|
||||||
|
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||||
|
|
||||||
|
prepared = lsp_server.prepare_call_hierarchy(
|
||||||
|
lsp.CallHierarchyPrepareParams(
|
||||||
|
text_document=lsp.TextDocumentIdentifier(uri=event.uri),
|
||||||
|
position=_position(event_source, "middle"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert prepared is not None
|
||||||
|
assert len(prepared) == 1
|
||||||
|
middle = prepared[0]
|
||||||
|
assert middle.name == "shop::middle"
|
||||||
|
assert middle.kind == lsp.SymbolKind.Function
|
||||||
|
assert middle.uri == library.uri
|
||||||
|
assert call_hierarchy_identity(middle) == SymbolIdentity(
|
||||||
|
kind="proc", name="::shop::middle"
|
||||||
|
)
|
||||||
|
|
||||||
|
incoming = lsp_server.incoming_calls(
|
||||||
|
lsp.CallHierarchyIncomingCallsParams(item=middle)
|
||||||
|
)
|
||||||
|
assert len(incoming) == 2
|
||||||
|
event_call = next(
|
||||||
|
call for call in incoming if call.from_.kind == lsp.SymbolKind.Event
|
||||||
|
)
|
||||||
|
file_call = next(
|
||||||
|
call for call in incoming if call.from_.kind == lsp.SymbolKind.File
|
||||||
|
)
|
||||||
|
assert event_call.from_.name == "MOM_linear_move"
|
||||||
|
assert len(event_call.from_ranges) == 2
|
||||||
|
assert file_call.from_.name == "bootstrap.tcl"
|
||||||
|
assert len(file_call.from_ranges) == 1
|
||||||
|
incoming_payload = get_converter().unstructure(event_call)
|
||||||
|
assert incoming_payload["from"]["name"] == "MOM_linear_move"
|
||||||
|
assert len(incoming_payload["fromRanges"]) == 2
|
||||||
|
assert incoming_payload["from"]["data"]["source"] == (
|
||||||
|
"nx-post-support.call-hierarchy"
|
||||||
|
)
|
||||||
|
|
||||||
|
outgoing = lsp_server.outgoing_calls(
|
||||||
|
lsp.CallHierarchyOutgoingCallsParams(item=middle)
|
||||||
|
)
|
||||||
|
assert len(outgoing) == 1
|
||||||
|
assert outgoing[0].to.name == "leaf"
|
||||||
|
assert _range_text(library_source, outgoing[0].from_ranges[0]) == "leaf"
|
||||||
|
|
||||||
|
event_item = event_call.from_
|
||||||
|
event_outgoing = lsp_server.outgoing_calls(
|
||||||
|
lsp.CallHierarchyOutgoingCallsParams(item=event_item)
|
||||||
|
)
|
||||||
|
assert len(event_outgoing) == 1
|
||||||
|
assert event_outgoing[0].to.name == "shop::middle"
|
||||||
|
assert len(event_outgoing[0].from_ranges) == 2
|
||||||
|
|
||||||
|
file_outgoing = lsp_server.outgoing_calls(
|
||||||
|
lsp.CallHierarchyOutgoingCallsParams(item=file_call.from_)
|
||||||
|
)
|
||||||
|
assert len(file_outgoing) == 1
|
||||||
|
assert file_outgoing[0].to.name == "shop::middle"
|
||||||
|
assert len(file_outgoing[0].from_ranges) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_hierarchy_ignores_dynamic_and_ambiguous_calls(tmp_path: Path):
|
||||||
|
caller_source = """proc caller {command} {
|
||||||
|
$command
|
||||||
|
duplicate
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
caller = _index(tmp_path / "caller.tcl", caller_source)
|
||||||
|
duplicate_a = _index(tmp_path / "duplicate_a.tcl", "proc duplicate {} {}\n")
|
||||||
|
duplicate_b = _index(tmp_path / "duplicate_b.tcl", "proc duplicate {} {}\n")
|
||||||
|
indexes = {
|
||||||
|
caller.path: caller,
|
||||||
|
duplicate_a.path: duplicate_a,
|
||||||
|
duplicate_b.path: duplicate_b,
|
||||||
|
}
|
||||||
|
definitions = definition_identities(indexes)
|
||||||
|
caller_identity = SymbolIdentity(kind="proc", name="::caller")
|
||||||
|
duplicate_identity = SymbolIdentity(kind="proc", name="::duplicate")
|
||||||
|
|
||||||
|
assert call_hierarchy_items(duplicate_identity, indexes) == []
|
||||||
|
assert (
|
||||||
|
incoming_call_hierarchy(duplicate_identity, indexes, definitions) == []
|
||||||
|
)
|
||||||
|
assert outgoing_call_hierarchy(caller_identity, indexes, definitions) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_hierarchy_item_uses_whole_proc_range(tmp_path: Path):
|
||||||
|
source = """proc multiline {} {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
index = _index(tmp_path / "range.tcl", source)
|
||||||
|
items = call_hierarchy_items(
|
||||||
|
SymbolIdentity(kind="proc", name="::multiline"),
|
||||||
|
{index.path: index},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].selection_range.start == lsp.Position(line=0, character=5)
|
||||||
|
assert items[0].range.start == lsp.Position(line=0, character=0)
|
||||||
|
assert items[0].range.end.line == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_highlight_marks_local_reads_and_writes(tmp_path: Path, monkeypatch):
|
||||||
|
source = """proc first {} {
|
||||||
|
set value 1
|
||||||
|
puts $value
|
||||||
|
incr value
|
||||||
|
}
|
||||||
|
proc second {} {
|
||||||
|
set value 2
|
||||||
|
puts $value
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
document = _document(tmp_path / "highlights.tcl", source)
|
||||||
|
server = TclLanguageServer(name="highlight-test", version="1", max_workers=1)
|
||||||
|
assert server.update_poco_completion_for_file(document)
|
||||||
|
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||||
|
position = _position(source, "$value")
|
||||||
|
|
||||||
|
highlights = lsp_server.document_highlight(
|
||||||
|
lsp.DocumentHighlightParams(
|
||||||
|
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||||
|
position=lsp.Position(position.line, position.character + 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(highlights) == 3
|
||||||
|
assert [highlight.kind for highlight in highlights] == [
|
||||||
|
lsp.DocumentHighlightKind.Write,
|
||||||
|
lsp.DocumentHighlightKind.Read,
|
||||||
|
lsp.DocumentHighlightKind.Write,
|
||||||
|
]
|
||||||
|
assert all(_range_text(source, highlight.range) == "value" for highlight in highlights)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_highlight_marks_proc_definition_and_calls(tmp_path: Path, monkeypatch):
|
||||||
|
source = """proc target {} { return }
|
||||||
|
proc caller {} {
|
||||||
|
target
|
||||||
|
target
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
document = _document(tmp_path / "proc_highlights.tcl", source)
|
||||||
|
server = TclLanguageServer(name="highlight-test", version="1", max_workers=1)
|
||||||
|
assert server.update_poco_completion_for_file(document)
|
||||||
|
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||||
|
|
||||||
|
highlights = lsp_server.document_highlight(
|
||||||
|
lsp.DocumentHighlightParams(
|
||||||
|
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
|
||||||
|
position=_position(source, "target"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(highlights) == 3
|
||||||
|
assert all(
|
||||||
|
highlight.kind == lsp.DocumentHighlightKind.Text
|
||||||
|
for highlight in highlights
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user