Compare commits

..
13 Commits
Author SHA1 Message Date
Christoph 91b86f29cb Merge pull request 'Add TclOO class completions, signature help and inlay hints' (#39) from add_class_support into main
build_and_puplish.yml / build_and_publish (release) Successful in 39s
2026-09-11 21:39:34 +00:00
Christoph Brandau f88a50d4ab feat(tcloo): add document-local TclOO completions, signature help and hints
Add conservative, document-local TclOO type inference and tooling so the server
can offer method completions, signature help, and inlay parameter hints for
statically resolvable TclOO receivers (including `new`/`create`, `my`, and
simple return-chains). Also surface class names as completion items and emit
semantic tokens for class declarations/references.

Notable changes:
- New tcloo_* tools: completion, symbols, and argument parsing; integrated into
  on_completion, signature_help, inlay hint generation and semantic token
  highlighting. Completions are returned early when an OO receiver context is
  detected.
- Use a completion-friendly parser fallback when the main AST fails (TclSyntaxError)
  so editing-in-progress code still yields useful completions.
- Add CompletionItemKind.Class to command kinds, exclude class items from the
  poco completion name cache, and include new unit tests for the TclOO helpers.
2026-09-11 23:38:42 +02:00
Christoph c28836933c Update version to 2026.9.300 2026-09-10 08:31:25 +00:00
Christoph 91c8c4aff3 Merge pull request 'Check Array Paramter' (#38) from #150 into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-10 08:29:22 +00:00
Christoph Brandau d8611d7aea feat(tcl): add static variable name extraction and array key completion
This adds tooling to statically extract Tcl variable names from syntax
trees without evaluating substitutions, enabling better completions
for array elements and plain variables. The new helpers are wired
into the completion and navigation flows and are supported by tests
covering array keys and substitutions.

- Introduce variable_names.py with variable_name() and array_key_parts()
- Wire static name extraction into completion and symbol indexing
- Add tests for array key completion with substitutions
2026-09-10 09:07:18 +02:00
Christoph Brandau 7e9a4359cd feat(cdl): include args in generated event handler snippet
The generated event handler snippet now declares the proc with an args block.
This enables parameters to be referenced within the handler body.
The change makes the snippet compatible with events that pass arguments.

- Include an args block in the proc declaration for event handlers.
2026-09-10 08:45:25 +02:00
Christoph b7f28ab1a3 Update version to 2026.9.220 2026-09-08 19:22:31 +00:00
Christoph 9b368ba761 Merge pull request 'fix(completion): scan nested commands in unfinished braced args' (#37) from #36 into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
Reviewed-on: #37
2026-09-08 19:21:49 +00:00
Christoph 82e13cf7ce fix(completion): scan nested commands in unfinished braced args
Make the completion parser inspect nested command segments when an
unfinished braced argument is the active context, instead of treating the
entire braced body as opaque. This resolves cases where inner commands
(e.g. command substitutions) were ignored and improves suggestion
accuracy. Tests were added to cover nested commands inside braced
conditions and to ensure closed braced arguments do not change context.
Also add Linux-specific launch and task configurations to IDE settings
to simplify local extension development and debugging.

- Recursively scan inner context when a braced body is unfinished
- Add tests for nested commands and closed-brace behavior
- Add Linux launch/task entries for easier development
2026-09-08 21:16:58 +02:00
Christoph d104906508 Update version to 2026.9.210 2026-09-04 11:46:55 +00:00
Christoph a5ff3b55ff Merge pull request 'feat(syntax): add escaped-quoted-strings token to def syntax' (#35) from new_completions into main
build_and_puplish.yml / build_and_publish (release) Successful in 7m35s
Reviewed-on: #35
2026-09-04 11:39:08 +00:00
Christoph Brandau 26225eb8ed feat(syntax): add escaped-quoted-strings token to def syntax
Introduces an escaped-quoted-strings pattern for the def syntax.
It enables proper highlighting of strings with escapes and vars.
The change adds the new pattern to the syntax and fixes the EOF newline.

- Adds escaped-quoted-strings for escapes and embedded vars
- Registers new pattern in repository for proper highlighting
- Ensures trailing newline in the file
2026-09-04 08:39:46 +02:00
Christoph 844138b383 Update version to 2026.9.200
build_and_puplish.yml / build_and_publish (release) Canceled after 33s
2026-09-03 08:39:22 +00:00
22 changed files with 1401 additions and 54 deletions
+26
View File
@@ -17,6 +17,19 @@
],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
"linux": {
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}",
"${workspaceFolder}/test/test.tcl"
],
"cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
]
},
"sourceMaps": true,
"resolveSourceMapLocations": [
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
@@ -50,6 +63,19 @@
],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
"linux": {
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}",
"${workspaceFolder}/test/test.tcl"
],
"cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
]
},
"sourceMaps": true,
"resolveSourceMapLocations": [
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
+7
View File
@@ -15,6 +15,13 @@
"${workspaceFolder}",
"${env:TEMP}\\nx-post-support-vscode-debug"
],
"linux": {
"command": "npm",
"args": ["run", "compile:debug"],
"options": {
"cwd": "${workspaceFolder}"
}
},
"problemMatcher": [],
"presentation": {
"reveal": "always",
+3
View File
@@ -1,5 +1,8 @@
## Unreleased
- Add document-local TclOO method completion for `new`/`create` instances, `my`, and statically inferred return chains
- Suggest TclOO class names as classes and semantically highlight their declarations and calls
- Show signature help and parameter inlay hints for resolved TclOO methods and constructors, including optional and variadic arguments
- 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
+1 -1
View File
@@ -105,7 +105,7 @@ export function createCdlEventHandlerSnippet(handler: CdlEventHandler): string {
const globals = [
...new Set(handler.parameterNames.map((parameter) => momVariableName(parameter)))
]
const lines = [`proc ${momEventName(handler.eventName)} { } {`]
const lines = [`proc ${momEventName(handler.eventName)} {args} {`]
if (globals.length > 0) {
lines.push(...globals.map((variable) => ` global ${variable}`), "")
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files",
"version": "2026.9.100",
"version": "2026.9.300",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"activationEvents": [
+31 -2
View File
@@ -47,6 +47,7 @@ from common.load_data import standard_items
from lsp_tclserver import TclLanguageServer
from tools.completion_items import (
CompletionContext,
array_element_completions,
completion_context,
ranked_completion_items,
)
@@ -74,6 +75,10 @@ from tools.semantic_tokens import (
_Highlighter,
)
from tools.signature_help import build_signature_help
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.tcl_command_completion import (
TCL_COMMAND_ITEMS,
TCL_COMMAND_NAMES,
@@ -271,12 +276,21 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
@LSP_SERVER.feature(
lsp.TEXT_DOCUMENT_COMPLETION,
lsp.CompletionOptions(trigger_characters=["$", " ", "-"]),
lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","]),
)
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)
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(),
str(pathlib.Path(uris.to_fs_path(doc.uri))),
)
if array_items is not None:
return lsp.CompletionList(is_incomplete=False, items=array_items)
context = completion_context(source_lines, position)
# Variable completion wins inside command arguments. Otherwise prefer the
@@ -323,11 +337,16 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
and params.context is not None
and params.context.trigger_kind
== lsp.CompletionTriggerKind.TriggerCharacter
and params.context.trigger_character in {" ", "-"}
and params.context.trigger_character in {" ", "-", "(", ","}
):
return lsp.CompletionList(is_incomplete=False, items=[])
try:
tree = LSP_SERVER.get_tree(doc)
except TclSyntaxError:
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
)
@@ -340,6 +359,7 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
break
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(
(
@@ -450,7 +470,15 @@ 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)
if method_help is not None:
return method_help
try:
tree = LSP_SERVER.get_tree(document)
except TclSyntaxError:
tree = parse_completion_source(document.source)
if tree is None:
return None
custom_signatures, custom_docs = LSP_SERVER.proc_metadata_snapshot(
document.path
@@ -534,6 +562,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
# Reuse cached AST
tree = LSP_SERVER.get_tree(document)
hl.highlight_classes(tree)
tree.accept(hl, recurse=True)
tokens = hl.tokens()
+3
View File
@@ -14,6 +14,7 @@ from tclint.lexer import TclSyntaxError
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.formatter import NxFormatter as Formatter
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index
@@ -190,6 +191,7 @@ class TclLanguageServer(LanguageServer):
item.label
for path_items in self.poco_completion.values()
for item in path_items
if item.kind != lsp.CompletionItemKind.Class
)
self._custom_function_names_cache = (self._index_generation, names)
return names
@@ -542,6 +544,7 @@ class TclLanguageServer(LanguageServer):
else self.parse_source(document.source)
)
tree.accept(collector, recurse=True)
collector.custom_functions.extend(class_completion_items(tree))
docs = build_proc_docs(tree, document.source)
navigation_index = build_file_symbol_index(
filepath, document.uri, tree
+98 -3
View File
@@ -9,7 +9,13 @@ import lsprotocol.types as lsp
from tclint.syntax_tree import BareWord, Command, List, Visitor
from common.load_data import standard_items
from tools.tcl_command_completion import line_prefix_at_position
from tools.navigation import FileSymbolIndex
from tools.variable_names import variable_name
from tools.tcl_command_completion import (
DynamicCompletionKind,
line_prefix_at_position,
tcl_argument_completion,
)
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs}
@@ -27,6 +33,7 @@ VARIABLE_KINDS = {
lsp.CompletionItemKind.Constant,
}
COMMAND_KINDS = {
lsp.CompletionItemKind.Class,
lsp.CompletionItemKind.Function,
lsp.CompletionItemKind.Method,
lsp.CompletionItemKind.Constructor,
@@ -36,6 +43,94 @@ COMMAND_KINDS = {
_VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
_COMMAND_PREFIX_RE = re.compile(r"(?:^|[;\[\{])\s*[^\s;\[\]\{\}]*$")
_ARRAY_PREFIX_RE = re.compile(
r"(?P<name>(?:::)?[A-Za-z_][A-Za-z0-9_:]*)\((?P<key>[^()\n]*)$"
)
def array_element_completions(
source_lines: Sequence[str],
position: lsp.Position,
indexes: Iterable[FileSymbolIndex],
current_path: str,
) -> list[lsp.CompletionItem] | None:
"""Complete literal array keys, replacing only the text inside parentheses."""
prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return None
match = _ARRAY_PREFIX_RE.search(prefix)
if match is None:
return None
before = prefix[:match.start()]
if not before.endswith("$"):
argument = tcl_argument_completion(source_lines, position)
if argument is None or argument.dynamic_kind != DynamicCompletionKind.VARIABLE:
return None
name, key_prefix = match.group("name", "key")
part_index = key_prefix.count(",")
key_prefix = key_prefix.rsplit(",", 1)[-1]
if any(char in key_prefix for char in "$[]{}\\"):
return None
indexes = list(indexes)
local_scope = None
for index in indexes:
if index.path != current_path:
continue
for occurrence in index.occurrences:
span = occurrence.declaration_range
if (
occurrence.identity.kind == "proc"
and occurrence.is_definition
and span is not None
and span.start.line <= position.line <= span.end.line
):
local_scope = f"{index.path}::proc::{occurrence.identity.name}"
keys: dict[str, set[tuple[str | None, ...]]] = {}
for index in indexes:
for occurrence in index.occurrences:
parts = occurrence.array_parts
key = parts[part_index] if part_index < len(parts) else None
if (
key
and (
occurrence.identity.scope is None
or occurrence.identity.scope == local_scope
)
and key.startswith(key_prefix)
and not any(char in key for char in "$[]\\")
and occurrence.identity.name.removeprefix("::") == name.removeprefix("::")
):
keys.setdefault(key, set()).add(occurrence.array_template_parts[part_index + 1:])
line = source_lines[position.line]
suffix = line[len(prefix):]
remaining = re.match(r"[^(),\s$\[\]{}]*", suffix).group()
has_close = suffix[len(remaining):].startswith((")", ","))
start = position.character - len(key_prefix.encode("utf-16-le")) // 2
end = position.character + len(remaining.encode("utf-16-le")) // 2
items = []
for key in sorted(keys):
new_text = key
# Fill missing index components only; keep an existing comma and suffix.
if not suffix[len(remaining):].startswith(","):
tails = sorted(keys[key], key=lambda tail: (len(tail), repr(tail)))
tail = tails[0]
if all(part is not None for part in tail) and any("$" in part for part in tail):
new_text = ",".join([key, *tail])
new_text += "" if has_close else ")"
items.append(lsp.CompletionItem(
label=key,
kind=lsp.CompletionItemKind.Field,
detail=f"{name}({key})",
insert_text_format=lsp.InsertTextFormat.PlainText,
text_edit=lsp.TextEdit(
range=lsp.Range(
start=lsp.Position(line=position.line, character=start),
end=lsp.Position(line=position.line, character=end),
),
new_text=new_text,
),
))
return items
def completion_context(
@@ -167,8 +262,8 @@ class CompletionCollector(Visitor):
# Collect variables set with explicit global namespace: set ::var_name ...
elif routine.contents == "set" and command.args:
first = command.args[0]
if isinstance(first, BareWord) and getattr(first, "value", None):
var_name = first.value
var_name = variable_name(first)
if var_name:
if var_name.startswith("::"):
base_name = var_name.split("(", 1)[0]
clean_name = base_name[2:] # remove leading '::' for completion display
+18 -1
View File
@@ -9,6 +9,8 @@ from typing import Any
import lsprotocol.types as lsp
from tclint.syntax_tree import Command, VarSub, Visitor
from tools.navigation import FileSymbolIndex
from tools.tcloo_arguments import method_parameters
from tools.tcloo_completion import resolved_method_calls
@dataclass(frozen=True)
@@ -160,6 +162,7 @@ class InlayHintGenerator(Visitor):
self.source_lines = (
source_lines if source_lines is not None else source.splitlines()
)
self.source = source
self.proc_signatures = proc_signatures
self.requested_range = requested_range
self.parameter_names = parameter_names
@@ -194,6 +197,17 @@ class InlayHintGenerator(Visitor):
walk(child)
walk(tree)
if self.parameter_names != "none":
for call in resolved_method_calls(self.source):
if not self._node_intersects_requested_range(call.command):
continue
parameters = method_parameters(call.parameters)
signature = InlayHintSignature(
parameters=tuple(InlayHintParameter(p.name, variadic=p.variadic) for p in parameters),
display_label=" ".join([call.label, *(p.label for p in parameters)]),
)
self._argument_hints(signature, call.command.args[call.argument_offset:])
self.hints.sort(key=lambda hint: (hint.position.line, hint.position.character))
return self.hints
def _position(self, line: int, column: int) -> lsp.Position:
@@ -238,7 +252,10 @@ class InlayHintGenerator(Visitor):
if signature is None or self.parameter_names == "none":
return
for argument_index, argument in enumerate(command.args):
self._argument_hints(signature, command.args)
def _argument_hints(self, signature, arguments):
for argument_index, argument in enumerate(arguments):
parameter = self._parameter_for_argument(signature, argument_index)
if parameter is None:
break
+15 -2
View File
@@ -4,7 +4,8 @@ from dataclasses import dataclass
from pathlib import Path
import lsprotocol.types as lsp
from tclint.syntax_tree import Command, List, Node, Script, VarSub
from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub
from tools.variable_names import array_key_parts, variable_name
ROOT_NAMESPACE = "::"
@@ -27,6 +28,9 @@ class SymbolOccurrence:
fallback_identity: SymbolIdentity | None = None
caller: SymbolIdentity | None = None
declaration_range: lsp.Range | None = None
array_element: str | None = None
array_parts: tuple[str | None, ...] = ()
array_template_parts: tuple[str | None, ...] = ()
@dataclass(frozen=True)
@@ -96,6 +100,8 @@ def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp
column += 2 if getattr(node, "braced", False) else 1
else:
position = getattr(node, "contents_pos", None) or node.pos
if isinstance(node, QuotedWord) and node.contents is None and node.children:
position = node.children[0].pos
line, column = position
normalized = _without_array_index(raw_name)
@@ -312,6 +318,13 @@ def build_file_symbol_index(
is_definition=is_definition,
symbol_kind=lsp.SymbolKind.Variable,
container_name=_container_name(symbol_identity),
array_element=(
raw_name.split("(", 1)[1][:-1]
if "(" in raw_name and raw_name.endswith(")")
else None
),
array_parts=array_key_parts(node),
array_template_parts=array_key_parts(node, preserve_variables=True),
)
)
@@ -444,7 +457,7 @@ def build_file_symbol_index(
for node, is_definition in _variable_command_nodes(command):
if id(node) in declaration_ids:
continue
raw_name = _static_contents(node)
raw_name = variable_name(node)
if raw_name:
add_variable(
node,
+22 -1
View File
@@ -5,6 +5,8 @@ import attrs
from common.load_data import standard_items
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
# Constructing a PluginManager scans entry points, and get_commands() rebuilds
# the builtin command set on every call. Semantic tokens are requested often, so
@@ -69,6 +71,7 @@ class _Highlighter(Visitor):
def __init__(self, plugins, custom_functions):
self._commands = _load_commands(plugins)
self._tokens = []
self._class_tokens = {}
if isinstance(custom_functions, dict):
self._custom_function_names = frozenset(
item.label
@@ -83,6 +86,17 @@ class _Highlighter(Visitor):
return
self._tokens.append((position, length, tok_type, modifiers or []))
def highlight_classes(self, tree):
declarations, references = class_symbols(tree)
for node, modifiers in [
*((node, [TokenModifier.declaration]) for node in declarations.values()),
*((node, []) for node in references),
]:
line, col = node.contents_pos
self._class_tokens[(line - 1, col - 1)] = (
(line - 1, col - 1), len(node.contents), "class", modifiers,
)
def _get_token_info(self, node):
"""Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren."""
if not hasattr(node, "pos"):
@@ -159,6 +173,11 @@ class _Highlighter(Visitor):
if routine.contents == "set" and command.args:
first_arg = command.args[0]
token_info = self._get_token_info(first_arg)
if first_arg.contents is None:
name = variable_name(first_arg)
if name:
line, col = first_arg.children[0].pos
token_info = ((line - 1, col - 1), len(name))
if token_info:
(line, col), length = token_info
self._append_token((line, col), length, "variable", [TokenModifier.declaration])
@@ -198,7 +217,9 @@ class _Highlighter(Visitor):
tokens = []
last_line = 0
last_col = 0
for (line, col), length, tok_type, tok_modifier in sorted(self._tokens, key=lambda x: x[0]):
raw_tokens = [token for token in self._tokens if token[0] not in self._class_tokens]
raw_tokens.extend(self._class_tokens.values())
for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]):
line_delta = line - last_line
col_delta = col
if line == last_line:
+7 -1
View File
@@ -1009,7 +1009,13 @@ def _current_command_segment(line_prefix: str) -> str:
starts.append(brace_starts[-1] + 1)
if bracket_starts:
starts.append(bracket_starts[-1] + 1)
return line_prefix[max(starts) :]
start = max(starts)
if brace_starts and start == brace_starts[-1] + 1:
# An unfinished braced body or expression can itself contain commands
# (for example, ``if {[string compare ...``). Scan that inner context
# independently, while keeping completed braced arguments opaque.
return _current_command_segment(line_prefix[start:])
return line_prefix[start:]
def _tokenize_command_segment(segment: str) -> list[str]:
+79
View File
@@ -0,0 +1,79 @@
"""Parameter presentation for statically resolved TclOO calls."""
from dataclasses import dataclass
import lsprotocol.types as lsp
from tclint.lexer import TclSyntaxError
from tclint.syntax_tree import BracedWord, Command
from tools.parser import CustomParser
from tools.signature_help import _active_argument, _contains_cursor
from tools.tcloo_completion import resolved_method_calls
@dataclass(frozen=True)
class MethodParameter:
name: str
label: str
variadic: bool = False
def method_parameters(parameters: str) -> list[MethodParameter]:
parser = CustomParser()
try:
words = parser.parse_list(BracedWord(parameters, pos=(1, 1))).children
result = []
for index, word in enumerate(words):
parts = parser.parse_list(word).children
if not parts or len(parts) > 2:
return []
name = parts[0].contents
if name is None:
return []
variadic = name == "args" and len(parts) == 1 and index == len(words) - 1
label = "{" + word.contents + "}" if len(parts) == 2 else name
result.append(MethodParameter(name, label, variadic))
return result
except TclSyntaxError:
return []
def method_signature_help(source: str, position: lsp.Position) -> 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)
if _contains_cursor(call.command, lines, cursor)]
if not candidates:
return None
call = max(candidates, key=lambda candidate: candidate.command.pos)
def nested_active(node):
return any(
isinstance(child, Command) and _contains_cursor(child, lines, cursor)
or nested_active(child)
for child in node.children
)
# Let the inner command's own signature provider handle its arguments.
if nested_active(call.command):
return None
argument = _active_argument(call.command, cursor) - call.argument_offset
if argument < 0:
return None
parameters = method_parameters(call.parameters)
label = call.label
infos = []
for parameter in parameters:
label += " "
start = len(label.encode("utf-16-le")) // 2
label += parameter.label
infos.append(lsp.ParameterInformation(label=(start, len(label.encode("utf-16-le")) // 2)))
active = min(argument, len(parameters) - 1) if parameters else None
return lsp.SignatureHelp(
signatures=[lsp.SignatureInformation(label=label, parameters=infos, active_parameter=active)],
active_signature=0, active_parameter=active,
)
+226
View File
@@ -0,0 +1,226 @@
"""Conservative, document-local TclOO type inference without executing Tcl."""
from collections.abc import Sequence
from dataclasses import dataclass, field
import re
import lsprotocol.types as lsp
from tclint.lexer import TclSyntaxError
from tclint.syntax_tree import BracedWord, Command, CommandSub, Script, VarSub
from tools.parser import CustomParser
from tools.tcl_command_completion import line_prefix_at_position
@dataclass
class ClassInfo:
methods: dict[str, tuple[str, Script | None]] = field(default_factory=dict)
namespace: str = ""
constructor: str = ""
@dataclass
class MethodCall:
command: Command
label: str
parameters: str
argument_offset: int = 1
def parse_completion_source(source, pos=None):
# Complete open delimiters while editing; never evaluate the user's code.
for _ in range(16):
try:
return CustomParser().parse(source, pos=pos)
except TclSyntaxError as error:
message = str(error)
closing = next((char for text, char in (
("end of command substitution", "]"),
("match for brace", "}"), ("match for quote", '"'),
) if text in message), None)
if closing is None:
return None
source += closing
return None
def _body(node):
if isinstance(node, Script):
return node
if isinstance(node, BracedWord):
return parse_completion_source(node.contents, node.contents_pos)
return None
def _qualified(name, namespace):
return name if name.startswith("::") else f"{namespace}::{name}"
def tcloo_completions(
source_lines: Sequence[str], position: lsp.Position,
) -> list[lsp.CompletionItem] | None:
"""Return receiver-specific methods, or None outside a known OO context."""
prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return None
match = re.search(r"[\w:]*$", prefix)
typed = match.group()
# Only a method word, never a variable substitution or method argument.
word_start = len(prefix) - len(typed)
if word_start == 0 or prefix[word_start - 1] not in " \t":
return None
marker = "__nx_tcloo_completion_cursor__"
lines = list(source_lines)
suffix = lines[position.line][len(prefix):]
remaining = re.match(r"[\w:]*", suffix).group()
lines[position.line] = prefix + marker + suffix[len(remaining):]
tree = parse_completion_source("\n".join(lines))
if tree is None:
return None
classes, result, _ = _analyze(tree, typed, marker)
if result is None:
return None
cls, internal = result
methods = classes[cls].methods if cls else {"new": ("args", None), "create": ("name args", None)}
methods = dict(methods)
if cls:
methods.setdefault("destroy", ("", None))
suffix = source_lines[position.line][len(prefix):]
remaining = re.match(r"[\w:]*", suffix).group()
start = position.character - len(typed.encode("utf-16-le")) // 2
end = position.character + len(remaining.encode("utf-16-le")) // 2
return [lsp.CompletionItem(
label=name, kind=lsp.CompletionItemKind.Method,
detail=f"{cls or 'class'} {name} {signature}".rstrip(),
text_edit=lsp.TextEdit(range=lsp.Range(
start=lsp.Position(line=position.line, character=start),
end=lsp.Position(line=position.line, character=end)), new_text=name),
) for name, (signature, _) in sorted(methods.items())
if name.startswith(typed) and (internal or not name.startswith("_") and not name[:1].isupper())]
def _analyze(tree, typed="", marker=""):
classes = {}
contexts = []
calls = []
def collect(script, namespace=""):
if script is None:
return
for cmd in script.children:
if not isinstance(cmd, Command):
continue
args = cmd.args
routine = (cmd.routine.contents or "").removeprefix("::")
if routine == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
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))
body = _body(args[2])
if body is None:
continue
for method in body.children:
if not isinstance(method, Command):
continue
ma = method.args
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)
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 ""
contexts.append((_body(ma[-1]), namespace, name))
collect(tree)
result = None
def receiver(node, env, objects, namespace, owner, depth=0):
if depth > 12:
return None
if isinstance(node, VarSub):
return env.get(node.value)
if isinstance(node, CommandSub) and len(node.children) == 1:
return returned(node.children[0], env, objects, namespace, owner, depth + 1)
name = node.contents
return objects.get(_qualified(name, namespace)) if name else None
def returned(cmd, env, objects, namespace, owner, depth=0):
if not isinstance(cmd, Command) or depth > 12:
return None
args = cmd.args
name = cmd.routine.contents
if name == "self" and not args:
return owner
qualified = _qualified(name, namespace) if name else None
if qualified in classes and args and args[0].contents in {"new", "create"}:
return qualified
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner, depth + 1)
if cls not in classes or not args:
return None
method = classes[cls].methods.get(args[0].contents)
if method is None or method[1] is None:
return None
# Only infer unconditional final returns; conditional results stay unknown.
commands = [c for c in method[1].children if isinstance(c, Command)]
if commands and commands[-1].routine.contents == "return" and len(commands[-1].args) == 1:
return receiver(commands[-1].args[0], {}, objects, classes[cls].namespace, cls, depth + 1)
return None
def walk(script, env, objects, namespace="", owner=None):
nonlocal result
if script is None:
return
for cmd in script.children:
if not isinstance(cmd, Command):
continue
args = cmd.args
name = cmd.routine.contents
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]))
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))
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:
result = (cls, name == "my")
elif name and _qualified(name, namespace) in classes:
result = (None, False)
return
# Command substitutions can contain the completion receiver.
for node in cmd.children:
if isinstance(node, CommandSub):
walk(node, env, objects, namespace, owner)
if name == "set" and len(args) == 2 and args[0].contents:
env[args[0].contents] = receiver(args[1], env, objects, namespace, owner)
elif name == "unset":
for arg in args:
env.pop(arg.contents, None)
elif name == "proc" and len(args) == 3:
walk(_body(args[2]), {}, objects.copy(), namespace)
elif name == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
walk(_body(args[2]), {}, objects, _qualified(args[1].contents, namespace))
elif name and _qualified(name, namespace) in classes and len(args) >= 2 and args[0].contents == "create" and args[1].contents:
objects[_qualified(args[1].contents, namespace)] = _qualified(name, namespace)
else:
for arg in args:
if isinstance(arg, Script):
# Branch-local facts are not propagated beyond the branch.
walk(arg, env.copy(), objects.copy(), namespace, owner)
walk(tree, {}, {})
for body, namespace, owner in contexts:
walk(body, {}, {}, namespace, owner)
return classes, result, calls
def resolved_method_calls(source):
tree = parse_completion_source(source)
return _analyze(tree)[2] if tree is not None else []
+53
View File
@@ -0,0 +1,53 @@
"""Class declarations and references shared by completion and highlighting."""
import lsprotocol.types as lsp
from tclint.syntax_tree import Command
from tools.tcloo_completion import _body, _qualified
def class_symbols(tree):
"""Return qualified class declarations and statically resolved name nodes."""
declarations = {}
commands = []
def walk(node, namespace="", in_class=False):
if node is None:
return
if isinstance(node, Command):
args = node.args
name = (node.routine.contents or "").removeprefix("::")
commands.append((node, namespace))
if (name == "namespace" and len(args) == 3
and args[0].contents == "eval" and args[1].contents):
walk(_body(args[2]), _qualified(args[1].contents, namespace), in_class)
return
if (name == "oo::class" and len(args) == 3
and args[0].contents == "create" and args[1].contents):
declarations[_qualified(args[1].contents, namespace)] = args[1]
walk(_body(args[2]), namespace, True)
return
if in_class and name in {"method", "constructor", "destructor"} and args:
walk(_body(args[-1]), namespace, True)
return
for child in node.children:
walk(child, namespace, in_class)
walk(tree)
references = []
for command, namespace in commands:
name = command.routine.contents
if name and any(candidate in declarations for candidate in (
_qualified(name, namespace), _qualified(name, ""),
)):
references.append(command.routine)
return declarations, references
def class_completion_items(tree):
declarations, _ = class_symbols(tree)
return [lsp.CompletionItem(
label=name.removeprefix("::"),
kind=lsp.CompletionItemKind.Class,
detail=f"TclOO class {name}",
) for name in sorted(declarations)]
+2 -1
View File
@@ -4,6 +4,7 @@ from dataclasses import dataclass
from tclint.syntax_tree import Command, Node, Script
from tclint.syntax_tree import List as TclList
from tools.variable_names import variable_name
@dataclass
@@ -81,7 +82,7 @@ def build_variable_index(
return
if routine == "set" and node.args:
raw_name = getattr(node.args[0], "contents", None)
raw_name = variable_name(node.args[0])
base = _normalize_var_name(raw_name)
if base is not None:
if raw_name and raw_name.startswith("::"):
+44
View File
@@ -0,0 +1,44 @@
"""Extract statically known variable names without evaluating Tcl substitutions."""
from tclint.syntax_tree import BareWord, CompoundBareWord, Node, QuotedWord, VarSub
def variable_name(node: Node) -> str | None:
contents = node.contents
if isinstance(contents, str):
return contents
if isinstance(node, (CompoundBareWord, QuotedWord)) and node.children:
first = node.children[0]
last = node.children[-1]
if (
isinstance(first, BareWord)
and isinstance(last, BareWord)
and "(" in first.value
and last.value.endswith(")")
):
# Substitutions in an array index do not change the array's name.
return first.value.split("(", 1)[0] or None
return None
def array_key_parts(node: Node, *, preserve_variables: bool = False) -> tuple[str | None, ...]:
"""Keep comma-separated literal index components; substitutions are unknown."""
contents = node.contents
if not isinstance(contents, str):
if not isinstance(node, (CompoundBareWord, QuotedWord)) or not variable_name(node):
return ()
chunks = []
for child in node.children:
if isinstance(child, BareWord):
chunks.append(child.value)
elif preserve_variables and isinstance(child, VarSub) and not child.children:
chunks.append("${" + child.value + "}" if child.braced else "$" + child.value)
else:
chunks.append("\0")
contents = "".join(chunks)
if "(" not in contents or not contents.endswith(")"):
return ()
return tuple(
part if part and not any(char in part for char in ("\0[]\\" if preserve_variables else "\0$[]\\")) else None
for part in contents.split("(", 1)[1][:-1].split(",")
)
@@ -103,6 +103,133 @@ def _argument_completion_labels(source: str) -> set[str] | None:
return {item.label for item in completion.items}
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
server, _, _ = _completion_server(tmp_path, monkeypatch)
workspace = _document(
tmp_path / "arrays.tcl",
"set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n",
)
assert server.update_poco_completion_for_file(workspace)
source = (
"set lib_flag(enabled) 0\n"
"set lib_flag(empty) 1\n"
"set other(wrong) 1\n"
"proc hidden {} { set lib_flag(private) 1 }\n"
"set lib_flag()\n"
"puts $lib_flag(en)\n"
"puts 😀; set lib_flag(em\n"
)
current = _document(tmp_path / "arrays-current.tcl", source)
server.workspace.put_text_document(lsp.TextDocumentItem(
uri=current.uri, language_id="tcl", version=1, text=source,
))
assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "set lib_flag(", 3))
assert [item.label for item in items] == ["empty", "enabled", "external"]
assert all(item.text_edit.new_text == item.label for item in items)
items = _complete(current, _position_after(source, "puts $lib_flag(en"))
assert [item.label for item in items] == ["enabled"]
assert items[0].text_edit.new_text == "enabled"
position = lsp.Position(line=6, character=len(source.splitlines()[6].encode("utf-16-le")) // 2)
items = _complete(current, position)
assert [item.label for item in items] == ["empty"]
assert items[0].text_edit.new_text == "empty)"
assert items[0].text_edit.range.start.character == position.character - 2
def test_dynamic_array_index_keeps_variable_identity(tmp_path: Path, monkeypatch):
from tools.navigation import build_file_symbol_index
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter
from tools.variable_index import build_variable_index
from tools.variable_names import variable_name
source = (
"set custom_flag(from_move,$::mom_path_name) 1\n"
'set "::quoted_flag(from_move,$::mom_path_name)" 1\n'
"set ::command_flag([info hostname]) 1\n"
"set ${dynamic_name}(entry) 1\n"
"proc example {} { set local_flag($::mom_path_name) 1 }\n"
"puts $custom_flag\n"
)
tree = CustomParser().parse(source)
globals_, locals_, _ = build_variable_index(source, tree)
assert {"custom_flag", "quoted_flag", "command_flag"} <= globals_
assert locals_["example"] == {"local_flag"}
assert variable_name(tree.children[3].args[0]) is None
path = tmp_path / "dynamic.tcl"
index = build_file_symbol_index(str(path), path.as_uri(), tree)
definition = next(
item for item in index.occurrences
if item.identity.name == "::custom_flag" and item.is_definition
)
assert definition.range.start.character == 4
assert definition.range.end.character == 15
assert definition.array_element is None
assert any(item.identity.name == "::mom_path_name" for item in index.occurrences)
highlighter = _Highlighter([], {})
tree.accept(highlighter, recurse=True)
assert any(
position == (0, 4) and length == 11 and kind == "variable"
for position, length, kind, _ in highlighter._tokens
)
server, _, _ = _completion_server(tmp_path, monkeypatch)
current = _document(path, source)
server.workspace.put_text_document(lsp.TextDocumentItem(
uri=current.uri, language_id="tcl", version=1, text=source,
))
assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "puts $custom"))
assert "custom_flag" in {item.label for item in items}
workspace_items = next(
items for item_path, items in server.completion_items_by_file_snapshot().items()
if server.paths_equal(item_path, str(path))
)
assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items}
def test_literal_array_components_complete_around_substitutions(tmp_path: Path, monkeypatch):
from tools.completion_items import array_element_completions
server, _, _ = _completion_server(tmp_path, monkeypatch)
source = (
"set custom_flag(from_move,$::mom_path_name) 1\n"
"set custom_flag(to_move,$::mom_path_name) 1\n"
"set custom_flag($::mom_path_name,finished) 1\n"
"set custom_flag(prefix_$::mom_path_name,other) 1\n"
"set custom_flag([info hostname],command_tail) 1\n"
"set other_flag(wrong,$::mom_path_name) 1\n"
"set multi_flag(move,$first,axis,$second) 1\n"
)
document = _document(tmp_path / "components.tcl", source)
assert server.update_poco_completion_for_file(document)
cases = [
("set custom_flag(|,$::mom_path_name)", {"from_move", "to_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set custom_flag(fr|om_old,$::mom_path_name)", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("puts $custom_flag($::mom_path_name,fi|)", {"finished"}, "finished", "puts $custom_flag($::mom_path_name,finished)"),
("set custom_flag($::mom_path_name,|)", {"finished", "other", "command_tail"}, "other", "set custom_flag($::mom_path_name,other)"),
("set custom_flag(fr|", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set custom_flag(fr|)", {"from_move"}, "from_move", "set custom_flag(from_move,$::mom_path_name)"),
("set multi_flag(m|)", {"move"}, "move", "set multi_flag(move,$first,axis,$second)"),
]
for marked, labels, selected, expected in cases:
offset = marked.index("|")
line = marked.replace("|", "")
items = array_element_completions(
[line], lsp.Position(line=0, character=offset),
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
)
assert {item.label for item in items} == labels
edit = next(item.text_edit for item in items if item.label == selected)
item = next(item for item in items if item.label == selected)
assert item.insert_text_format == lsp.InsertTextFormat.PlainText
assert line[:edit.range.start.character] + edit.new_text + line[edit.range.end.character:] == expected
assert array_element_completions(
["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31),
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
) is None
def _argument_completion_request(source: str):
lines = source.split("\n")
character = len(lines[-1].encode("utf-16-le")) // 2
@@ -204,6 +331,29 @@ def test_string_completion_handles_nested_commands_and_is_values():
}
def test_string_completion_inside_braced_conditions_and_bodies():
for prefix in ("if {", "while {", "proc example {} { if {"):
subcommands = _argument_completion_labels(prefix + "[string ")
assert subcommands is not None
assert {"compare", "equal", "is"} <= subcommands
assert _argument_completion_labels(prefix + "[string compare -") == {
"-length", "-nocase"
}
assert _argument_completion_labels(
prefix + "[string compare -nocase "
) == {"-length"}
def test_closed_braced_arguments_do_not_change_completion_context():
assert _argument_completion_labels("puts {[string compare }") is None
assert _argument_completion_labels(
"if {[string equal a b]} {string compare "
) == {"-length", "-nocase"}
assert _argument_completion_labels(
"if {[string equal a b] && [string is integer "
) == {"-failindex", "-strict"}
def test_dict_array_namespace_file_and_info_subcommands():
dict_items = _argument_completion_labels("dict ")
assert dict_items is not None
@@ -0,0 +1,100 @@
import lsprotocol.types as lsp
import pytest
from tools.inlay_hint import InlayHintGenerator
from tools.parser import CustomParser
from tools.tcloo_arguments import method_parameters, method_signature_help
CLASS = """oo::class create MCS {
constructor {name {size 3}} {}
method initValue {i value} {return [self]}
method format {value {precision 7}} {}
method many {first args} {}
method empty {} {}
method internal {} {my initValue 0 10}
}
"""
def signature(source):
prefix, suffix = source.split("|")
position = lsp.Position(line=prefix.count("\n"), character=len(prefix.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
return method_signature_help(prefix + suffix, position)
@pytest.mark.parametrize("tail, label, active", [
("set mcs [MCS new test]\n$mcs initValue |", "::MCS initValue i value", 0),
("set mcs [MCS new test]\n$mcs initValue 0 |", "::MCS initValue i value", 1),
("set mcs [MCS new test]\n$mcs format 12 |", "::MCS format value {precision 7}", 1),
("set mcs [MCS new test]\n$mcs many 1 2 3 |", "::MCS many first args", 1),
("set mcs [MCS new test]\n$mcs empty |", "::MCS empty", None),
("MCS create instance test\ninstance initValue 0 |", "::MCS initValue i value", 1),
("set mcs [[MCS new test] initValue 0 0]\n$mcs initValue |", "::MCS initValue i value", 0),
("set mcs [MCS new test]\nputs [$mcs initValue 0 |", "::MCS initValue i value", 1),
("set mcs [MCS new |", "::MCS new name {size 3}", 0),
("MCS create instance |", "::MCS create objectName name {size 3}", 1),
("set mcs [MCS new test]\nputs 😀; $mcs initValue 0 |", "::MCS initValue i value", 1),
])
def test_method_signatures(tail, label, active):
result = signature(CLASS + tail)
assert result is not None
assert result.signatures[0].label == label
assert result.active_parameter == active
def test_my_signature_and_parameter_spans():
result = signature(CLASS.replace("my initValue 0 10", "my initValue 0 |"))
assert result.active_parameter == 1
info = result.signatures[0]
assert [info.label[start:end] for start, end in (p.label for p in info.parameters)] == ["i", "value"]
@pytest.mark.parametrize("tail", [
"$unknown initValue |",
"set mcs [MCS new test]\nset mcs text\n$mcs initValue |",
"set mcs [MCS new test]\n$mcs initValue [unknown |] 3",
"set mcs [MCS new test]\n$mcs initValue|",
])
def test_unknown_receivers_and_nested_commands(tail):
assert signature(CLASS + tail) is None
def test_optional_parameter_with_spaced_default():
parameters = method_parameters('value {description {hello world}} args')
assert [p.name for p in parameters] == ["value", "description", "args"]
assert parameters[1].label == "{description {hello world}}"
assert parameters[-1].variadic
def test_method_inlay_hints_and_existing_preferences():
source = CLASS + "set mcs [MCS new test]\n$mcs initValue 0 10\n$mcs many 1 2 3\n$mcs initValue $i $other"
tree = CustomParser().parse(source)
start = len(CLASS.splitlines()) + 1
requested = lsp.Range(start=lsp.Position(line=start, character=0), end=lsp.Position(line=start + 2, character=100))
generator = InlayHintGenerator(source, {}, requested_range=requested)
hints = generator.generate(tree)
assert [hint.label[0].value for hint in hints] == ["i:", "value:", "first:", "args:", "args:", "value:"]
assert [source.splitlines()[hint.position.line][hint.position.character:] for hint in hints[:2]] == ["0 10", "10"]
assert "::MCS initValue i value" in hints[0].tooltip.value
generator = InlayHintGenerator(source, {}, requested_range=requested, parameter_names="literals")
assert len(generator.generate(tree)) == 5
generator = InlayHintGenerator(source, {}, parameter_names="none")
assert generator.generate(tree) == []
def test_lsp_signature_help_with_unfinished_bracket(tmp_path, monkeypatch):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + "set mcs [MCS new test]\nputs [$mcs initValue 0 "
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
result = lsp_server.signature_help(lsp.SignatureHelpParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1, character=len(source.splitlines()[-1])),
))
assert result.active_parameter == 1
assert result.signatures[0].label == "::MCS initValue i value"
@@ -0,0 +1,166 @@
import lsprotocol.types as lsp
import pytest
from tools.tcloo_completion import tcloo_completions
CLASS = """oo::class create MCS {
constructor {} {my initValue 0 0}
method initValue {i value} {return [self]}
method initOrg {x y z} {return [my initValue 0 $x]}
method toLst {} {return {1 2 3}}
method _private {} {return [self]}
}
"""
def complete(source):
offset = source.index("|")
before = source[:offset]
position = lsp.Position(line=before.count("\n"), character=len(before.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
return tcloo_completions(source.replace("|", "").splitlines(), position)
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\n$mcs |",
"set mcs [::MCS new]\n$mcs |",
"MCS create instance\ninstance |",
"set mcs [MCS create instance]\n$mcs |",
"set mcs [[MCS new] initValue 0 0]\n$mcs |",
"set mcs [[MCS new] initOrg 1 2 3]\n$mcs |",
"set a [MCS new]\nset mcs $a\n$mcs |",
"proc run {} {set mcs [MCS new]; $mcs |}",
"set mcs [MCS new]\nputs [$mcs |]",
"set mcs [MCS new]\nputs [$mcs |",
"[MCS new] |",
])
def test_instances_and_chains(code):
items = complete(CLASS + code)
assert {item.label for item in items} == {"initValue", "initOrg", "toLst", "destroy"}
assert next(item for item in items if item.label == "initValue").detail == "::MCS initValue i value"
def test_my_and_constructor():
for source in [CLASS.replace("my initValue 0 0", "my |"), CLASS.replace("return {1 2 3}", "my |")]:
assert "_private" in {item.label for item in complete(source)}
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\nset mcs text\n$mcs |",
"set mcs [MCS new]\nunset mcs\n$mcs |",
"proc a {} {set mcs [MCS new]}\nproc b {} {$mcs |}",
"set mcs [MCS new]\nproc b {mcs} {$mcs |}",
"set mcs [[MCS new] toLst]\n$mcs |",
"$mcs |\nset mcs [MCS new]",
"set mcs [MCS new]\n$mcs initValue |",
"# my |",
"puts {my |}",
])
def test_unknown_and_non_command_contexts(code):
assert complete(CLASS + code) is None
def test_prefix_and_replacement():
items = complete(CLASS + "set mcs [MCS new]\n$mcs initV|alue")
assert [item.label for item in items] == ["initValue"]
assert items[0].text_edit.new_text == "initValue"
assert items[0].text_edit.range.start.character == 5
assert items[0].text_edit.range.end.character == 14
def test_namespace():
source = "namespace eval geometry {\n" + CLASS + "set mcs [MCS new]\n$mcs |\n}"
assert "initOrg" in {item.label for item in complete(source)}
def test_lsp_space_trigger(tmp_path, monkeypatch):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + "set mcs [MCS new]\n$mcs "
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
items = lsp_server.on_completion(lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1, character=5),
context=lsp.CompletionContext(trigger_kind=lsp.CompletionTriggerKind.TriggerCharacter, trigger_character=" "),
)).items
assert "initOrg" in {item.label for item in items}
@pytest.mark.parametrize("tail", ["set mcs [MC", "set mcs [MC]", "MC"])
def test_class_name_completion_while_editing(tmp_path, monkeypatch, tail):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + tail
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
items = lsp_server.on_completion(lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1,
character=len(tail.rstrip("]"))),
)).items
classes = [item for item in items if item.label == "MCS"]
assert len(classes) == 1
assert classes[0].kind == lsp.CompletionItemKind.Class
def test_classes_are_indexed_with_their_own_kind(tmp_path, monkeypatch):
from test_completion_context import _completion_server, _document
server, _, _ = _completion_server(tmp_path, monkeypatch)
assert server.update_poco_completion_for_file(_document(tmp_path / "class.tcl", CLASS))
items = server.completion_items_snapshot()
assert any(item.label == "MCS" and item.kind == lsp.CompletionItemKind.Class for item in items)
assert "MCS" not in server.custom_function_names_snapshot()
def test_class_command_completions():
assert {item.label for item in complete(CLASS + "set mcs [MCS |]")} == {"new", "create"}
def test_semantic_class_declarations_and_uses():
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter, TokenModifier
source = CLASS + 'set mcs [MCS new]\nset second [::MCS new]\nputs "MCS"\n'
tree = CustomParser().parse(source)
highlighter = _Highlighter([], {"MCS"})
highlighter.highlight_classes(tree)
tree.accept(highlighter, recurse=True)
line = col = 0
classified = []
for token in highlighter.tokens():
col = col + token.offset if token.line == 0 else token.offset
line += token.line
text = source.splitlines()[line][col:col + token.length]
classified.append((line, col, text, token.tok_type, token.tok_modifiers))
classes = [entry for entry in classified if entry[3] == "class"]
assert [entry[2] for entry in classes] == ["MCS", "MCS", "::MCS"]
assert TokenModifier.declaration in classes[0][4]
assert not any(entry[2] in {"MCS", "::MCS"} and entry[3] == "function" for entry in classified)
assert len({entry[:2] for entry in classified}) == len(classified)
def test_namespaced_class_symbols_and_method_body_references():
from tools.parser import CustomParser
from tools.tcloo_symbols import class_completion_items, class_symbols
tree = CustomParser().parse('''namespace eval geometry {
oo::class create MCS {
method duplicate {} {return [MCS new]}
}
set mcs [MCS new]
}
set mcs [geometry::MCS new]
set mcs [::geometry::MCS new]
''')
declarations, references = class_symbols(tree)
assert set(declarations) == {"::geometry::MCS"}
assert [node.contents for node in references] == ["MCS", "MCS", "geometry::MCS", "::geometry::MCS"]
assert class_completion_items(tree)[0].label == "geometry::MCS"
+28
View File
@@ -5,6 +5,9 @@
{
"include": "#comment"
},
{
"include": "#escaped-quoted-strings"
},
{
"include": "#strings"
},
@@ -28,6 +31,31 @@
}
],
"repository": {
"escaped-quoted-strings": {
"name": "string.quoted.double.def",
"begin": "\\\\\"",
"end": "\\\\\"",
"beginCaptures": {
"0": {
"name": "punctuation.definition.string.begin.def"
}
},
"endCaptures": {
"0": {
"name": "punctuation.definition.string.end.def"
}
},
"patterns": [
{
"name": "constant.character.escape.def",
"match": "\\\\."
},
{
"name": "variable.other.property",
"match": "\\$[_a-zA-Z][_a-zA-Z0-9]*(\\(\\$?[_a-zA-Z][_a-zA-Z0-9]*(,\\$?[_a-zA-Z][_a-zA-Z0-9]*)*\\))?"
}
]
},
"keywords": {
"patterns": [
{
+280
View File
@@ -123,3 +123,283 @@ LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF
SERVICE_remove_file "test"
oo::class create MCS {
variable values
constructor {args} {
for {set i 0} {$i <= 11} {incr i} {
my initValue $i 0.
}
}
destructor {
#puts "[self] is now deleted"
}
method fformat {value {precision 7}} {return [expr {round( 10.0 ** $precision * $value) / (10.0 ** $precision)}]}
method radtodeg {rad} {return [expr {$rad*180.0/(4.0*atan(1.0))}]}
method degtorad {deg} {return [expr {$deg*(4.0*atan(1.0))/180.0}]}
method duplicate {args} {return [[MCS new] initMCS [self]]}
method type {args} {return "::MCS"}
method toArray {args} {
for {set i 0} {$i < [array size values]} {incr i} {
append arr [string cat $i " "] ; append arr [string cat $values($i) " "]
}
return [string trimright $arr]
}
method toLst {args} {
for {set i 0} {$i < [array size values]} {incr i} {
lappend lst $values($i)
}
return $lst
}
method toStr {args} {
for {set i 0} {$i < [array size values]} {incr i} {
if {$i>0} {set str [string cat $str ", "]}
append str $values($i)
}
return $str
}
method toStrF {{precision 7}} {
for {set i 0} {$i < [array size values]} {incr i} {
if {$i>0} {set str [string cat $str ", "]}
append str [my fformat $values($i) 10]
}
return $str
}
method initValue {i value} {set values($i) [expr double($value)] ; return [self]}
method initArray {a} {upvar $a arr ; for {set i 0} {$i < [array size arr]} {incr i} {my initValue $i $arr($i)} ; return [self]}
method initArrayLst {arr_lst} {array set arr $arr_lst ; for {set i 0} {$i < [array size arr]} {incr i} {my initValue $i $arr($i)} ; return [self]}
method initLst {lst} {for {set i 0} {$i < [llength $lst]} {incr i} {my initValue $i [lindex $lst $i]} ; return [self]}
method initMCS {mcs} {
my identity
set p [$mcs org] ; my initOrgP $p ; $p destroy
set v [$mcs xVct] ; my initXvctV $v ; $v destroy
set v [$mcs yVct] ; my initYvctV $v ; $v destroy
set v [$mcs zVct] ; my initZvctV $v ; $v destroy
return [self]
}
method initMTX {mtx} {
set p [$mtx org] ; my initOrg $p ; $p destroy
set v [$mtx xVct] ; my initXvct $v ; $v destroy
set v [$mtx yVct] ; my initYvct $v ; $v destroy
set v [$mtx zVct] ; my initZvct $v ; $v destroy
return [self]
}
method initOrg {dx dy dz} {my initValue 0 $dx ; my initValue 1 $dy ; my initValue 2 $dz ; return [self]}
method initOrgP {p} {return [my initOrg [$p x] [$p y] [$p z]]}
method initXvct {dx dy dz} {my initValue 3 $dx ; my initValue 4 $dy ; my initValue 5 $dz ; return [self]}
method initXvctV {v} {return [my initXvct [$v x] [$v y] [$v z]]}
method initYvct {dx dy dz} {my initValue 6 $dx ; my initValue 7 $dy ; my initValue 8 $dz ; return [self]}
method initYvctV {v} {return [my initYvct [$v x] [$v y] [$v z]]}
method initZvct {dx dy dz} {my initValue 9 $dx ; my initValue 10 $dy ; my initValue 11 $dz ; return [self]}
method initZvctV {v} {return [my initZvct [$v x] [$v y] [$v z]]}
method initOrgArray {a} {upvar $a arr ; return [my initOrg $arr(0) $arr(1) $arr(2)]}
method initVctsArray {a} {
upvar $a arr
my initXvct $arr(0) $arr(1) $arr(2)
my initYvct $arr(3) $arr(4) $arr(5)
my initZvct $arr(6) $arr(7) $arr(8)
return [self]
}
method value {i} {return $values($i)}
method org {args} {return [Point3D new $values(0) $values(1) $values(2)]}
method xVct {args} {return [Vector3D new $values(3) $values(4) $values(5)]}
method yVct {args} {return [Vector3D new $values(6) $values(7) $values(8)]}
method zVct {args} {return [Vector3D new $values(9) $values(10) $values(11)]}
method same {mcs {precision 7}} {
set org [[self] org] ; set org_1 [$mcs org]
set vx [[self] xVct] ; set vx_1 [$mcs xVct]
set vy [[self] yVct] ; set vy_1 [$mcs yVct]
set vz [[self] zVct] ; set vz_1 [$mcs zVct]
if {[$org same $org_1 $precision] && [$vx same $vx_1 $precision] && [$vy same $vy_1 $precision] && [$vz same $vz_1 $precision]} {
set value 1
} else {
set value 0
}
$org destroy ; $org_1 destroy
$vx destroy ; $vx_1 destroy
$vy destroy ; $vy_1 destroy
$vz destroy ; $vz_1 destroy
return $value
}
}
set point [Point3D new]
$point add 1 2 3
$custom_flag(from_move,$::mom_path_name)
oo::class create Point3D {
variable x 0. ; variable y 0. ; variable z 0.
constructor {{dx 0.} {dy 0.} {dz 0.}} {my initX $dx ; my initY $dy ; my initZ $dz}
destructor {
#puts "[self] is now deleted"
}
method fformat {value {precision 7}} {return [expr {round( 10.0 ** $precision * $value) / (10.0 ** $precision)}]}
method radtodeg {rad} {return [expr {$rad*180.0/(4.0*atan(1.0))}]}
method degtorad {deg} {return [expr {$deg*(4.0*atan(1.0))/180.0}]}
method duplicate {args} {return [[Point3D new] initP [self]]}
method type {args} {return "::Point3D"}
method toArray {{i 0}} {return [list [incr i 0] $x [incr i] $y [incr i] $z]}
method toLst {args} {return [list $x $y $z]}
method toStr {args} {return "$x, $y, $z"}
method toStrF {{precision 7}} {return "[my fformat $x $precision], [my fformat $y $precision], [my fformat $z $precision]"}
method x {args} {return $x}
method y {args} {return $y}
method z {args} {return $z}
method initX {dx} {set x [expr double($dx)]}
method initY {dy} {set y [expr double($dy)]}
method initZ {dz} {set z [expr double($dz)]}
method init {dx dy dz} {my initX $dx ; my initY $dy ; my initZ $dz ; return [self]}
method initArray {a} {upvar $a arr ; return [my init $arr(0) $arr(1) $arr(2)]}
method initArrayLst {arr_lst} {array set arr $arr_lst ; return [my init $arr(0) $arr(1) $arr(2)]}
method initLst {lst} {return [my init [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method initP {p} {return [my init [$p x] [$p y] [$p z]]}
method reset {args} {my init 0. 0. 0. ; return [self]}
method add {dx dy dz} {my init [expr {$x + $dx}] [expr {$y + $dy}] [expr {$z + $dz}] ; return [self]}
method addArray {a} {upvar $a arr ; return [my add $arr(0) $arr(1) $arr(2)]}
method addArrayLst {arr_lst} {array set arr $arr_lst ; return [my add $arr(0) $arr(1) $arr(2)]}
method addLst {lst} {return [my add [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method addP {p} {return [my add [$p x] [$p y] [$p z]]}
method sub {dx dy dz} {my init [expr {$x - $dx}] [expr {$y - $dy}] [expr {$z - $dz}] ; return [self]}
method subArray {a} {upvar $a arr ; return [my sub $arr(0) $arr(1) $arr(2)]}
method subArrayLst {arr_lst} {array set arr $arr_lst ; return [my sub $arr(0) $arr(1) $arr(2)]}
method subLst {lst} {return [my sub [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method subP {p} {return [my sub [$p x] [$p y] [$p z]]}
method dist {dx dy dz} {return [expr {sqrt([expr {$x - $dx}]**2 + [expr {$y - $dy}]**2 + [expr {$z - $dz}]**2)}]}
method distArray {a} {upvar $a arr ; return [my dist $arr(0) $arr(1) $arr(2)]}
method distArrayLst {arr_lst} {array set arr $arr_lst ; return [my dist $arr(0) $arr(1) $arr(2)]}
method distLst {lst} {return [my dist [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method distP {p} {return [my dist [$p x] [$p y] [$p z]]}
method dist_to_line {pl1 pl2} {
set pl [[$pl2 duplicate] subP $pl1]
set pp [[$pl1 duplicate] subP [self]]
set vl [[Vector3D new] initV $pl]
set vp [[Vector3D new] initV $pp]
set l [$vl magnitude]
if {[my fformat $l 5]==0} {
set value 0.
} else {
set vc [$vl cross $vp]
set value [expr [$vc magnitude]/$l]
$vc destroy
}
$pl destroy ; $vl destroy
$pp destroy ; $vp destroy
return $value
}
method dist_to_lineV {v} {
set p1 [[Point3D new] init 0. 0. 0.]
set p2 [[Point3D new] initP $v]
set value [my dist_to_line $p1 $p2]
$p1 destroy
$p2 destroy
return $value
}
#midpoint method to calculate the midpoint between this point and another point. This can be done by averaging the x, y, and z coordinates of the two points.
method midpoint {dx dy dz} {return [Point3D new [expr {double([[self] x] + $dx)/2}] [expr {double([[self] y] + $dy)/2}] [expr {double([[self] z] + $dz)/2}]]}
method midpointArr {a} {upvar $a arr ; return [my midpoint $arr(0) $arr(1) $arr(2)]}
method midpointArrLst {arr} {array set arr $arr_lst ; return [my midpoint $arr(0) $arr(1) $arr(2)]}
method midpointLst {lst} {return [my midpoint [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method midpointP {p} {return [my midpoint [$p x] [$p y] [$p z]]}
method same {p {precision 7}} {
if {[my fformat [expr {[[self] x] - [$p x]}] $precision] == 0. && [my fformat [expr {[[self] y] - [$p y]}] $precision] == 0. && [my fformat [expr {[[self] z] - [$p z]}] $precision] == 0.} {return 1} else {return 0}
}
#This method takes three arguments: a vector object "axis" as the axis of rotation, an angle of rotation, and a point object "center" as the center of rotation.
#First, it translates the point by subtracting the center of rotation,
#then it applies the Rodrigues' rotation formula to calculate the new coordinates of the point, and finally,
#it translates back the point by adding the center of rotation.
#The center parameter is defined as an optional parameter with a default value of a new Point3D object, initialized with the values (0,0,0).
method rotAround {u v w angle {x0 0.} {y0 0.} {z0 0.}} {
set x1 [expr {double($x) - $x0}] ; set y1 [expr {double($y) - $y0}] ; set z1 [expr {double($z) - $z0}]
set u [expr {double($u)}] ; set v [expr {double($v)}] ; set w [expr {double($w)}]
set a [my degtorad $angle]
set coss [expr {cos($a)}]
set sinn [expr {sin($a)}]
set x2 [expr {$u*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $x1*$coss + (-$w*$y1 + $v*$z1)*$sinn + $x0}]
set y2 [expr {$v*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $y1*$coss + ($w*$x1 - $u*$z1)*$sinn + $y0}]
set z2 [expr {$w*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $z1*$coss + (-$v*$x1 + $u*$y1)*$sinn + $z0}]
my init $x2 $y2 $z2
return [self]
}
method rotAroundLst {aLst angle {cLst {0. 0. 0.}}} {
return [my rotAround [lindex $aLst 0] [lindex $aLst 1] [lindex $aLst 2] $angle [lindex $cLst 0] [lindex $cLst 1] [lindex $cLst 2]]
}
method rotAroundVP {axis angle {center {}}} {
if {[catch {set s [$center type]}] || (![string equal [$center type] "::Point3D"] && ![string equal [$center type] "::Vector3D"])} {
set center [Point3D new]
set b_center true
}
my rotAround [$axis x] [$axis y] [$axis z] $angle [$center x] [$center y] [$center z]
if {[info exists b_center]} {$center destroy}
return [self]
}
method transform {mtx_from mtx_to} {
if {[catch {set s [$mtx_from type]}] || ![string equal [$mtx_from type] "::Matrix4x4"]} {
set mtx_from [[Matrix4x4 new] identity]
set b_mtx_from true
}
if {[catch {set s [$mtx_to type]}] || ![string equal [$mtx_to type] "::Matrix4x4"]} {
set mtx_to [[Matrix4x4 new] identity]
set b_mtx_to true
}
set m [[Matrix4x4 new] identity]
$m initValue 0 [my x]
$m initValue 4 [my y]
$m initValue 8 [my z]
$m initValue 12 1.
set trs [[[[$mtx_from duplicate] inverse] multiply $mtx_to false] inverse]
$m multiply $trs true
my init [$m value 0] [$m value 4] [$m value 8]
$m destroy
$trs destroy
if {[info exists b_mtx_from]} {$mtx_from destroy}
if {[info exists b_mtx_to]} {$mtx_to destroy}
return [self]
}
}