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:
+105
-6
@@ -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)
|
||||
|
||||
@@ -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_:]*$")
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user