feat(server): index PSC scripts and provide cross-file TclOO navigation/completions

Add PSC (.psc) indexing and share TclOO class metadata across files so class
definitions discovered via PSC layers can be used for completions, signature
help, inlay hints, and "go to definition". Key behavior changes:

- Client file watcher now includes *.psc and .vscode launch paths/tests updated
  to use the postprocessor test folder; .gitignore updated to ignore that folder.
- Server watches .psc changes and refreshes a PSC script index; new
  tools/tcloo_navigation.py exposes tcloo_definition used by the language server
  to resolve cross-file class/constructor/method definitions.
- Language server uses class_snapshot(document.path) when producing TclOO
  completions, signature help, and inlay hints so resolved class metadata is
  available across files.

Also includes related docs/changelog updates, minor code formatting cleanups,
and added tests for PSC/TclOO behavior.
This commit is contained in:
Christoph Brandau
2026-09-21 20:47:51 +02:00
parent f88a50d4ab
commit 757b885f28
21 changed files with 699 additions and 378 deletions
+102 -177
View File
@@ -79,6 +79,7 @@ from tools.tcloo_arguments import method_signature_help
from tclint.lexer import TclSyntaxError
from tools.tcloo_completion import parse_completion_source, tcloo_completions
from tools.tcloo_symbols import class_completion_items
from tools.tcloo_navigation import tcloo_definition
from tools.tcl_command_completion import (
TCL_COMMAND_ITEMS,
TCL_COMMAND_NAMES,
@@ -92,40 +93,18 @@ GLOBAL_SETTINGS = {}
MAX_WORKERS = 5
LSP_SERVER = TclLanguageServer(
name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS
)
LSP_SERVER = TclLanguageServer(name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS)
BUILTIN_PROC_NAMES = {
item.label
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
} | set(TCL_COMMAND_NAMES)
BUILTIN_PROC_NAMES = {item.label for item in standard_items.tcl_keyword_list + standard_items.nx_procs} | set(TCL_COMMAND_NAMES)
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
_TCL_COMMAND_ITEMS_BY_LABEL = {
item.label: item for item in TCL_COMMAND_ITEMS
}
_TCL_KEYWORD_ITEMS = [
_TCL_COMMAND_ITEMS_BY_LABEL.get(item.label, item)
for item in standard_items.tcl_keyword_list
]
_TCL_COMMAND_ITEMS_BY_LABEL = {item.label: item for item in TCL_COMMAND_ITEMS}
_TCL_KEYWORD_ITEMS = [_TCL_COMMAND_ITEMS_BY_LABEL.get(item.label, item) for item in standard_items.tcl_keyword_list]
_STATIC_TCL_LABELS = {item.label for item in standard_items.tcl_keyword_list}
STATIC_COMPLETION_ITEMS = tuple(
_TCL_KEYWORD_ITEMS
+ [item for item in TCL_COMMAND_ITEMS if item.label not in _STATIC_TCL_LABELS]
+ standard_items.nx_procs
+ standard_items.nx_variables
)
STATIC_VARIABLE_ITEMS = {
item.label: item for item in standard_items.nx_variables
}
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(
standard_items.json_data.get("MOM_procs", [])
)
STATIC_COMPLETION_ITEMS = tuple(_TCL_KEYWORD_ITEMS + [item for item in TCL_COMMAND_ITEMS if item.label not in _STATIC_TCL_LABELS] + standard_items.nx_procs + standard_items.nx_variables)
STATIC_VARIABLE_ITEMS = {item.label: item for item in standard_items.nx_variables}
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(standard_items.json_data.get("MOM_procs", []))
BUILTIN_HOVER_ITEMS = {}
for _hover_item in (
standard_items.json_data.get("MOM_procs", [])
+ standard_items.json_data.get("mom_variables", [])
):
for _hover_item in standard_items.json_data.get("MOM_procs", []) + standard_items.json_data.get("mom_variables", []):
BUILTIN_HOVER_ITEMS.setdefault(_hover_item.get("label"), _hover_item)
# **********************************************************
@@ -236,12 +215,23 @@ def did_rename_files(params: lsp.RenameFilesParams) -> None:
def did_change_watched_files(params: lsp.DidChangeWatchedFilesParams) -> None:
"""Keep indexes for closed Tcl files synchronized with disk changes."""
for change in params.changes:
if pathlib.Path(uris.to_fs_path(change.uri)).suffix.lower() == ".psc":
_refresh_psc_index()
continue
if change.type == lsp.FileChangeType.Deleted:
LSP_SERVER.remove_file_state(change.uri)
else:
_index_tcl_file_from_disk(change.uri)
def _refresh_psc_index():
folders = LSP_SERVER.workspace.folders
roots = [pathlib.Path(uris.to_fs_path(uri)) for uri in folders]
if not roots and LSP_SERVER.workspace.root_path:
roots = [pathlib.Path(LSP_SERVER.workspace.root_path)]
LSP_SERVER.refresh_psc_scripts(roots, report=log_warning)
@LSP_SERVER.feature(
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
lsp.DiagnosticOptions(
@@ -255,9 +245,7 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
uri = params.text_document.uri
doc = LSP_SERVER.workspace.get_text_document(uri)
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
was_cached = (
diagnostic_state is not None and diagnostic_state[0] == doc.version
)
was_cached = diagnostic_state is not None and diagnostic_state[0] == doc.version
if not was_cached:
LSP_SERVER.compute_diagnostics(doc)
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
@@ -282,11 +270,13 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
position = params.position
source_lines = LSP_SERVER.get_lines(doc)
oo_items = tcloo_completions(source_lines, position)
oo_items = tcloo_completions(source_lines, position, LSP_SERVER.class_snapshot(doc.path))
if oo_items is not None:
return lsp.CompletionList(is_incomplete=False, items=oo_items)
array_items = array_element_completions(
source_lines, position, LSP_SERVER.navigation_snapshot().values(),
source_lines,
position,
LSP_SERVER.navigation_snapshot().values(),
str(pathlib.Path(uris.to_fs_path(doc.uri))),
)
if array_items is not None:
@@ -298,20 +288,14 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
argument_completion = None
if context != CompletionContext.VARIABLE:
argument_completion = tcl_argument_completion(source_lines, position)
if (
argument_completion is not None
and argument_completion.dynamic_kind is None
):
if argument_completion is not None and argument_completion.dynamic_kind is None:
items = ranked_completion_items(
((0, item) for item in argument_completion.items),
CompletionContext.GENERAL,
)
return lsp.CompletionList(is_incomplete=False, items=items)
if (
argument_completion is not None
and argument_completion.dynamic_kind == DynamicCompletionKind.PATH
):
if argument_completion is not None and argument_completion.dynamic_kind == DynamicCompletionKind.PATH:
dynamic_items: tuple[lsp.CompletionItem, ...] = ()
if doc.uri.startswith("file:"):
document_path = pathlib.Path(uris.to_fs_path(doc.uri))
@@ -320,9 +304,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
argument_completion,
position,
)
path_candidates = [
(0, item) for item in argument_completion.items
]
path_candidates = [(0, item) for item in argument_completion.items]
path_candidates.extend((10, item) for item in dynamic_items)
items = ranked_completion_items(
path_candidates,
@@ -335,8 +317,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
if (
argument_completion is None
and params.context is not None
and params.context.trigger_kind
== lsp.CompletionTriggerKind.TriggerCharacter
and params.context.trigger_kind == lsp.CompletionTriggerKind.TriggerCharacter
and params.context.trigger_character in {" ", "-", "(", ","}
):
return lsp.CompletionList(is_incomplete=False, items=[])
@@ -347,9 +328,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
tree = parse_completion_source(doc.source)
if tree is None:
return lsp.CompletionList(is_incomplete=False, items=[])
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
doc, tree
)
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(doc, tree)
local_names: set[str] = set()
for proc_range in proc_ranges:
@@ -361,16 +340,14 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
candidates: list[tuple[int, lsp.CompletionItem]] = []
candidates.extend((0, item) for item in class_completion_items(tree))
for name in sorted(local_names - globals_set):
candidates.append(
(
0,
lsp.CompletionItem(
label=name,
kind=lsp.CompletionItemKind.Variable,
detail="Local variable",
),
)
)
candidates.append((
0,
lsp.CompletionItem(
label=name,
kind=lsp.CompletionItemKind.Variable,
detail="Local variable",
),
))
for name in sorted(globals_set):
item = STATIC_VARIABLE_ITEMS.get(name)
@@ -380,12 +357,10 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
kind=lsp.CompletionItemKind.Variable,
detail="Workspace variable",
)
candidates.append(
(
100,
item,
)
)
candidates.append((
100,
item,
))
filepath = str(pathlib.Path(uris.to_fs_path(doc.uri)))
items_by_file = LSP_SERVER.completion_items_by_file_snapshot()
@@ -396,14 +371,15 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
if argument_completion is not None:
static_candidates = [(0, item) for item in argument_completion.items]
if argument_completion.dynamic_kind == DynamicCompletionKind.VARIABLE:
variable_candidates = [*static_candidates, *candidates]
variable_candidates.extend(
(300, item) for item in standard_items.nx_variables
)
variable_candidates = list(candidates)
variable_candidates.extend((300, item) for item in standard_items.nx_variables)
items = ranked_completion_items(
variable_candidates,
CompletionContext.VARIABLE,
)
# Command options are valid alongside variables, even though they
# are keywords and must not pass through the variable-only filter.
items = [*ranked_completion_items(static_candidates, CompletionContext.GENERAL), *items]
return lsp.CompletionList(is_incomplete=False, items=items)
if argument_completion.dynamic_kind == DynamicCompletionKind.PROCEDURE:
@@ -412,14 +388,8 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
lsp.CompletionItemKind.Function,
lsp.CompletionItemKind.Method,
}
procedure_candidates = [
(priority, item)
for priority, item in candidates
if item.kind in procedure_kinds
]
procedure_candidates.extend(
(300, item) for item in standard_items.nx_procs
)
procedure_candidates = [(priority, item) for priority, item in candidates if item.kind in procedure_kinds]
procedure_candidates.extend((300, item) for item in standard_items.nx_procs)
items = ranked_completion_items(
[*static_candidates, *procedure_candidates],
CompletionContext.GENERAL,
@@ -430,26 +400,20 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
namespace_candidates = list(static_candidates)
prefix_is_absolute = argument_completion.active_prefix.startswith("::")
for index in LSP_SERVER.navigation_snapshot().values():
priority = (
100
if LSP_SERVER.paths_equal(index.path, filepath)
else 200
)
priority = 100 if LSP_SERVER.paths_equal(index.path, filepath) else 200
for occurrence in index.occurrences:
if occurrence.identity.kind != "namespace":
continue
name = occurrence.identity.name
label = name if prefix_is_absolute else name.removeprefix("::")
namespace_candidates.append(
(
priority,
lsp.CompletionItem(
label=label,
kind=lsp.CompletionItemKind.Module,
detail="Tcl namespace",
),
)
)
namespace_candidates.append((
priority,
lsp.CompletionItem(
label=label,
kind=lsp.CompletionItemKind.Module,
detail="Tcl namespace",
),
))
items = ranked_completion_items(
namespace_candidates,
CompletionContext.GENERAL,
@@ -470,7 +434,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
)
def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None:
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
method_help = method_signature_help(document.source, params.position)
method_help = method_signature_help(document.source, params.position, LSP_SERVER.class_snapshot(document.path))
if method_help is not None:
return method_help
try:
@@ -480,9 +444,7 @@ def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None:
if tree is None:
return None
custom_signatures, custom_docs = LSP_SERVER.proc_metadata_snapshot(
document.path
)
custom_signatures, custom_docs = LSP_SERVER.proc_metadata_snapshot(document.path)
return build_signature_help(
document.source,
@@ -528,20 +490,17 @@ def inlay_hints(params: lsp.InlayHintParams):
# Built-in NX procedures are the fallback. Workspace procedures replace them,
# and a declaration in the current file wins over duplicate workspace names.
custom_signatures = LSP_SERVER.custom_inlay_signatures_snapshot(
document.path
)
custom_signatures = LSP_SERVER.custom_inlay_signatures_snapshot(document.path)
signatures = ChainMap(custom_signatures, BUILTIN_INLAY_SIGNATURES)
generator = InlayHintGenerator(
document.source,
signatures,
external_classes=LSP_SERVER.class_snapshot(document.path),
source_lines=LSP_SERVER.get_lines(document),
requested_range=params.range,
parameter_names=parameter_names,
suppress_when_argument_matches_name=inlay_settings.get(
"suppressWhenArgumentMatchesName", True
),
suppress_when_argument_matches_name=inlay_settings.get("suppressWhenArgumentMatchesName", True),
)
return generator.generate(tree)
@@ -562,20 +521,20 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
# Reuse cached AST
tree = LSP_SERVER.get_tree(document)
hl.highlight_classes(tree)
classes = LSP_SERVER.class_snapshot(document.path)
hl.highlight_classes(tree, classes)
hl.highlight_methods(tree, document.source, document.uri, classes)
tree.accept(hl, recurse=True)
tokens = hl.tokens()
for token in tokens:
data.extend(
[
token.line,
token.offset,
token.length,
TOKEN_TYPE_INDEX[token.tok_type],
reduce(operator.or_, token.tok_modifiers, 0),
]
)
data.extend([
token.line,
token.offset,
token.length,
TOKEN_TYPE_INDEX[token.tok_type],
reduce(operator.or_, token.tok_modifiers, 0),
])
return lsp.SemanticTokens(data=data)
@@ -617,9 +576,7 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
if match and match.get("kind") == "function":
label = match.get("label", "")
parameters = match.get("parameters", [])
param_lines = (
"\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
)
param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
example_data = match.get("example", [])
example_md = "\n".join(f"{line}" for line in example_data)
returns_data = match.get("returns", ["None"])
@@ -648,28 +605,30 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
# Build a merged map of proc -> docs gathered during initialization and updates
proc_doc = LSP_SERVER.proc_documentation(token, document.path)
if proc_doc is not None:
return lsp.Hover(
lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_doc)
)
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_doc))
return None
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
def goto_definition(params: lsp.DefinitionParams):
"""Resolve Tcl proc and variable definitions through the symbol index."""
"""Resolve TclOO declarations, then indexed proc and variable definitions."""
try:
workspace = LSP_SERVER.workspace
except RuntimeError:
workspace = None
if workspace is not None:
document = workspace.get_text_document(params.text_document.uri)
target = tcloo_definition(document.source, document.uri, params.position,
LSP_SERVER.class_snapshot(document.path))
if target is not None:
return [target]
context = _navigation_context(params.text_document.uri, params.position)
if context is None:
return None
indexes, definitions, _, identity = context
locations = [
lsp.Location(uri=index.uri, range=occurrence.range)
for index, occurrence in matching_occurrences(
identity, indexes, definitions
)
if occurrence.is_definition
]
locations = [lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in matching_occurrences(identity, indexes, definitions) if occurrence.is_definition]
return _sorted_locations(locations) or None
@@ -713,9 +672,7 @@ def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
indexes, definitions, _, identity = context
locations = [
lsp.Location(uri=index.uri, range=occurrence.range)
for index, occurrence in matching_occurrences(
identity, indexes, definitions
)
for index, occurrence in matching_occurrences(identity, indexes, definitions)
if params.context.include_declaration or not occurrence.is_definition
]
return _sorted_locations(locations)
@@ -747,11 +704,7 @@ def _is_renamable(
if identity.kind == "proc":
if basename in BUILTIN_PROC_NAMES:
return False
definition_count = sum(
occurrence.is_definition and occurrence.identity == identity
for index in indexes.values()
for occurrence in index.occurrences
)
definition_count = sum(occurrence.is_definition and occurrence.identity == identity for index in indexes.values() for occurrence in index.occurrences)
return definition_count == 1
return basename not in BUILTIN_VARIABLE_NAMES
@@ -766,9 +719,7 @@ def prepare_rename(params: lsp.PrepareRenameParams):
indexes, definitions, occurrence, identity = context
if not _is_renamable(identity, indexes, definitions):
return None
return lsp.PrepareRenamePlaceholder(
range=occurrence.range, placeholder=occurrence.placeholder
)
return lsp.PrepareRenamePlaceholder(range=occurrence.range, placeholder=occurrence.placeholder)
@LSP_SERVER.feature(
@@ -800,9 +751,7 @@ def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
if key in seen:
continue
seen.add(key)
changes.setdefault(index.uri, []).append(
lsp.TextEdit(range=occurrence.range, new_text=params.new_name)
)
changes.setdefault(index.uri, []).append(lsp.TextEdit(range=occurrence.range, new_text=params.new_name))
for edits in changes.values():
edits.sort(
@@ -913,12 +862,8 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
settings = params.initialization_options["settings"]
_update_workspace_settings(settings)
log_to_output(
f"Settings used to run Server:\r\n{json.dumps(settings, indent=4, ensure_ascii=False)}\r\n"
)
log_to_output(
f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n"
)
log_to_output(f"Settings used to run Server:\r\n{json.dumps(settings, indent=4, ensure_ascii=False)}\r\n")
log_to_output(f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n")
semantic_tokens_legend = lsp.SemanticTokensLegend(
token_types=TOKEN_TYPES,
token_modifiers=[m.name for m in TokenModifier],
@@ -927,9 +872,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
capabilities=lsp.ServerCapabilities(
document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True),
folding_range_provider=True,
semantic_tokens_provider=lsp.SemanticTokensOptions(
legend=semantic_tokens_legend, full=True, range=False
),
semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False),
definition_provider=True,
document_highlight_provider=True,
references_provider=True,
@@ -963,19 +906,10 @@ def initialized(_params: lsp.InitializedParams):
"node_modules",
"out",
}
tcl_files = (
path
for path in root_path.rglob("*.tcl")
if not any(
part.casefold() in skipped_directories
for part in path.relative_to(root_path).parts[:-1]
)
)
tcl_files = (path for path in root_path.rglob("*.tcl") if not any(part.casefold() in skipped_directories for part in path.relative_to(root_path).parts[:-1]))
for filepath in sorted(tcl_files, key=lambda path: str(path).casefold()):
try:
document = TextDocument(
uri=filepath.as_uri(), language_id="tcl"
)
document = TextDocument(uri=filepath.as_uri(), language_id="tcl")
LSP_SERVER.update_poco_completion_for_file(
document,
cache_tree=False,
@@ -983,6 +917,7 @@ def initialized(_params: lsp.InitializedParams):
)
except Exception as error:
log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
_refresh_psc_index()
log_to_output("Background indexing completed.")
except Exception as e:
log_to_output(f"Background indexing failed: {e}")
@@ -1089,36 +1024,26 @@ def _get_settings_by_document(document: TextDocument | None):
# *****************************************************
# Logging and notification.
# *****************************************************
def log_to_output(
message: str, msg_type: lsp.MessageType = lsp.MessageType.Log
) -> None:
LSP_SERVER.window_log_message(
lsp.LogMessageParams(message=message, type=msg_type)
)
def log_to_output(message: str, msg_type: lsp.MessageType = lsp.MessageType.Log) -> None:
LSP_SERVER.window_log_message(lsp.LogMessageParams(message=message, type=msg_type))
def log_error(message: str) -> None:
log_to_output(message, lsp.MessageType.Error)
if os.getenv("LS_SHOW_NOTIFICATION", "off") in ["onError", "onWarning", "always"]:
LSP_SERVER.window_show_message(
lsp.ShowMessageParams(message=message, type=lsp.MessageType.Error)
)
LSP_SERVER.window_show_message(lsp.ShowMessageParams(message=message, type=lsp.MessageType.Error))
def log_warning(message: str) -> None:
log_to_output(message, lsp.MessageType.Warning)
if os.getenv("LS_SHOW_NOTIFICATION", "off") in ["onWarning", "always"]:
LSP_SERVER.window_show_message(
lsp.ShowMessageParams(message=message, type=lsp.MessageType.Warning)
)
LSP_SERVER.window_show_message(lsp.ShowMessageParams(message=message, type=lsp.MessageType.Warning))
def log_always(message: str) -> None:
log_to_output(message, lsp.MessageType.Info)
if os.getenv("LS_SHOW_NOTIFICATION", "off") in ["always"]:
LSP_SERVER.window_show_message(
lsp.ShowMessageParams(message=message, type=lsp.MessageType.Info)
)
LSP_SERVER.window_show_message(lsp.ShowMessageParams(message=message, type=lsp.MessageType.Info))
# *****************************************************
+79
View File
@@ -2,6 +2,7 @@ import logging
import os
import pathlib
import threading
import xml.etree.ElementTree as ET
from typing import List, Optional, Tuple
import lsprotocol.types as lsp
@@ -15,6 +16,8 @@ from tclint.violations import Violation
from tools import checks, parser
from tools.completion_items import CompletionCollector
from tools.tcloo_symbols import class_completion_items
from tools.tcloo_completion import indexed_classes
from tools.file_sourcing import get_all_psc_files, psc_script_files
from tools.formatter import NxFormatter as Formatter
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index
@@ -35,6 +38,10 @@ class TclLanguageServer(LanguageServer):
self.poco_completion: dict = {}
self.proc_signatures: dict = {}
self.proc_docs: dict = {}
self.class_indexes: dict = {}
self.psc_script_paths: list[str] = []
self._psc_files: dict[str, list[pathlib.Path]] = {}
self._psc_lock = threading.RLock()
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
self.variable_indexes: dict[
str,
@@ -171,6 +178,74 @@ class TclLanguageServer(LanguageServer):
self._workspace_completion_cache = (self._index_generation, items)
return items
def class_snapshot(self, current_path) -> dict:
"""Share class metadata, without stale definitions from the active file.
PSC scripts override other workspace files in their listed load order.
The request parser adds the current document's declarations last.
"""
with self._index_lock:
indexes = {self._normalized_path(path): classes for path, classes in self.class_indexes.items()}
paths = sorted(self.class_indexes, key=str.casefold)
psc_paths = {self._normalized_path(path) for path in self.psc_script_paths}
paths = [path for path in paths if self._normalized_path(path) not in psc_paths]
paths.extend(self.psc_script_paths)
classes = {}
for path in paths:
if not self.paths_equal(path, current_path):
classes.update(indexes.get(self._normalized_path(path), {}))
return classes
def refresh_psc_scripts(self, roots, report=LOGGER.warning):
"""Index PSC dependencies through the same pipeline as workspace procs."""
with self._psc_lock:
discovered = {}
for root in roots:
for psc in get_all_psc_files(root):
try:
discovered[str(psc)] = psc_script_files(psc)
except (OSError, ET.ParseError) as error:
report(f"Could not read PSC {psc}: {error}")
discovered[str(psc)] = self._psc_files.get(str(psc), [])
paths = [str(path) for scripts in discovered.values() for path in scripts]
with self._index_lock:
previous = set(self.psc_script_paths)
self.psc_script_paths = paths
self._psc_files = discovered
try:
open_documents = {
self._normalized_path(document.path): document
for document in self.workspace.text_documents.values()
}
except RuntimeError:
open_documents = {}
for removed in previous - set(paths):
path = pathlib.Path(removed)
if (not any(self._is_same_or_child(path, root) for root in roots)
and self._normalized_path(path) not in open_documents):
self.remove_file_state(path.as_uri())
for path_string in dict.fromkeys(paths):
path = pathlib.Path(path_string)
uri = path.as_uri()
document = open_documents.get(self._normalized_path(path))
if document is None and not path.is_file():
self.remove_file_state(uri)
report(f"PSC script not found: {path}")
continue
try:
if document is None:
data = path.read_bytes()
try:
source = data.decode("utf-8-sig")
except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page.
source = data.decode("cp1252")
document = TextDocument(uri=uri, source=source, language_id="tcl")
if not self.update_poco_completion_for_file(document, cache_tree=False):
report(f"Could not index PSC script: {path}")
except (OSError, UnicodeError) as error:
report(f"Could not read PSC script {path}: {error}")
def completion_items_by_file_snapshot(
self,
) -> dict[str, tuple[lsp.CompletionItem, ...]]:
@@ -455,6 +530,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion.pop(filepath, None)
self.proc_signatures.pop(filepath, None)
self.proc_docs.pop(filepath, None)
self.class_indexes.pop(filepath, None)
self.navigation_indexes.pop(filepath, None)
self.variable_indexes.pop(filepath, None)
self._committed_index_versions.pop(filepath, None)
@@ -486,6 +562,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion,
self.proc_signatures,
self.proc_docs,
self.class_indexes,
self.navigation_indexes,
self.variable_indexes,
self._index_tokens,
@@ -545,6 +622,7 @@ class TclLanguageServer(LanguageServer):
)
tree.accept(collector, recurse=True)
collector.custom_functions.extend(class_completion_items(tree))
classes = indexed_classes(tree, document.uri, document.source)
docs = build_proc_docs(tree, document.source)
navigation_index = build_file_symbol_index(
filepath, document.uri, tree
@@ -565,6 +643,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion[filepath] = list(collector.custom_functions)
self.proc_signatures[filepath] = dict(collector.proc_signatures)
self.proc_docs[filepath] = docs
self.class_indexes[filepath] = classes
self.navigation_indexes[filepath] = navigation_index
self.variable_indexes[filepath] = (document.version, variable_index)
self._committed_index_versions[filepath] = document.version
+22 -10
View File
@@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
import os
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
@@ -14,6 +15,9 @@ class SourcedFile:
def read_psc_file(psc_file: Path) -> List[SourcedFile]:
tree = ET.parse(psc_file)
root = tree.getroot()
# PSC exports may use a default XML namespace.
for element in root.iter():
element.tag = element.tag.rsplit("}", 1)[-1]
layers = root.findall(".//Layer")
@@ -37,15 +41,23 @@ def read_psc_file(psc_file: Path) -> List[SourcedFile]:
def get_all_psc_files(root_path: Path) -> list[Path]:
return [path for path in root_path.rglob("*.psc")]
return sorted(root_path.rglob("*.psc"), key=lambda path: str(path).casefold())
if __name__ == "__main__":
test = get_all_psc_files(
Path(
r"H:\janus-engineering-customers\KSB_Frankenthal\custom\library\machine\installed_machines\ksb_pe_grob_g550_sone\postprocessor"
)
)
print(test)
for pp in test:
read_psc_file(pp)
def psc_script_files(psc_file: Path) -> list[Path]:
"""Resolve layer script paths relative to the PSC, preserving load order."""
def expanded(value):
return Path(os.path.expandvars(value).replace("\\", "/"))
paths = []
for layer in read_psc_file(psc_file):
folder = layer.subfolder or "."
base = psc_file.parent / expanded(os.environ.get(folder, folder))
for name in layer.files:
filename = expanded(name)
if not filename.suffix:
filename = filename.with_suffix(".tcl")
path = (base / filename).resolve()
if path.suffix.lower() == ".tcl":
paths.append(path)
return paths
+3 -1
View File
@@ -158,11 +158,13 @@ class InlayHintGenerator(Visitor):
requested_range: lsp.Range | None = None,
parameter_names: str = "all",
suppress_when_argument_matches_name: bool = True,
external_classes=None,
):
self.source_lines = (
source_lines if source_lines is not None else source.splitlines()
)
self.source = source
self.external_classes = external_classes
self.proc_signatures = proc_signatures
self.requested_range = requested_range
self.parameter_names = parameter_names
@@ -198,7 +200,7 @@ class InlayHintGenerator(Visitor):
walk(tree)
if self.parameter_names != "none":
for call in resolved_method_calls(self.source):
for call in resolved_method_calls(self.source, self.external_classes):
if not self._node_intersects_requested_range(call.command):
continue
parameters = method_parameters(call.parameters)
+35 -11
View File
@@ -7,6 +7,7 @@ from tclint.commands.plugins import PluginManager
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor
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
@@ -72,12 +73,9 @@ class _Highlighter(Visitor):
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
)
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)
@@ -86,15 +84,18 @@ class _Highlighter(Visitor):
return
self._tokens.append((position, length, tok_type, modifiers or []))
def highlight_classes(self, tree):
declarations, references = class_symbols(tree)
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,
(line - 1, col - 1),
len(node.contents),
"class",
modifiers,
)
def _get_token_info(self, node):
@@ -124,6 +125,28 @@ class _Highlighter(Visitor):
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
@@ -170,7 +193,7 @@ class _Highlighter(Visitor):
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 == "set" and command.args:
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:
@@ -217,8 +240,9 @@ class _Highlighter(Visitor):
tokens = []
last_line = 0
last_col = 0
raw_tokens = [token for token in self._tokens if token[0] not in self._class_tokens]
raw_tokens.extend(self._class_tokens.values())
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
+44 -136
View File
@@ -284,6 +284,7 @@ def _options(*labels: str) -> tuple[OptionSpec, ...]:
OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
("unset",): _options("nocomplain"),
("binary", "decode", "base64"): (OptionSpec("-strict"),),
("binary", "encode", "base64"): (
OptionSpec("-maxlen", takes_value=True),
@@ -437,109 +438,51 @@ _REPEATED_SUBCOMMAND_ARGUMENTS = frozenset(range(2, 33))
DYNAMIC_COMPLETION_RULES = (
# Variable-taking commands.
DynamicCompletionRule(("append",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("incr",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("lappend",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("set",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(
("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("vwait",), frozenset({1}), DynamicCompletionKind.VARIABLE),
# Procedure-taking commands.
DynamicCompletionRule(
("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
# Namespace-taking commands.
DynamicCompletionRule(
("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(
("namespace", "delete"),
_REPEATED_SUBCOMMAND_ARGUMENTS,
DynamicCompletionKind.NAMESPACE,
),
DynamicCompletionRule(
("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(
("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(
("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
# Path-taking commands. Source files are narrowed to Tcl while directories
# remain visible so users can continue navigating.
DynamicCompletionRule(("cd",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule(
("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")
),
DynamicCompletionRule(("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")),
DynamicCompletionRule(("open",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule(
("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)
),
DynamicCompletionRule(("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)),
*(
DynamicCompletionRule(
("file", subcommand),
@@ -666,12 +609,7 @@ SUBCOMMAND_SNIPPET_ITEMS = {
TCL_COMMAND_NAMES = tuple(
sorted(
{path[0] for path in SUBCOMMANDS_BY_PATH}
| {path[0] for path in OPTIONS_BY_PATH}
| set(TCL_COMMAND_SNIPPET_ITEMS)
| {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES}
)
sorted({path[0] for path in SUBCOMMANDS_BY_PATH} | {path[0] for path in OPTIONS_BY_PATH} | set(TCL_COMMAND_SNIPPET_ITEMS) | {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES})
)
TCL_COMMAND_ITEMS = tuple(
@@ -688,9 +626,7 @@ TCL_COMMAND_ITEMS = tuple(
)
def line_prefix_at_position(
source_lines: Sequence[str], position: Position
) -> str | None:
def line_prefix_at_position(source_lines: Sequence[str], position: Position) -> str | None:
"""Return the current line before an LSP UTF-16 position."""
if position.line < 0 or position.line >= len(source_lines):
@@ -703,9 +639,7 @@ def line_prefix_at_position(
return line[:codepoint_offset]
def tcl_argument_completion(
source_lines: Sequence[str], position: Position
) -> TclArgumentCompletion | None:
def tcl_argument_completion(source_lines: Sequence[str], position: Position) -> TclArgumentCompletion | None:
"""Describe static and dynamic argument completion at ``position``.
``None`` means that the cursor is not at a command-specific completion
@@ -771,7 +705,7 @@ def tcl_argument_completion(
active_prefix,
)
if option_completion is not None:
if active_prefix.startswith("-"):
if active_prefix.startswith("-") or (path == ("unset",) and active_index == 1 and not active_prefix):
return option_completion
return _merge_dynamic_completion(
(*argument_items, *option_completion.items),
@@ -780,9 +714,7 @@ def tcl_argument_completion(
)
if argument_items:
return _merge_dynamic_completion(
argument_items, dynamic_completion, active_prefix
)
return _merge_dynamic_completion(argument_items, dynamic_completion, active_prefix)
return dynamic_completion
@@ -827,9 +759,7 @@ def _option_completion(
if active_prefix and not active_prefix.startswith("-"):
return None
remaining_options = tuple(
option.label for option in options if option.label not in used_options
)
remaining_options = tuple(option.label for option in options if option.label not in used_options)
return TclArgumentCompletion(
items=_completion_items(
remaining_options,
@@ -840,16 +770,9 @@ def _option_completion(
)
def _dynamic_completion(
words: Sequence[str], active_index: int, active_prefix: str
) -> TclArgumentCompletion | None:
for rule in sorted(
DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True
):
if (
active_index in rule.argument_indices
and tuple(words[: len(rule.path)]) == rule.path
):
def _dynamic_completion(words: Sequence[str], active_index: int, active_prefix: str) -> TclArgumentCompletion | None:
for rule in sorted(DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True):
if active_index in rule.argument_indices and tuple(words[: len(rule.path)]) == rule.path:
return TclArgumentCompletion(
dynamic_kind=rule.kind,
active_prefix=active_prefix,
@@ -873,9 +796,7 @@ def _merge_dynamic_completion(
)
def _completion_items(
labels: Sequence[str], kind: CompletionItemKind, detail: str
) -> tuple[CompletionItem, ...]:
def _completion_items(labels: Sequence[str], kind: CompletionItemKind, detail: str) -> tuple[CompletionItem, ...]:
return tuple(
CompletionItem(
label=label,
@@ -913,9 +834,7 @@ def path_completion_items(
except (OSError, ValueError):
return ()
allowed_extensions = {
extension.casefold() for extension in completion.path_extensions
}
allowed_extensions = {extension.casefold() for extension in completion.path_extensions}
replace_start = max(
0,
position.character - len(raw_prefix.encode("utf-16-le")) // 2,
@@ -933,28 +852,17 @@ def path_completion_items(
is_directory = entry.is_dir()
except OSError:
continue
if (
not is_directory
and allowed_extensions
and entry.suffix.casefold() not in allowed_extensions
):
if not is_directory and allowed_extensions and entry.suffix.casefold() not in allowed_extensions:
continue
escaped_name = "".join(
f"\\{character}" if character.isspace() else character
for character in entry.name
)
escaped_name = "".join(f"\\{character}" if character.isspace() else character for character in entry.name)
new_text = f"{normalized_directory}{escaped_name}"
if is_directory:
new_text += "/"
items.append(
CompletionItem(
label=new_text,
kind=(
CompletionItemKind.Folder
if is_directory
else CompletionItemKind.File
),
kind=(CompletionItemKind.Folder if is_directory else CompletionItemKind.File),
detail="Directory" if is_directory else "File",
text_edit=TextEdit(range=replace_range, new_text=new_text),
)
+2 -2
View File
@@ -38,14 +38,14 @@ def method_parameters(parameters: str) -> list[MethodParameter]:
return []
def method_signature_help(source: str, position: lsp.Position) -> lsp.SignatureHelp | None:
def method_signature_help(source: str, position: lsp.Position, external_classes=None) -> lsp.SignatureHelp | None:
lines = source.split("\n")
if position.line >= len(lines):
return None
# AST columns are codepoints; LSP columns are UTF-16 code units.
prefix = lines[position.line].encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
cursor = (position.line, len(prefix))
candidates = [call for call in resolved_method_calls(source)
candidates = [call for call in resolved_method_calls(source, external_classes)
if _contains_cursor(call.command, lines, cursor)]
if not candidates:
return None
+49 -12
View File
@@ -1,4 +1,4 @@
"""Conservative, document-local TclOO type inference without executing Tcl."""
"""Static TclOO inference using local and indexed classes, without executing Tcl."""
from collections.abc import Sequence
from dataclasses import dataclass, field
@@ -17,6 +17,9 @@ class ClassInfo:
methods: dict[str, tuple[str, Script | None]] = field(default_factory=dict)
namespace: str = ""
constructor: str = ""
definition: lsp.Location | None = None
method_definitions: dict[str, lsp.Location] = field(default_factory=dict)
constructor_definition: lsp.Location | None = None
@dataclass
@@ -25,6 +28,21 @@ class MethodCall:
label: str
parameters: str
argument_offset: int = 1
definition: lsp.Location | None = None
def name_location(node, uri, source):
"""Locate the literal name, excluding braces/quotes, using UTF-16 columns."""
if not uri or node.contents is None or node.contents_pos is None:
return None
line, column = node.contents_pos
lines = source.splitlines()
prefix = lines[line - 1][:column - 1] if line <= len(lines) else ""
start = len(prefix.encode("utf-16-le")) // 2
end = start + len(node.contents.encode("utf-16-le")) // 2
return lsp.Location(uri=uri, range=lsp.Range(
start=lsp.Position(line=line - 1, character=start),
end=lsp.Position(line=line - 1, character=end)))
def parse_completion_source(source, pos=None):
@@ -57,7 +75,7 @@ def _qualified(name, namespace):
def tcloo_completions(
source_lines: Sequence[str], position: lsp.Position,
source_lines: Sequence[str], position: lsp.Position, external_classes=None,
) -> list[lsp.CompletionItem] | None:
"""Return receiver-specific methods, or None outside a known OO context."""
prefix = line_prefix_at_position(source_lines, position)
@@ -77,7 +95,7 @@ def tcloo_completions(
tree = parse_completion_source("\n".join(lines))
if tree is None:
return None
classes, result, _ = _analyze(tree, typed, marker)
classes, result, _ = _analyze(tree, typed, marker, external_classes)
if result is None:
return None
cls, internal = result
@@ -99,11 +117,8 @@ def tcloo_completions(
if name.startswith(typed) and (internal or not name.startswith("_") and not name[:1].isupper())]
def _analyze(tree, typed="", marker=""):
classes = {}
def _collect_classes(tree, classes, uri=None, source=""):
contexts = []
calls = []
def collect(script, namespace=""):
if script is None:
return
@@ -116,7 +131,9 @@ def _analyze(tree, typed="", marker=""):
collect(_body(args[2]), _qualified(args[1].contents, namespace))
elif routine == "oo::class" and len(args) == 3 and args[0].contents == "create" and args[1].contents:
name = _qualified(args[1].contents, namespace)
info = classes.setdefault(name, ClassInfo(namespace=namespace))
info = ClassInfo(namespace=namespace)
info.definition = name_location(args[1], uri, source)
classes[name] = info
body = _body(args[2])
if body is None:
continue
@@ -127,13 +144,31 @@ def _analyze(tree, typed="", marker=""):
if method.routine.contents == "method" and len(ma) == 3 and ma[0].contents:
method_body = _body(ma[2])
info.methods[ma[0].contents] = (ma[1].contents or "", method_body)
location = name_location(ma[0], uri, source)
if location is not None:
info.method_definitions[ma[0].contents] = location
contexts.append((method_body, namespace, name))
elif method.routine.contents in {"constructor", "destructor"} and ma:
if method.routine.contents == "constructor" and len(ma) == 2:
info.constructor = ma[0].contents or ""
info.constructor_definition = name_location(method.routine, uri, source)
contexts.append((_body(ma[-1]), namespace, name))
collect(tree)
return contexts
def indexed_classes(tree, uri=None, source=""):
classes = {}
_collect_classes(tree, classes, uri, source)
return classes
def _analyze(tree, typed="", marker="", external_classes=None, uri=None, source=""):
classes = dict(external_classes or {})
calls = []
contexts = _collect_classes(tree, classes, uri, source)
result = None
def receiver(node, env, objects, namespace, owner, depth=0):
@@ -180,13 +215,15 @@ def _analyze(tree, typed="", marker=""):
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
method_name = args[0].contents if args else None
if cls in classes and method_name in classes[cls].methods:
calls.append(MethodCall(cmd, f"{cls} {method_name}", classes[cls].methods[method_name][0]))
calls.append(MethodCall(cmd, f"{cls} {method_name}", classes[cls].methods[method_name][0],
definition=classes[cls].method_definitions.get(method_name)))
elif name and _qualified(name, namespace) in classes and method_name in {"new", "create"}:
cls = _qualified(name, namespace)
parameters = classes[cls].constructor
if method_name == "create":
parameters = "objectName " + parameters
calls.append(MethodCall(cmd, f"{cls} {method_name}", parameters))
calls.append(MethodCall(cmd, f"{cls} {method_name}", parameters,
definition=classes[cls].constructor_definition or classes[cls].definition))
if marker and args and args[0].contents == typed + marker:
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
if cls in classes:
@@ -221,6 +258,6 @@ def _analyze(tree, typed="", marker=""):
return classes, result, calls
def resolved_method_calls(source):
def resolved_method_calls(source, external_classes=None):
tree = parse_completion_source(source)
return _analyze(tree)[2] if tree is not None else []
return _analyze(tree, external_classes=external_classes)[2] if tree is not None else []
+35
View File
@@ -0,0 +1,35 @@
"""Definition targets for literal TclOO classes and resolved method calls."""
from tools.tcloo_completion import _analyze, name_location, parse_completion_source
from tools.tcloo_symbols import class_symbols
def tcloo_definition(source, uri, position, external_classes=None):
tree = parse_completion_source(source)
if tree is None:
return None
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
def contains(location):
if location is None:
return False
start, end = location.range.start, location.range.end
return (start.line, start.character) <= (position.line, position.character) < (end.line, end.character)
targets = {}
declarations, references = class_symbols(tree, classes, targets)
for node in references:
if contains(name_location(node, uri, source)):
return classes[targets[node.pos]].definition
for name, node in declarations.items():
if contains(name_location(node, uri, source)):
return classes[name].definition
for call in calls:
if call.command.args and contains(name_location(call.command.args[0], uri, source)):
return call.definition
# F12 on a declaration itself should stay on that declaration.
for info in classes.values():
for location in [*info.method_definitions.values(), info.constructor_definition]:
if location is not None and location.uri == uri and contains(location):
return location
return None
+7 -3
View File
@@ -6,7 +6,7 @@ from tclint.syntax_tree import Command
from tools.tcloo_completion import _body, _qualified
def class_symbols(tree):
def class_symbols(tree, external_classes=None, reference_targets=None):
"""Return qualified class declarations and statically resolved name nodes."""
declarations = {}
commands = []
@@ -34,13 +34,17 @@ def class_symbols(tree):
walk(child, namespace, in_class)
walk(tree)
known_classes = set(external_classes or ()) | declarations.keys()
references = []
for command, namespace in commands:
name = command.routine.contents
if name and any(candidate in declarations for candidate in (
target = next((candidate for candidate in (
_qualified(name, namespace), _qualified(name, ""),
)):
) if candidate in known_classes), None) if name else None
if target:
references.append(command.routine)
if reference_targets is not None:
reference_targets[command.routine.pos] = target
return declarations, references