feat(tcl): add dynamic argument completion and snippets

The changes add dynamic, semantic argument completion for Tcl
commands and snippet support.

- Introduces DynamicCompletionKind, TclArgumentCompletion, and dynamic rules
  for Tcl to provide variable, procedure, namespace, and path suggestions.
- Adds snippet-backed commands and arguments for Tcl blocks and paths.
- Refactors tcl_argument_completion and updates the LSP to use dynamic path,
  variable, and namespace completions with snippet kinds.
This commit is contained in:
Christoph Brandau
2026-09-03 10:35:39 +02:00
parent 20f76b6a20
commit af5acfc946
7 changed files with 781 additions and 56 deletions
+2
View File
@@ -4,6 +4,8 @@
- Add document highlights for procedure and variable occurrences - Add document highlights for procedure and variable occurrences
- Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols - Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols
- Add command-aware completion for Tcl subcommands, fixed arguments, and valid options - Add command-aware completion for Tcl subcommands, fixed arguments, and valid options
- Add semantic argument completion for variables, procedures, namespaces, and local file paths
- Add placeholder-based snippets for common Tcl structures and `dict for`
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support - Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files - Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops - Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
+4 -2
View File
@@ -12,7 +12,8 @@ A comprehensive VS Code extension providing language support and remote debuggin
- **Signature Help** - Shows parameters and documentation for custom and NX procedures - **Signature Help** - Shows parameters and documentation for custom and NX procedures
- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers - **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers
- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor - **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor
- **Context-aware Completion** - Prioritizes local symbols and suggests variables, commands, Tcl subcommands, and valid options based on cursor context - **Context-aware Completion** - Prioritizes local symbols and suggests variables, procedures, namespaces, paths, Tcl subcommands, valid argument values, and options based on cursor context
- **Tcl Snippets** - Inserts placeholder-based structures for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for`
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process - **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
## Supported File Types ## Supported File Types
@@ -101,7 +102,8 @@ Simply open any supported file type and enjoy:
- Signature help while entering procedure arguments - Signature help while entering procedure arguments
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers - Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
- Document-wide highlights for procedure and variable occurrences - Document-wide highlights for procedure and variable occurrences
- Context-aware completion with local symbols ranked before workspace and built-in symbols, plus Tcl subcommands and options such as `string compare -nocase` - Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase`
- Placeholder-based snippets for common Tcl control structures and procedures
- Remote NX Tcl debugging with breakpoints and full stepping - Remote NX Tcl debugging with breakpoints and full stepping
## Contributing ## Contributing
+105 -6
View File
@@ -77,7 +77,9 @@ from tools.signature_help import build_signature_help
from tools.tcl_command_completion import ( from tools.tcl_command_completion import (
TCL_COMMAND_ITEMS, TCL_COMMAND_ITEMS,
TCL_COMMAND_NAMES, TCL_COMMAND_NAMES,
tcl_argument_completions, DynamicCompletionKind,
path_completion_items,
tcl_argument_completion,
) )
WORKSPACE_SETTINGS = {} WORKSPACE_SETTINGS = {}
@@ -94,9 +96,16 @@ BUILTIN_PROC_NAMES = {
for item in standard_items.tcl_keyword_list + standard_items.nx_procs for item in standard_items.tcl_keyword_list + standard_items.nx_procs
} | set(TCL_COMMAND_NAMES) } | set(TCL_COMMAND_NAMES)
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables} 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
]
_STATIC_TCL_LABELS = {item.label 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( STATIC_COMPLETION_ITEMS = tuple(
standard_items.tcl_keyword_list _TCL_KEYWORD_ITEMS
+ [item for item in TCL_COMMAND_ITEMS if item.label not in _STATIC_TCL_LABELS] + [item for item in TCL_COMMAND_ITEMS if item.label not in _STATIC_TCL_LABELS]
+ standard_items.nx_procs + standard_items.nx_procs
+ standard_items.nx_variables + standard_items.nx_variables
@@ -272,11 +281,37 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
# Variable completion wins inside command arguments. Otherwise prefer the # Variable completion wins inside command arguments. Otherwise prefer the
# narrow command grammar when the cursor is at a known subcommand/option. # narrow command grammar when the cursor is at a known subcommand/option.
argument_completion = None
if context != CompletionContext.VARIABLE: if context != CompletionContext.VARIABLE:
argument_items = tcl_argument_completions(source_lines, position) argument_completion = tcl_argument_completion(source_lines, position)
if argument_items is not None: if (
argument_completion is not None
and argument_completion.dynamic_kind is None
):
items = ranked_completion_items( items = ranked_completion_items(
((0, item) for item in argument_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
):
dynamic_items: tuple[lsp.CompletionItem, ...] = ()
if doc.uri.startswith("file:"):
document_path = pathlib.Path(uris.to_fs_path(doc.uri))
dynamic_items = path_completion_items(
document_path.parent,
argument_completion,
position,
)
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,
CompletionContext.GENERAL, CompletionContext.GENERAL,
) )
return lsp.CompletionList(is_incomplete=False, items=items) return lsp.CompletionList(is_incomplete=False, items=items)
@@ -284,7 +319,8 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
# Space and dash are registered only to open command-aware suggestions. # Space and dash are registered only to open command-aware suggestions.
# Do not display the broad fallback list when such a trigger has no match. # Do not display the broad fallback list when such a trigger has no match.
if ( if (
params.context is not None argument_completion is None
and params.context is not None
and params.context.trigger_kind and params.context.trigger_kind
== lsp.CompletionTriggerKind.TriggerCharacter == lsp.CompletionTriggerKind.TriggerCharacter
and params.context.trigger_character in {" ", "-"} and params.context.trigger_character in {" ", "-"}
@@ -337,6 +373,69 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
priority = 100 if LSP_SERVER.paths_equal(item_path, filepath) else 200 priority = 100 if LSP_SERVER.paths_equal(item_path, filepath) else 200
candidates.extend((priority, item) for item in items_by_file[item_path]) candidates.extend((priority, item) for item in items_by_file[item_path])
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
)
items = ranked_completion_items(
variable_candidates,
CompletionContext.VARIABLE,
)
return lsp.CompletionList(is_incomplete=False, items=items)
if argument_completion.dynamic_kind == DynamicCompletionKind.PROCEDURE:
procedure_kinds = {
lsp.CompletionItemKind.Constructor,
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
)
items = ranked_completion_items(
[*static_candidates, *procedure_candidates],
CompletionContext.GENERAL,
)
return lsp.CompletionList(is_incomplete=False, items=items)
if argument_completion.dynamic_kind == DynamicCompletionKind.NAMESPACE:
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
)
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",
),
)
)
items = ranked_completion_items(
namespace_candidates,
CompletionContext.GENERAL,
)
return lsp.CompletionList(is_incomplete=False, items=items)
candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS) candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS)
items = ranked_completion_items(candidates, context) items = ranked_completion_items(candidates, context)
return lsp.CompletionList(is_incomplete=False, items=items) return lsp.CompletionList(is_incomplete=False, items=items)
+1
View File
@@ -31,6 +31,7 @@ COMMAND_KINDS = {
lsp.CompletionItemKind.Method, lsp.CompletionItemKind.Method,
lsp.CompletionItemKind.Constructor, lsp.CompletionItemKind.Constructor,
lsp.CompletionItemKind.Keyword, lsp.CompletionItemKind.Keyword,
lsp.CompletionItemKind.Snippet,
} }
_VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$") _VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
+455 -21
View File
@@ -4,8 +4,44 @@ from __future__ import annotations
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from lsprotocol.types import CompletionItem, CompletionItemKind, Position from lsprotocol.types import (
CompletionItem,
CompletionItemKind,
InsertTextFormat,
Position,
Range,
TextEdit,
)
class DynamicCompletionKind(Enum):
"""Workspace- or filesystem-backed completion requested by the grammar."""
VARIABLE = "variable"
PROCEDURE = "procedure"
NAMESPACE = "namespace"
PATH = "path"
@dataclass(frozen=True)
class TclArgumentCompletion:
"""Static suggestions plus an optional dynamic completion category."""
items: tuple[CompletionItem, ...] = ()
dynamic_kind: DynamicCompletionKind | None = None
active_prefix: str = ""
path_extensions: tuple[str, ...] = ()
@dataclass(frozen=True)
class DynamicCompletionRule:
path: tuple[str, ...]
argument_indices: frozenset[int]
kind: DynamicCompletionKind
path_extensions: tuple[str, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -343,7 +379,11 @@ OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
OptionSpec("--"), OptionSpec("--"),
), ),
("return",): ( ("return",): (
OptionSpec("-code", takes_value=True), OptionSpec(
"-code",
takes_value=True,
values=("ok", "error", "return", "break", "continue"),
),
OptionSpec("-errorcode", takes_value=True), OptionSpec("-errorcode", takes_value=True),
OptionSpec("-errorinfo", takes_value=True), OptionSpec("-errorinfo", takes_value=True),
OptionSpec("-errorstack", takes_value=True), OptionSpec("-errorstack", takes_value=True),
@@ -383,23 +423,266 @@ for _string_class in STRING_CLASSES:
VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = { VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = {
(("array", "names"), 3): ("-exact", "-glob", "-regexp"), (("array", "names"), 3): ("-exact", "-glob", "-regexp"),
(("close",), 2): ("read", "write"),
(("open",), 2): ("r", "r+", "w", "w+", "a", "a+"),
(("package", "prefer"), 2): ("latest", "stable"),
(("seek",), 3): ("start", "current", "end"),
(("string", "is"), 2): STRING_CLASSES, (("string", "is"), 2): STRING_CLASSES,
} }
_REPEATED_ARGUMENTS = frozenset(range(1, 33))
_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(("incr",), frozenset({1}), 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(("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(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
# Namespace-taking commands.
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
),
# 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(("open",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule(
("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)
),
*(
DynamicCompletionRule(
("file", subcommand),
_REPEATED_SUBCOMMAND_ARGUMENTS,
DynamicCompletionKind.PATH,
)
for subcommand in ("copy", "delete", "join", "link", "mkdir", "rename")
),
*(
DynamicCompletionRule(
("file", subcommand),
frozenset({2}),
DynamicCompletionKind.PATH,
)
for subcommand in (
"atime",
"attributes",
"dirname",
"executable",
"exists",
"extension",
"isdirectory",
"isfile",
"lstat",
"mtime",
"nativename",
"normalize",
"owned",
"pathtype",
"readable",
"readlink",
"rootname",
"separator",
"size",
"split",
"stat",
"system",
"tail",
"type",
"writable",
)
),
)
def _snippet_item(label: str, insert_text: str, detail: str) -> CompletionItem:
return CompletionItem(
label=label,
kind=CompletionItemKind.Snippet,
detail=detail,
insert_text=insert_text,
insert_text_format=InsertTextFormat.Snippet,
)
TCL_COMMAND_SNIPPET_ITEMS = {
"foreach": _snippet_item(
"foreach",
"foreach ${1:item} ${2:list} {\n\t${0}\n}",
"Tcl foreach loop",
),
"if": _snippet_item(
"if",
"if {${1:condition}} {\n\t${0}\n}",
"Tcl if block",
),
"proc": _snippet_item(
"proc",
"proc ${1:name} {${2:arguments}} {\n\t${0}\n}",
"Tcl procedure",
),
"switch": _snippet_item(
"switch",
"switch -- ${1:value} {\n\t${2:pattern} {\n\t\t${0}\n\t}\n}",
"Tcl switch block",
),
"try": _snippet_item(
"try",
"try {\n\t${1}\n} on error {${2:message} ${3:options}} {\n\t${0}\n}",
"Tcl try/on error block",
),
}
ARGUMENT_SNIPPETS_BY_PATH = {
("dict", "for"): _snippet_item(
"dict for loop",
"{${1:key} ${2:value}} ${3:dictionary} {\n\t${0}\n}",
"Arguments and body for dict for",
),
("foreach",): _snippet_item(
"foreach loop",
"${1:item} ${2:list} {\n\t${0}\n}",
"Arguments and body for foreach",
),
("if",): _snippet_item(
"if block",
"{${1:condition}} {\n\t${0}\n}",
"Condition and body for if",
),
("proc",): _snippet_item(
"procedure",
"${1:name} {${2:arguments}} {\n\t${0}\n}",
"Name, arguments, and body for proc",
),
("switch",): _snippet_item(
"switch block",
"-- ${1:value} {\n\t${2:pattern} {\n\t\t${0}\n\t}\n}",
"Value, patterns, and body for switch",
),
("try",): _snippet_item(
"try/on error block",
"{\n\t${1}\n} on error {${2:message} ${3:options}} {\n\t${0}\n}",
"Body and error handler for try",
),
}
SUBCOMMAND_SNIPPET_ITEMS = {
("dict", "for"): _snippet_item(
"for",
"for {${1:key} ${2:value}} ${3:dictionary} {\n\t${0}\n}",
"dict for loop",
)
}
TCL_COMMAND_NAMES = tuple( TCL_COMMAND_NAMES = tuple(
sorted( sorted(
{path[0] for path in SUBCOMMANDS_BY_PATH} {path[0] for path in SUBCOMMANDS_BY_PATH}
| {path[0] for path in OPTIONS_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( TCL_COMMAND_ITEMS = tuple(
TCL_COMMAND_SNIPPET_ITEMS.get(
command,
CompletionItem( CompletionItem(
label=command, label=command,
kind=CompletionItemKind.Function, kind=CompletionItemKind.Function,
detail="Tcl command", detail="Tcl command",
insert_text=command, insert_text=command,
),
) )
for command in TCL_COMMAND_NAMES for command in TCL_COMMAND_NAMES
) )
@@ -420,10 +703,10 @@ def line_prefix_at_position(
return line[:codepoint_offset] return line[:codepoint_offset]
def tcl_argument_completions( def tcl_argument_completion(
source_lines: Sequence[str], position: Position source_lines: Sequence[str], position: Position
) -> list[CompletionItem] | None: ) -> TclArgumentCompletion | None:
"""Return subcommand, fixed-value, or option completions at ``position``. """Describe static and dynamic argument completion at ``position``.
``None`` means that the cursor is not at a command-specific completion ``None`` means that the cursor is not at a command-specific completion
position and the caller should fall back to normal symbol completion. position and the caller should fall back to normal symbol completion.
@@ -445,43 +728,70 @@ def tcl_argument_completions(
active_index = len(words) - 1 active_index = len(words) - 1
active_prefix = words[active_index] active_prefix = words[active_index]
completed_path = tuple(words[:active_index]) completed_path = tuple(words[:active_index])
dynamic_completion = _dynamic_completion(words, active_index, active_prefix)
subcommands = SUBCOMMANDS_BY_PATH.get(completed_path) subcommands = SUBCOMMANDS_BY_PATH.get(completed_path)
if subcommands is not None: if subcommands is not None:
return _completion_items( items = tuple(
subcommands, SUBCOMMAND_SNIPPET_ITEMS.get(
CompletionItemKind.EnumMember, (*completed_path, label),
f"{' '.join(completed_path)} subcommand", CompletionItem(
label=label,
kind=CompletionItemKind.EnumMember,
detail=f"{' '.join(completed_path)} subcommand",
insert_text=label,
),
) )
for label in subcommands
)
return TclArgumentCompletion(items=items, active_prefix=active_prefix)
argument_snippet = ARGUMENT_SNIPPETS_BY_PATH.get(completed_path)
argument_items = (argument_snippet,) if argument_snippet is not None else ()
for (path, argument_index), values in VALUES_BY_POSITION.items(): for (path, argument_index), values in VALUES_BY_POSITION.items():
if active_index == argument_index and tuple(words[: len(path)]) == path: if active_index == argument_index and tuple(words[: len(path)]) == path:
return _completion_items( return TclArgumentCompletion(
items=_completion_items(
values, values,
CompletionItemKind.Value, CompletionItemKind.Value,
f"{' '.join(path)} value", f"{' '.join(path)} value",
),
active_prefix=active_prefix,
) )
for path in sorted(OPTIONS_BY_PATH, key=len, reverse=True): for path in sorted(OPTIONS_BY_PATH, key=len, reverse=True):
if active_index < len(path) or tuple(words[: len(path)]) != path: if active_index < len(path) or tuple(words[: len(path)]) != path:
continue continue
return _option_completions( option_completion = _option_completion(
path, path,
OPTIONS_BY_PATH[path], OPTIONS_BY_PATH[path],
words[len(path) : active_index], words[len(path) : active_index],
active_prefix, active_prefix,
) )
if option_completion is not None:
if active_prefix.startswith("-"):
return option_completion
return _merge_dynamic_completion(
(*argument_items, *option_completion.items),
dynamic_completion,
active_prefix,
)
return None if argument_items:
return _merge_dynamic_completion(
argument_items, dynamic_completion, active_prefix
)
return dynamic_completion
def _option_completions( def _option_completion(
path: tuple[str, ...], path: tuple[str, ...],
options: tuple[OptionSpec, ...], options: tuple[OptionSpec, ...],
completed_arguments: Sequence[str], completed_arguments: Sequence[str],
active_prefix: str, active_prefix: str,
) -> list[CompletionItem] | None: ) -> TclArgumentCompletion | None:
option_by_label = {option.label: option for option in options} option_by_label = {option.label: option for option in options}
used_options: set[str] = set() used_options: set[str] = set()
argument_index = 0 argument_index = 0
@@ -503,10 +813,13 @@ def _option_completions(
if argument_index >= len(completed_arguments): if argument_index >= len(completed_arguments):
if option.values: if option.values:
return _completion_items( return TclArgumentCompletion(
items=_completion_items(
option.values, option.values,
CompletionItemKind.Value, CompletionItemKind.Value,
f"{option.label} value", f"{option.label} value",
),
active_prefix=active_prefix,
) )
return None return None
argument_index += 1 argument_index += 1
@@ -517,17 +830,53 @@ def _option_completions(
remaining_options = tuple( remaining_options = tuple(
option.label for option in options if option.label not in used_options option.label for option in options if option.label not in used_options
) )
return _completion_items( return TclArgumentCompletion(
items=_completion_items(
remaining_options, remaining_options,
CompletionItemKind.Keyword, CompletionItemKind.Keyword,
f"{' '.join(path)} option", f"{' '.join(path)} option",
),
active_prefix=active_prefix,
)
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,
path_extensions=rule.path_extensions,
)
return None
def _merge_dynamic_completion(
items: Sequence[CompletionItem],
dynamic_completion: TclArgumentCompletion | None,
active_prefix: str,
) -> TclArgumentCompletion:
if dynamic_completion is None:
return TclArgumentCompletion(items=tuple(items), active_prefix=active_prefix)
return TclArgumentCompletion(
items=tuple(items),
dynamic_kind=dynamic_completion.dynamic_kind,
active_prefix=active_prefix,
path_extensions=dynamic_completion.path_extensions,
) )
def _completion_items( def _completion_items(
labels: Sequence[str], kind: CompletionItemKind, detail: str labels: Sequence[str], kind: CompletionItemKind, detail: str
) -> list[CompletionItem]: ) -> tuple[CompletionItem, ...]:
return [ return tuple(
CompletionItem( CompletionItem(
label=label, label=label,
kind=kind, kind=kind,
@@ -535,7 +884,85 @@ def _completion_items(
insert_text=label, insert_text=label,
) )
for label in labels for label in labels
] )
def path_completion_items(
base_directory: Path,
completion: TclArgumentCompletion,
position: Position,
*,
limit: int = 200,
) -> tuple[CompletionItem, ...]:
"""Complete one filesystem path relative to the current Tcl document."""
raw_prefix = completion.active_prefix
separator_index = max(raw_prefix.rfind("/"), raw_prefix.rfind("\\"))
typed_directory = raw_prefix[: separator_index + 1]
name_prefix = raw_prefix[separator_index + 1 :]
normalized_directory = typed_directory.replace("\\", "/")
filesystem_directory = Path(normalized_directory)
if not filesystem_directory.is_absolute():
filesystem_directory = base_directory / filesystem_directory
try:
entries = sorted(
filesystem_directory.iterdir(),
key=lambda entry: (not entry.is_dir(), entry.name.casefold()),
)
except (OSError, ValueError):
return ()
allowed_extensions = {
extension.casefold() for extension in completion.path_extensions
}
replace_start = max(
0,
position.character - len(raw_prefix.encode("utf-16-le")) // 2,
)
replace_range = Range(
start=Position(line=position.line, character=replace_start),
end=position,
)
items: list[CompletionItem] = []
for entry in entries:
if not entry.name.casefold().startswith(name_prefix.casefold()):
continue
try:
is_directory = entry.is_dir()
except OSError:
continue
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
)
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
),
detail="Directory" if is_directory else "File",
text_edit=TextEdit(range=replace_range, new_text=new_text),
)
)
if len(items) >= limit:
break
return tuple(items)
def _current_command_segment(line_prefix: str) -> str: def _current_command_segment(line_prefix: str) -> str:
@@ -592,41 +1019,48 @@ def _tokenize_command_segment(segment: str) -> list[str]:
in_quote = False in_quote = False
escaped = False escaped = False
ended_with_separator = False ended_with_separator = False
word_started = False
for char in segment: for char in segment:
if escaped: if escaped:
current.append(char) current.append(char)
escaped = False escaped = False
ended_with_separator = False ended_with_separator = False
word_started = True
continue continue
if char == "\\": if char == "\\":
current.append(char) current.append(char)
escaped = True escaped = True
ended_with_separator = False ended_with_separator = False
word_started = True
continue continue
if char == '"' and brace_depth == 0: if char == '"' and brace_depth == 0:
in_quote = not in_quote in_quote = not in_quote
ended_with_separator = False ended_with_separator = False
word_started = True
continue continue
if not in_quote and char == "{": if not in_quote and char == "{":
brace_depth += 1 brace_depth += 1
ended_with_separator = False ended_with_separator = False
word_started = True
continue continue
if not in_quote and char == "}" and brace_depth: if not in_quote and char == "}" and brace_depth:
brace_depth -= 1 brace_depth -= 1
ended_with_separator = False ended_with_separator = False
continue continue
if char.isspace() and not in_quote and brace_depth == 0: if char.isspace() and not in_quote and brace_depth == 0:
if current: if current or word_started:
words.append("".join(current)) words.append("".join(current))
current = [] current = []
word_started = False
ended_with_separator = bool(words) ended_with_separator = bool(words)
continue continue
current.append(char) current.append(char)
ended_with_separator = False ended_with_separator = False
word_started = True
if current: if current or word_started:
words.append("".join(current)) words.append("".join(current))
elif ended_with_separator: elif ended_with_separator:
words.append("") words.append("")
+18 -8
View File
@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Set
from tclint.syntax_tree import Command, Node, Script from tclint.syntax_tree import Command, Node, Script
from tclint.syntax_tree import List as TclList
@dataclass @dataclass
@@ -18,13 +18,14 @@ def _normalize_var_name(raw_name: str | None) -> str | None:
return None return None
base = raw_name.split("(", 1)[0] base = raw_name.split("(", 1)[0]
if base.startswith("::"): base = base.removeprefix("::")
base = base[2:]
return base or None return base or None
def build_variable_index(source: str, tree: Node | None = None) -> tuple[Set[str], Dict[str, Set[str]], List[ProcRange]]: def build_variable_index(
source: str, tree: Node | None = None
) -> tuple[set[str], dict[str, set[str]], list[ProcRange]]:
""" """
Parse Tcl source text and build: Parse Tcl source text and build:
- globals: set of variable names considered global suggestions - globals: set of variable names considered global suggestions
@@ -40,9 +41,9 @@ def build_variable_index(source: str, tree: Node | None = None) -> tuple[Set[str
""" """
_ = source # Kept for signature compatibility with callers. _ = source # Kept for signature compatibility with callers.
globals_set: Set[str] = set() globals_set: set[str] = set()
procs: Dict[str, Set[str]] = {} procs: dict[str, set[str]] = {}
proc_ranges: List[ProcRange] = [] proc_ranges: list[ProcRange] = []
if tree is None: if tree is None:
return globals_set, procs, proc_ranges return globals_set, procs, proc_ranges
@@ -54,7 +55,16 @@ def build_variable_index(source: str, tree: Node | None = None) -> tuple[Set[str
if routine == "proc" and len(node.args) >= 3 and isinstance(node.args[2], Script): if routine == "proc" and len(node.args) >= 3 and isinstance(node.args[2], Script):
proc_name = _normalize_var_name(getattr(node.args[0], "contents", None)) proc_name = _normalize_var_name(getattr(node.args[0], "contents", None))
if proc_name is not None: if proc_name is not None:
procs.setdefault(proc_name, set()) local_variables = procs.setdefault(proc_name, set())
for parameter in getattr(node.args[1], "children", []):
parameter_node = parameter
if isinstance(parameter, TclList) and parameter.children:
parameter_node = parameter.children[0]
parameter_name = _normalize_var_name(
getattr(parameter_node, "contents", None)
)
if parameter_name is not None:
local_variables.add(parameter_name)
proc_ranges.append( proc_ranges.append(
ProcRange( ProcRange(
name=proc_name, name=proc_name,
@@ -19,7 +19,11 @@ from tools.completion_items import (
CompletionContext, CompletionContext,
completion_context, completion_context,
) )
from tools.tcl_command_completion import tcl_argument_completions from tools.tcl_command_completion import (
DynamicCompletionKind,
path_completion_items,
tcl_argument_completion,
)
def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position: def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position:
@@ -93,15 +97,19 @@ def _complete(document: TextDocument, position: lsp.Position):
def _argument_completion_labels(source: str) -> set[str] | None: def _argument_completion_labels(source: str) -> set[str] | None:
completion = _argument_completion_request(source)
if completion is None:
return None
return {item.label for item in completion.items}
def _argument_completion_request(source: str):
lines = source.split("\n") lines = source.split("\n")
character = len(lines[-1].encode("utf-16-le")) // 2 character = len(lines[-1].encode("utf-16-le")) // 2
items = tcl_argument_completions( return tcl_argument_completion(
lines, lines,
lsp.Position(line=len(lines) - 1, character=character), lsp.Position(line=len(lines) - 1, character=character),
) )
if items is None:
return None
return {item.label for item in items}
def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch): def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch):
@@ -308,3 +316,172 @@ def test_space_trigger_does_not_open_broad_fallback_completion(
) )
assert result.items == [] assert result.items == []
def test_argument_values_are_suggested_at_the_expected_position():
assert _argument_completion_labels("return -code ") == {
"break",
"continue",
"error",
"ok",
"return",
}
assert _argument_completion_labels("open output.txt ") == {
"a",
"a+",
"r",
"r+",
"w",
"w+",
}
assert _argument_completion_labels("seek channel 0 ") == {
"current",
"end",
"start",
}
def test_dynamic_argument_categories_are_detected():
variable = _argument_completion_request("set ")
procedure = _argument_completion_request("info body ")
namespace = _argument_completion_request("namespace eval ")
path = _argument_completion_request("source scripts/")
assert variable is not None
assert variable.dynamic_kind == DynamicCompletionKind.VARIABLE
assert procedure is not None
assert procedure.dynamic_kind == DynamicCompletionKind.PROCEDURE
assert namespace is not None
assert namespace.dynamic_kind == DynamicCompletionKind.NAMESPACE
assert path is not None
assert path.dynamic_kind == DynamicCompletionKind.PATH
assert path.path_extensions == (".tcl",)
def test_path_completion_is_relative_filtered_and_tcl_safe(tmp_path: Path):
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "first file.tcl").write_text("puts ok", encoding="utf-8")
(scripts / "ignored.txt").write_text("ignored", encoding="utf-8")
source = "source scripts/f"
completion = _argument_completion_request(source)
assert completion is not None
items = path_completion_items(
tmp_path,
completion,
_position_after(source, source),
)
assert [item.label for item in items] == ["scripts/first\\ file.tcl"]
assert items[0].kind == lsp.CompletionItemKind.File
assert items[0].text_edit is not None
assert items[0].text_edit.new_text == "scripts/first\\ file.tcl"
assert items[0].text_edit.range.start.character == len("source ")
def test_lsp_source_completion_reads_paths_from_document_directory(
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch)
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "helper.tcl").write_text("proc helper {} {}", encoding="utf-8")
(scripts / "ignored.txt").write_text("ignored", encoding="utf-8")
source = "source scripts/"
current = _document(tmp_path / "current.tcl", source)
server.workspace.put_text_document(
lsp.TextDocumentItem(
uri=current.uri,
language_id="tcl",
version=2,
text=source,
)
)
items = _complete(current, _position_after(source, source))
labels = {item.label for item in items}
assert "scripts/helper.tcl" in labels
assert "scripts/ignored.txt" not in labels
def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch)
command_items = _complete(current, _position_after(source, "localP", occurrence=1))
command_by_label = {item.label: item for item in command_items}
assert command_by_label["if"].kind == lsp.CompletionItemKind.Snippet
assert command_by_label["if"].insert_text_format == lsp.InsertTextFormat.Snippet
assert "${1:condition}" in (command_by_label["if"].insert_text or "")
dict_for = _argument_completion_request("dict f")
assert dict_for is not None
for_item = next(item for item in dict_for.items if item.label == "for")
assert for_item.kind == lsp.CompletionItemKind.Snippet
assert "${3:dictionary}" in (for_item.insert_text or "")
switch_arguments = _argument_completion_request("switch ")
assert switch_arguments is not None
assert {"switch block", "-exact", "-glob", "-regexp"} <= {
item.label for item in switch_arguments.items
}
def test_semantic_variable_and_procedure_argument_completion(
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch)
variable_items = _complete(current, _position_after(source, " set "))
assert variable_items
assert all(item.kind in VARIABLE_KINDS for item in variable_items)
assert {"argument", "localValue"} <= {item.label for item in variable_items}
procedure_source = source + "info body localProc\n"
current = _document(tmp_path / "current.tcl", procedure_source)
lsp_server.LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(
uri=current.uri,
language_id="tcl",
version=2,
text=procedure_source,
)
)
procedure_items = _complete(
current,
_position_after(procedure_source, "info body "),
)
procedure_labels = {item.label for item in procedure_items}
assert "localProc" in procedure_labels
assert "workspaceProc" in procedure_labels
assert "MOM_abort" in procedure_labels
assert "localValue" not in procedure_labels
assert "string" not in procedure_labels
def test_namespace_argument_completion_uses_navigation_index(
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch)
namespace_source = "namespace eval tools { proc helper {} { return } }\n"
namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source)
assert server.update_poco_completion_for_file(namespace_document)
source = "namespace eval "
current = _document(tmp_path / "current.tcl", source)
server.workspace.put_text_document(
lsp.TextDocumentItem(
uri=current.uri,
language_id="tcl",
version=2,
text=source,
)
)
items = _complete(current, _position_after(source, source))
assert "tools" in {item.label for item in items}
assert all(item.kind == lsp.CompletionItemKind.Module for item in items)