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))
# *****************************************************