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
+1
View File
@@ -31,6 +31,7 @@ COMMAND_KINDS = {
lsp.CompletionItemKind.Method,
lsp.CompletionItemKind.Constructor,
lsp.CompletionItemKind.Keyword,
lsp.CompletionItemKind.Snippet,
}
_VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
+469 -35
View File
@@ -4,8 +4,44 @@ from __future__ import annotations
from collections.abc import Sequence
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)
@@ -343,7 +379,11 @@ OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
OptionSpec("--"),
),
("return",): (
OptionSpec("-code", takes_value=True),
OptionSpec(
"-code",
takes_value=True,
values=("ok", "error", "return", "break", "continue"),
),
OptionSpec("-errorcode", takes_value=True),
OptionSpec("-errorinfo", 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, ...]] = {
(("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,
}
_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(
sorted(
{path[0] for path in SUBCOMMANDS_BY_PATH}
| {path[0] for path in OPTIONS_BY_PATH}
| set(TCL_COMMAND_SNIPPET_ITEMS)
| {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES}
)
)
TCL_COMMAND_ITEMS = tuple(
CompletionItem(
label=command,
kind=CompletionItemKind.Function,
detail="Tcl command",
insert_text=command,
TCL_COMMAND_SNIPPET_ITEMS.get(
command,
CompletionItem(
label=command,
kind=CompletionItemKind.Function,
detail="Tcl command",
insert_text=command,
),
)
for command in TCL_COMMAND_NAMES
)
@@ -420,10 +703,10 @@ def line_prefix_at_position(
return line[:codepoint_offset]
def tcl_argument_completions(
def tcl_argument_completion(
source_lines: Sequence[str], position: Position
) -> list[CompletionItem] | None:
"""Return subcommand, fixed-value, or option completions at ``position``.
) -> TclArgumentCompletion | None:
"""Describe static and dynamic argument completion at ``position``.
``None`` means that the cursor is not at a command-specific 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_prefix = 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)
if subcommands is not None:
return _completion_items(
subcommands,
CompletionItemKind.EnumMember,
f"{' '.join(completed_path)} subcommand",
items = tuple(
SUBCOMMAND_SNIPPET_ITEMS.get(
(*completed_path, label),
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():
if active_index == argument_index and tuple(words[: len(path)]) == path:
return _completion_items(
values,
CompletionItemKind.Value,
f"{' '.join(path)} value",
return TclArgumentCompletion(
items=_completion_items(
values,
CompletionItemKind.Value,
f"{' '.join(path)} value",
),
active_prefix=active_prefix,
)
for path in sorted(OPTIONS_BY_PATH, key=len, reverse=True):
if active_index < len(path) or tuple(words[: len(path)]) != path:
continue
return _option_completions(
option_completion = _option_completion(
path,
OPTIONS_BY_PATH[path],
words[len(path) : active_index],
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, ...],
options: tuple[OptionSpec, ...],
completed_arguments: Sequence[str],
active_prefix: str,
) -> list[CompletionItem] | None:
) -> TclArgumentCompletion | None:
option_by_label = {option.label: option for option in options}
used_options: set[str] = set()
argument_index = 0
@@ -503,10 +813,13 @@ def _option_completions(
if argument_index >= len(completed_arguments):
if option.values:
return _completion_items(
option.values,
CompletionItemKind.Value,
f"{option.label} value",
return TclArgumentCompletion(
items=_completion_items(
option.values,
CompletionItemKind.Value,
f"{option.label} value",
),
active_prefix=active_prefix,
)
return None
argument_index += 1
@@ -517,17 +830,53 @@ def _option_completions(
remaining_options = tuple(
option.label for option in options if option.label not in used_options
)
return _completion_items(
remaining_options,
CompletionItemKind.Keyword,
f"{' '.join(path)} option",
return TclArgumentCompletion(
items=_completion_items(
remaining_options,
CompletionItemKind.Keyword,
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(
labels: Sequence[str], kind: CompletionItemKind, detail: str
) -> list[CompletionItem]:
return [
) -> tuple[CompletionItem, ...]:
return tuple(
CompletionItem(
label=label,
kind=kind,
@@ -535,7 +884,85 @@ def _completion_items(
insert_text=label,
)
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:
@@ -592,41 +1019,48 @@ def _tokenize_command_segment(segment: str) -> list[str]:
in_quote = False
escaped = False
ended_with_separator = False
word_started = False
for char in segment:
if escaped:
current.append(char)
escaped = False
ended_with_separator = False
word_started = True
continue
if char == "\\":
current.append(char)
escaped = True
ended_with_separator = False
word_started = True
continue
if char == '"' and brace_depth == 0:
in_quote = not in_quote
ended_with_separator = False
word_started = True
continue
if not in_quote and char == "{":
brace_depth += 1
ended_with_separator = False
word_started = True
continue
if not in_quote and char == "}" and brace_depth:
brace_depth -= 1
ended_with_separator = False
continue
if char.isspace() and not in_quote and brace_depth == 0:
if current:
if current or word_started:
words.append("".join(current))
current = []
word_started = False
ended_with_separator = bool(words)
continue
current.append(char)
ended_with_separator = False
word_started = True
if current:
if current or word_started:
words.append("".join(current))
elif ended_with_separator:
words.append("")
+18 -8
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Set
from tclint.syntax_tree import Command, Node, Script
from tclint.syntax_tree import List as TclList
@dataclass
@@ -18,13 +18,14 @@ def _normalize_var_name(raw_name: str | None) -> str | None:
return None
base = raw_name.split("(", 1)[0]
if base.startswith("::"):
base = base[2:]
base = base.removeprefix("::")
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:
- 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.
globals_set: Set[str] = set()
procs: Dict[str, Set[str]] = {}
proc_ranges: List[ProcRange] = []
globals_set: set[str] = set()
procs: dict[str, set[str]] = {}
proc_ranges: list[ProcRange] = []
if tree is None:
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):
proc_name = _normalize_var_name(getattr(node.args[0], "contents", 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(
ProcRange(
name=proc_name,