feat(lsp): add Tcl command-aware completion and integration
- Introduced a Tcl command-aware completion engine and wired to LSP. - Added a new module with Tcl commands, options, and contextual matching. - Updated completion to favor command-aware items and args.
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
"""Command-aware completion data and cursor parsing for Tcl commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from lsprotocol.types import CompletionItem, CompletionItemKind, Position
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptionSpec:
|
||||
"""A command option and whether it consumes the following word."""
|
||||
|
||||
label: str
|
||||
takes_value: bool = False
|
||||
values: tuple[str, ...] = ()
|
||||
|
||||
|
||||
STRING_CLASSES = (
|
||||
"alnum",
|
||||
"alpha",
|
||||
"ascii",
|
||||
"boolean",
|
||||
"control",
|
||||
"digit",
|
||||
"double",
|
||||
"entier",
|
||||
"false",
|
||||
"graph",
|
||||
"integer",
|
||||
"list",
|
||||
"lower",
|
||||
"print",
|
||||
"punct",
|
||||
"space",
|
||||
"true",
|
||||
"upper",
|
||||
"wideinteger",
|
||||
"wordchar",
|
||||
"xdigit",
|
||||
)
|
||||
|
||||
|
||||
# Keys are complete command paths. Their values are valid words immediately
|
||||
# following that path. Keeping this declarative makes Tcl-version additions easy.
|
||||
SUBCOMMANDS_BY_PATH: dict[tuple[str, ...], tuple[str, ...]] = {
|
||||
("array",): (
|
||||
"anymore",
|
||||
"donesearch",
|
||||
"exists",
|
||||
"get",
|
||||
"names",
|
||||
"nextelement",
|
||||
"set",
|
||||
"size",
|
||||
"startsearch",
|
||||
"statistics",
|
||||
"unset",
|
||||
),
|
||||
("binary",): ("decode", "encode", "format", "scan"),
|
||||
("binary", "decode"): ("base64", "hex", "uuencode"),
|
||||
("binary", "encode"): ("base64", "hex", "uuencode"),
|
||||
("chan",): (
|
||||
"blocked",
|
||||
"close",
|
||||
"configure",
|
||||
"copy",
|
||||
"create",
|
||||
"eof",
|
||||
"event",
|
||||
"flush",
|
||||
"gets",
|
||||
"names",
|
||||
"pending",
|
||||
"pipe",
|
||||
"pop",
|
||||
"postevent",
|
||||
"push",
|
||||
"puts",
|
||||
"read",
|
||||
"seek",
|
||||
"tell",
|
||||
"truncate",
|
||||
),
|
||||
("clock",): (
|
||||
"add",
|
||||
"clicks",
|
||||
"format",
|
||||
"microseconds",
|
||||
"milliseconds",
|
||||
"scan",
|
||||
"seconds",
|
||||
),
|
||||
("dict",): (
|
||||
"append",
|
||||
"create",
|
||||
"exists",
|
||||
"filter",
|
||||
"for",
|
||||
"get",
|
||||
"incr",
|
||||
"info",
|
||||
"keys",
|
||||
"lappend",
|
||||
"map",
|
||||
"merge",
|
||||
"remove",
|
||||
"replace",
|
||||
"set",
|
||||
"size",
|
||||
"unset",
|
||||
"update",
|
||||
"values",
|
||||
"with",
|
||||
),
|
||||
("dict", "filter"): ("key", "script", "value"),
|
||||
("encoding",): ("convertfrom", "convertto", "dirs", "names", "system"),
|
||||
("file",): (
|
||||
"atime",
|
||||
"attributes",
|
||||
"channels",
|
||||
"copy",
|
||||
"delete",
|
||||
"dirname",
|
||||
"executable",
|
||||
"exists",
|
||||
"extension",
|
||||
"isdirectory",
|
||||
"isfile",
|
||||
"join",
|
||||
"link",
|
||||
"lstat",
|
||||
"mkdir",
|
||||
"mtime",
|
||||
"nativename",
|
||||
"normalize",
|
||||
"owned",
|
||||
"pathtype",
|
||||
"readable",
|
||||
"readlink",
|
||||
"rename",
|
||||
"rootname",
|
||||
"separator",
|
||||
"size",
|
||||
"split",
|
||||
"stat",
|
||||
"system",
|
||||
"tail",
|
||||
"tempfile",
|
||||
"type",
|
||||
"volumes",
|
||||
"writable",
|
||||
),
|
||||
("info",): (
|
||||
"args",
|
||||
"body",
|
||||
"class",
|
||||
"cmdcount",
|
||||
"commands",
|
||||
"complete",
|
||||
"coroutine",
|
||||
"default",
|
||||
"errorstack",
|
||||
"exists",
|
||||
"frame",
|
||||
"functions",
|
||||
"globals",
|
||||
"hostname",
|
||||
"level",
|
||||
"library",
|
||||
"loaded",
|
||||
"locals",
|
||||
"nameofexecutable",
|
||||
"object",
|
||||
"patchlevel",
|
||||
"procs",
|
||||
"script",
|
||||
"sharedlibextension",
|
||||
"tclversion",
|
||||
"vars",
|
||||
),
|
||||
("namespace",): (
|
||||
"children",
|
||||
"code",
|
||||
"current",
|
||||
"delete",
|
||||
"ensemble",
|
||||
"eval",
|
||||
"exists",
|
||||
"export",
|
||||
"forget",
|
||||
"import",
|
||||
"inscope",
|
||||
"origin",
|
||||
"parent",
|
||||
"path",
|
||||
"qualifiers",
|
||||
"tail",
|
||||
"unknown",
|
||||
"upvar",
|
||||
"which",
|
||||
),
|
||||
("namespace", "ensemble"): ("configure", "create", "exists"),
|
||||
("package",): (
|
||||
"forget",
|
||||
"ifneeded",
|
||||
"names",
|
||||
"prefer",
|
||||
"present",
|
||||
"provide",
|
||||
"require",
|
||||
"unknown",
|
||||
"vcompare",
|
||||
"versions",
|
||||
"vsatisfies",
|
||||
),
|
||||
("string",): (
|
||||
"bytelength",
|
||||
"cat",
|
||||
"compare",
|
||||
"equal",
|
||||
"first",
|
||||
"index",
|
||||
"is",
|
||||
"last",
|
||||
"length",
|
||||
"map",
|
||||
"match",
|
||||
"range",
|
||||
"repeat",
|
||||
"replace",
|
||||
"reverse",
|
||||
"tolower",
|
||||
"totitle",
|
||||
"toupper",
|
||||
"trim",
|
||||
"trimleft",
|
||||
"trimright",
|
||||
"wordend",
|
||||
"wordstart",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _options(*labels: str) -> tuple[OptionSpec, ...]:
|
||||
return tuple(OptionSpec(label) for label in labels)
|
||||
|
||||
|
||||
OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
|
||||
("binary", "decode", "base64"): (OptionSpec("-strict"),),
|
||||
("binary", "encode", "base64"): (
|
||||
OptionSpec("-maxlen", takes_value=True),
|
||||
OptionSpec("-wrapchar", takes_value=True),
|
||||
),
|
||||
("clock", "format"): (
|
||||
OptionSpec("-format", takes_value=True),
|
||||
OptionSpec("-gmt", takes_value=True, values=("0", "1")),
|
||||
OptionSpec("-locale", takes_value=True),
|
||||
OptionSpec("-timezone", takes_value=True),
|
||||
),
|
||||
("clock", "scan"): (
|
||||
OptionSpec("-base", takes_value=True),
|
||||
OptionSpec("-format", takes_value=True),
|
||||
OptionSpec("-gmt", takes_value=True, values=("0", "1")),
|
||||
OptionSpec("-locale", takes_value=True),
|
||||
OptionSpec("-timezone", takes_value=True),
|
||||
),
|
||||
("exec",): _options("-ignorestderr", "-keepnewline", "--"),
|
||||
("file", "copy"): _options("-force", "--"),
|
||||
("file", "delete"): _options("-force", "--"),
|
||||
("file", "link"): _options("-symbolic", "-hard"),
|
||||
("file", "rename"): _options("-force", "--"),
|
||||
("glob",): (
|
||||
OptionSpec("-directory", takes_value=True),
|
||||
OptionSpec("-join"),
|
||||
OptionSpec("-nocomplain"),
|
||||
OptionSpec("-path", takes_value=True),
|
||||
OptionSpec("-tails"),
|
||||
OptionSpec("-types", takes_value=True),
|
||||
OptionSpec("--"),
|
||||
),
|
||||
("lsearch",): (
|
||||
OptionSpec("-all"),
|
||||
OptionSpec("-ascii"),
|
||||
OptionSpec("-bisect"),
|
||||
OptionSpec("-decreasing"),
|
||||
OptionSpec("-dictionary"),
|
||||
OptionSpec("-exact"),
|
||||
OptionSpec("-glob"),
|
||||
OptionSpec("-increasing"),
|
||||
OptionSpec("-index", takes_value=True),
|
||||
OptionSpec("-inline"),
|
||||
OptionSpec("-integer"),
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-not"),
|
||||
OptionSpec("-real"),
|
||||
OptionSpec("-regexp"),
|
||||
OptionSpec("-sorted"),
|
||||
OptionSpec("-start", takes_value=True),
|
||||
OptionSpec("-subindices"),
|
||||
),
|
||||
("lsort",): (
|
||||
OptionSpec("-ascii"),
|
||||
OptionSpec("-command", takes_value=True),
|
||||
OptionSpec("-decreasing"),
|
||||
OptionSpec("-dictionary"),
|
||||
OptionSpec("-increasing"),
|
||||
OptionSpec("-index", takes_value=True),
|
||||
OptionSpec("-indices"),
|
||||
OptionSpec("-integer"),
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-real"),
|
||||
OptionSpec("-stride", takes_value=True),
|
||||
OptionSpec("-unique"),
|
||||
),
|
||||
("namespace", "which"): _options("-command", "-variable"),
|
||||
("package", "present"): _options("-exact"),
|
||||
("package", "require"): _options("-exact"),
|
||||
("puts",): _options("-nonewline"),
|
||||
("regexp",): (
|
||||
OptionSpec("-about"),
|
||||
OptionSpec("-all"),
|
||||
OptionSpec("-expanded"),
|
||||
OptionSpec("-indices"),
|
||||
OptionSpec("-inline"),
|
||||
OptionSpec("-line"),
|
||||
OptionSpec("-lineanchor"),
|
||||
OptionSpec("-linestop"),
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-start", takes_value=True),
|
||||
OptionSpec("--"),
|
||||
),
|
||||
("regsub",): (
|
||||
OptionSpec("-all"),
|
||||
OptionSpec("-command"),
|
||||
OptionSpec("-expanded"),
|
||||
OptionSpec("-line"),
|
||||
OptionSpec("-lineanchor"),
|
||||
OptionSpec("-linestop"),
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-start", takes_value=True),
|
||||
OptionSpec("--"),
|
||||
),
|
||||
("return",): (
|
||||
OptionSpec("-code", takes_value=True),
|
||||
OptionSpec("-errorcode", takes_value=True),
|
||||
OptionSpec("-errorinfo", takes_value=True),
|
||||
OptionSpec("-errorstack", takes_value=True),
|
||||
OptionSpec("-level", takes_value=True),
|
||||
OptionSpec("-options", takes_value=True),
|
||||
),
|
||||
("source",): (OptionSpec("-encoding", takes_value=True),),
|
||||
("string", "compare"): (
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-length", takes_value=True),
|
||||
),
|
||||
("string", "equal"): (
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-length", takes_value=True),
|
||||
),
|
||||
("string", "map"): _options("-nocase"),
|
||||
("string", "match"): _options("-nocase"),
|
||||
("switch",): (
|
||||
OptionSpec("-exact"),
|
||||
OptionSpec("-glob"),
|
||||
OptionSpec("-indexvar", takes_value=True),
|
||||
OptionSpec("-matchvar", takes_value=True),
|
||||
OptionSpec("-nocase"),
|
||||
OptionSpec("-regexp"),
|
||||
OptionSpec("--"),
|
||||
),
|
||||
}
|
||||
|
||||
# ``string is`` takes its class before its options, so each class is a concrete
|
||||
# command path for the generic option resolver below.
|
||||
for _string_class in STRING_CLASSES:
|
||||
OPTIONS_BY_PATH[("string", "is", _string_class)] = (
|
||||
OptionSpec("-strict"),
|
||||
OptionSpec("-failindex", takes_value=True),
|
||||
)
|
||||
|
||||
|
||||
VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = {
|
||||
(("array", "names"), 3): ("-exact", "-glob", "-regexp"),
|
||||
(("string", "is"), 2): STRING_CLASSES,
|
||||
}
|
||||
|
||||
|
||||
TCL_COMMAND_NAMES = tuple(
|
||||
sorted(
|
||||
{path[0] for path in SUBCOMMANDS_BY_PATH}
|
||||
| {path[0] for path in OPTIONS_BY_PATH}
|
||||
)
|
||||
)
|
||||
|
||||
TCL_COMMAND_ITEMS = tuple(
|
||||
CompletionItem(
|
||||
label=command,
|
||||
kind=CompletionItemKind.Function,
|
||||
detail="Tcl command",
|
||||
insert_text=command,
|
||||
)
|
||||
for command in TCL_COMMAND_NAMES
|
||||
)
|
||||
|
||||
|
||||
def line_prefix_at_position(
|
||||
source_lines: Sequence[str], position: Position
|
||||
) -> str | None:
|
||||
"""Return the current line before an LSP UTF-16 position."""
|
||||
|
||||
if position.line < 0 or position.line >= len(source_lines):
|
||||
return None
|
||||
|
||||
line = source_lines[position.line]
|
||||
codepoint_offset = _codepoint_offset(line, position.character)
|
||||
if codepoint_offset is None:
|
||||
return None
|
||||
return line[:codepoint_offset]
|
||||
|
||||
|
||||
def tcl_argument_completions(
|
||||
source_lines: Sequence[str], position: Position
|
||||
) -> list[CompletionItem] | None:
|
||||
"""Return subcommand, fixed-value, or option completions 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.
|
||||
"""
|
||||
|
||||
line_prefix = line_prefix_at_position(source_lines, position)
|
||||
if line_prefix is None:
|
||||
return None
|
||||
|
||||
segment = _current_command_segment(line_prefix)
|
||||
if not segment.strip() or segment.lstrip().startswith("#"):
|
||||
return None
|
||||
|
||||
words = _tokenize_command_segment(segment)
|
||||
if not words:
|
||||
return None
|
||||
|
||||
words[0] = words[0].removeprefix("::")
|
||||
active_index = len(words) - 1
|
||||
active_prefix = words[active_index]
|
||||
completed_path = tuple(words[:active_index])
|
||||
|
||||
subcommands = SUBCOMMANDS_BY_PATH.get(completed_path)
|
||||
if subcommands is not None:
|
||||
return _completion_items(
|
||||
subcommands,
|
||||
CompletionItemKind.EnumMember,
|
||||
f"{' '.join(completed_path)} subcommand",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
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(
|
||||
path,
|
||||
OPTIONS_BY_PATH[path],
|
||||
words[len(path) : active_index],
|
||||
active_prefix,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _option_completions(
|
||||
path: tuple[str, ...],
|
||||
options: tuple[OptionSpec, ...],
|
||||
completed_arguments: Sequence[str],
|
||||
active_prefix: str,
|
||||
) -> list[CompletionItem] | None:
|
||||
option_by_label = {option.label: option for option in options}
|
||||
used_options: set[str] = set()
|
||||
argument_index = 0
|
||||
|
||||
while argument_index < len(completed_arguments):
|
||||
argument = completed_arguments[argument_index]
|
||||
if argument == "--":
|
||||
return None
|
||||
|
||||
option = option_by_label.get(argument)
|
||||
if option is None:
|
||||
# Tcl options precede normal operands for the commands covered here.
|
||||
return None
|
||||
|
||||
used_options.add(option.label)
|
||||
argument_index += 1
|
||||
if not option.takes_value:
|
||||
continue
|
||||
|
||||
if argument_index >= len(completed_arguments):
|
||||
if option.values:
|
||||
return _completion_items(
|
||||
option.values,
|
||||
CompletionItemKind.Value,
|
||||
f"{option.label} value",
|
||||
)
|
||||
return None
|
||||
argument_index += 1
|
||||
|
||||
if active_prefix and not active_prefix.startswith("-"):
|
||||
return None
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
def _completion_items(
|
||||
labels: Sequence[str], kind: CompletionItemKind, detail: str
|
||||
) -> list[CompletionItem]:
|
||||
return [
|
||||
CompletionItem(
|
||||
label=label,
|
||||
kind=kind,
|
||||
detail=detail,
|
||||
insert_text=label,
|
||||
)
|
||||
for label in labels
|
||||
]
|
||||
|
||||
|
||||
def _current_command_segment(line_prefix: str) -> str:
|
||||
"""Select the innermost unfinished command from a line prefix."""
|
||||
|
||||
brace_starts: list[int] = []
|
||||
bracket_starts: list[int] = []
|
||||
last_command_delimiter = -1
|
||||
in_quote = False
|
||||
escaped = False
|
||||
|
||||
for index, char in enumerate(line_prefix):
|
||||
if escaped:
|
||||
escaped = False
|
||||
continue
|
||||
if char == "\\":
|
||||
escaped = True
|
||||
continue
|
||||
|
||||
if char == '"' and not brace_starts:
|
||||
in_quote = not in_quote
|
||||
continue
|
||||
|
||||
if not in_quote:
|
||||
if char == "{":
|
||||
brace_starts.append(index)
|
||||
continue
|
||||
if char == "}" and brace_starts:
|
||||
brace_starts.pop()
|
||||
continue
|
||||
|
||||
if not brace_starts:
|
||||
if char == "[":
|
||||
bracket_starts.append(index)
|
||||
continue
|
||||
if char == "]" and bracket_starts:
|
||||
bracket_starts.pop()
|
||||
continue
|
||||
if char == ";" and not in_quote:
|
||||
last_command_delimiter = index
|
||||
|
||||
starts = [last_command_delimiter + 1]
|
||||
if brace_starts:
|
||||
starts.append(brace_starts[-1] + 1)
|
||||
if bracket_starts:
|
||||
starts.append(bracket_starts[-1] + 1)
|
||||
return line_prefix[max(starts) :]
|
||||
|
||||
|
||||
def _tokenize_command_segment(segment: str) -> list[str]:
|
||||
words: list[str] = []
|
||||
current: list[str] = []
|
||||
brace_depth = 0
|
||||
in_quote = False
|
||||
escaped = False
|
||||
ended_with_separator = False
|
||||
|
||||
for char in segment:
|
||||
if escaped:
|
||||
current.append(char)
|
||||
escaped = False
|
||||
ended_with_separator = False
|
||||
continue
|
||||
if char == "\\":
|
||||
current.append(char)
|
||||
escaped = True
|
||||
ended_with_separator = False
|
||||
continue
|
||||
if char == '"' and brace_depth == 0:
|
||||
in_quote = not in_quote
|
||||
ended_with_separator = False
|
||||
continue
|
||||
if not in_quote and char == "{":
|
||||
brace_depth += 1
|
||||
ended_with_separator = False
|
||||
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:
|
||||
words.append("".join(current))
|
||||
current = []
|
||||
ended_with_separator = bool(words)
|
||||
continue
|
||||
|
||||
current.append(char)
|
||||
ended_with_separator = False
|
||||
|
||||
if current:
|
||||
words.append("".join(current))
|
||||
elif ended_with_separator:
|
||||
words.append("")
|
||||
return words
|
||||
|
||||
|
||||
def _codepoint_offset(text: str, utf16_offset: int) -> int | None:
|
||||
if utf16_offset < 0:
|
||||
return None
|
||||
|
||||
consumed = 0
|
||||
for index, char in enumerate(text):
|
||||
if consumed == utf16_offset:
|
||||
return index
|
||||
consumed += 2 if ord(char) > 0xFFFF else 1
|
||||
if consumed > utf16_offset:
|
||||
return None
|
||||
|
||||
if consumed == utf16_offset:
|
||||
return len(text)
|
||||
return None
|
||||
Reference in New Issue
Block a user