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
+105 -6
View File
@@ -77,7 +77,9 @@ from tools.signature_help import build_signature_help
from tools.tcl_command_completion import (
TCL_COMMAND_ITEMS,
TCL_COMMAND_NAMES,
tcl_argument_completions,
DynamicCompletionKind,
path_completion_items,
tcl_argument_completion,
)
WORKSPACE_SETTINGS = {}
@@ -94,9 +96,16 @@ BUILTIN_PROC_NAMES = {
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
} | set(TCL_COMMAND_NAMES)
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
_TCL_COMMAND_ITEMS_BY_LABEL = {
item.label: item for item in TCL_COMMAND_ITEMS
}
_TCL_KEYWORD_ITEMS = [
_TCL_COMMAND_ITEMS_BY_LABEL.get(item.label, item)
for item in standard_items.tcl_keyword_list
]
_STATIC_TCL_LABELS = {item.label for item in standard_items.tcl_keyword_list}
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]
+ standard_items.nx_procs
+ 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
# narrow command grammar when the cursor is at a known subcommand/option.
argument_completion = None
if context != CompletionContext.VARIABLE:
argument_items = tcl_argument_completions(source_lines, position)
if argument_items is not None:
argument_completion = tcl_argument_completion(source_lines, position)
if (
argument_completion is not None
and argument_completion.dynamic_kind is None
):
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,
)
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.
# Do not display the broad fallback list when such a trigger has no match.
if (
params.context is not None
argument_completion is None
and params.context is not None
and params.context.trigger_kind
== lsp.CompletionTriggerKind.TriggerCharacter
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
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)
items = ranked_completion_items(candidates, context)
return lsp.CompletionList(is_incomplete=False, items=items)
+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,
@@ -19,7 +19,11 @@ from tools.completion_items import (
CompletionContext,
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:
@@ -93,15 +97,19 @@ def _complete(document: TextDocument, position: lsp.Position):
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")
character = len(lines[-1].encode("utf-16-le")) // 2
items = tcl_argument_completions(
return tcl_argument_completion(
lines,
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):
@@ -308,3 +316,172 @@ def test_space_trigger_does_not_open_broad_fallback_completion(
)
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)