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:
Christoph Brandau
2026-09-03 10:17:16 +02:00
parent 081b488fe3
commit 20f76b6a20
7 changed files with 862 additions and 32 deletions
+1
View File
@@ -3,6 +3,7 @@
- Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers - Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers
- Add document highlights for procedure and variable occurrences - Add document highlights for procedure and variable occurrences
- Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols - Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols
- Add command-aware completion for Tcl subcommands, fixed arguments, and valid options
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support - Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files - Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops - Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
+2 -2
View File
@@ -12,7 +12,7 @@ A comprehensive VS Code extension providing language support and remote debuggin
- **Signature Help** - Shows parameters and documentation for custom and NX procedures - **Signature Help** - Shows parameters and documentation for custom and NX procedures
- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers - **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers
- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor - **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor
- **Context-aware Completion** - Prioritizes local symbols and suggests variables or commands based on cursor context - **Context-aware Completion** - Prioritizes local symbols and suggests variables, commands, Tcl subcommands, and valid options based on cursor context
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process - **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
## Supported File Types ## Supported File Types
@@ -101,7 +101,7 @@ Simply open any supported file type and enjoy:
- Signature help while entering procedure arguments - Signature help while entering procedure arguments
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers - Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
- Document-wide highlights for procedure and variable occurrences - Document-wide highlights for procedure and variable occurrences
- Context-aware completion with local symbols ranked before workspace and built-in symbols - Context-aware completion with local symbols ranked before workspace and built-in symbols, plus Tcl subcommands and options such as `string compare -nocase`
- Remote NX Tcl debugging with breakpoints and full stepping - Remote NX Tcl debugging with breakpoints and full stepping
## Contributing ## Contributing
+43 -8
View File
@@ -38,13 +38,18 @@ update_sys_path(
# Imports needed for the language server goes below this. # Imports needed for the language server goes below this.
# ********************************************************** # **********************************************************
# pylint: disable=wrong-import-position,import-error # pylint: disable=wrong-import-position,import-error
import lsp_jsonrpc as jsonrpc
import lsprotocol.types as lsp import lsprotocol.types as lsp
from common.load_data import standard_items
from lsp_tclserver import TclLanguageServer
from pygls import uris from pygls import uris
from pygls.workspace.text_document import TextDocument from pygls.workspace.text_document import TextDocument
from tools.completion_items import completion_context, ranked_completion_items
import lsp_jsonrpc as jsonrpc
from common.load_data import standard_items
from lsp_tclserver import TclLanguageServer
from tools.completion_items import (
CompletionContext,
completion_context,
ranked_completion_items,
)
from tools.folding_ranges import build_folding_ranges from tools.folding_ranges import build_folding_ranges
from tools.inlay_hint import ( from tools.inlay_hint import (
InlayHintGenerator, InlayHintGenerator,
@@ -69,6 +74,11 @@ from tools.semantic_tokens import (
_Highlighter, _Highlighter,
) )
from tools.signature_help import build_signature_help from tools.signature_help import build_signature_help
from tools.tcl_command_completion import (
TCL_COMMAND_ITEMS,
TCL_COMMAND_NAMES,
tcl_argument_completions,
)
WORKSPACE_SETTINGS = {} WORKSPACE_SETTINGS = {}
GLOBAL_SETTINGS = {} GLOBAL_SETTINGS = {}
@@ -82,10 +92,12 @@ LSP_SERVER = TclLanguageServer(
BUILTIN_PROC_NAMES = { BUILTIN_PROC_NAMES = {
item.label item.label
for item in standard_items.tcl_keyword_list + standard_items.nx_procs for item in standard_items.tcl_keyword_list + standard_items.nx_procs
} } | set(TCL_COMMAND_NAMES)
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables} BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
_STATIC_TCL_LABELS = {item.label for item in standard_items.tcl_keyword_list}
STATIC_COMPLETION_ITEMS = tuple( STATIC_COMPLETION_ITEMS = tuple(
standard_items.tcl_keyword_list standard_items.tcl_keyword_list
+ [item for item in TCL_COMMAND_ITEMS if item.label not in _STATIC_TCL_LABELS]
+ standard_items.nx_procs + standard_items.nx_procs
+ standard_items.nx_variables + standard_items.nx_variables
) )
@@ -250,16 +262,40 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
@LSP_SERVER.feature( @LSP_SERVER.feature(
lsp.TEXT_DOCUMENT_COMPLETION, lsp.TEXT_DOCUMENT_COMPLETION,
lsp.CompletionOptions(trigger_characters=["$"]), lsp.CompletionOptions(trigger_characters=["$", " ", "-"]),
) )
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
position = params.position
source_lines = LSP_SERVER.get_lines(doc)
context = completion_context(source_lines, position)
# Variable completion wins inside command arguments. Otherwise prefer the
# narrow command grammar when the cursor is at a known subcommand/option.
if context != CompletionContext.VARIABLE:
argument_items = tcl_argument_completions(source_lines, position)
if argument_items is not None:
items = ranked_completion_items(
((0, item) for item in argument_items),
CompletionContext.GENERAL,
)
return lsp.CompletionList(is_incomplete=False, items=items)
# 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
and params.context.trigger_kind
== lsp.CompletionTriggerKind.TriggerCharacter
and params.context.trigger_character in {" ", "-"}
):
return lsp.CompletionList(is_incomplete=False, items=[])
tree = LSP_SERVER.get_tree(doc) tree = LSP_SERVER.get_tree(doc)
globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document( globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
doc, tree doc, tree
) )
position = params.position
local_names: set[str] = set() local_names: set[str] = set()
for proc_range in proc_ranges: for proc_range in proc_ranges:
end_line = proc_range.end_line or proc_range.start_line end_line = proc_range.end_line or proc_range.start_line
@@ -302,7 +338,6 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
candidates.extend((priority, item) for item in items_by_file[item_path]) candidates.extend((priority, item) for item in items_by_file[item_path])
candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS) candidates.extend((300, item) for item in STATIC_COMPLETION_ITEMS)
context = completion_context(LSP_SERVER.get_lines(doc), position)
items = ranked_completion_items(candidates, context) items = ranked_completion_items(candidates, context)
return lsp.CompletionList(is_incomplete=False, items=items) return lsp.CompletionList(is_incomplete=False, items=items)
+5 -18
View File
@@ -6,9 +6,11 @@ from collections.abc import Iterable, Sequence
from enum import Enum from enum import Enum
import lsprotocol.types as lsp import lsprotocol.types as lsp
from common.load_data import standard_items
from tclint.syntax_tree import BareWord, Command, List, Visitor from tclint.syntax_tree import BareWord, Command, List, Visitor
from common.load_data import standard_items
from tools.tcl_command_completion import line_prefix_at_position
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables} BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs} BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs}
@@ -35,27 +37,12 @@ _VARIABLE_PREFIX_RE = re.compile(r"(?<!\\)\$(?:\{)?[A-Za-z0-9_:]*$")
_COMMAND_PREFIX_RE = re.compile(r"(?:^|[;\[\{])\s*[^\s;\[\]\{\}]*$") _COMMAND_PREFIX_RE = re.compile(r"(?:^|[;\[\{])\s*[^\s;\[\]\{\}]*$")
def _codepoint_offset(line: str, utf16_offset: int) -> int:
"""Translate an LSP UTF-16 character offset into a Python string offset."""
if utf16_offset <= 0:
return 0
units = 0
for offset, character in enumerate(line):
units += 2 if ord(character) > 0xFFFF else 1
if units >= utf16_offset:
return offset + 1
return len(line)
def completion_context( def completion_context(
source_lines: Sequence[str], position: lsp.Position source_lines: Sequence[str], position: lsp.Position
) -> CompletionContext: ) -> CompletionContext:
if position.line < 0 or position.line >= len(source_lines): prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return CompletionContext.GENERAL return CompletionContext.GENERAL
line = source_lines[position.line]
prefix = line[: _codepoint_offset(line, position.character)]
if _VARIABLE_PREFIX_RE.search(prefix): if _VARIABLE_PREFIX_RE.search(prefix):
return CompletionContext.VARIABLE return CompletionContext.VARIABLE
if _COMMAND_PREFIX_RE.search(prefix): if _COMMAND_PREFIX_RE.search(prefix):
+650
View File
@@ -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
@@ -6,18 +6,20 @@ SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path: if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR)) sys.path.insert(0, str(SRC_DIR))
import lsp_server
import lsprotocol.types as lsp # type: ignore import lsprotocol.types as lsp # type: ignore
from common.load_data import standard_items
from lsp_tclserver import TclLanguageServer
from pygls.workspace import Workspace from pygls.workspace import Workspace
from pygls.workspace.text_document import TextDocument from pygls.workspace.text_document import TextDocument
import lsp_server
from common.load_data import standard_items
from lsp_tclserver import TclLanguageServer
from tools.completion_items import ( from tools.completion_items import (
COMMAND_KINDS, COMMAND_KINDS,
VARIABLE_KINDS, VARIABLE_KINDS,
CompletionContext, CompletionContext,
completion_context, completion_context,
) )
from tools.tcl_command_completion import tcl_argument_completions
def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position: def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position:
@@ -90,6 +92,18 @@ def _complete(document: TextDocument, position: lsp.Position):
).items ).items
def _argument_completion_labels(source: str) -> set[str] | None:
lines = source.split("\n")
character = len(lines[-1].encode("utf-16-le")) // 2
items = tcl_argument_completions(
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): def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
items = _complete(current, _position_after(source, "$local")) items = _complete(current, _position_after(source, "$local"))
@@ -150,3 +164,147 @@ def test_completion_context_handles_nested_commands_and_utf16():
completion_context(["puts value"], lsp.Position(line=0, character=10)) completion_context(["puts value"], lsp.Position(line=0, character=10))
== CompletionContext.GENERAL == CompletionContext.GENERAL
) )
def test_string_subcommands_and_compare_options_are_context_aware():
subcommands = _argument_completion_labels("string ")
assert subcommands is not None
assert {"compare", "equal", "is", "map", "match"} <= subcommands
assert _argument_completion_labels("string compare ") == {
"-length",
"-nocase",
}
assert _argument_completion_labels("string compare -nocase ") == {
"-length"
}
assert _argument_completion_labels("string compare -length ") is None
def test_string_completion_handles_nested_commands_and_is_values():
assert _argument_completion_labels("set result [string compare -") == {
"-length",
"-nocase",
}
classes = _argument_completion_labels("string is ")
assert classes is not None
assert {"boolean", "double", "integer", "wideinteger"} <= classes
assert _argument_completion_labels("string is integer ") == {
"-failindex",
"-strict",
}
def test_dict_array_namespace_file_and_info_subcommands():
dict_items = _argument_completion_labels("dict ")
assert dict_items is not None
assert {"create", "filter", "get", "set", "with"} <= dict_items
assert _argument_completion_labels("dict filter ") == {
"key",
"script",
"value",
}
assert _argument_completion_labels("array names values ") == {
"-exact",
"-glob",
"-regexp",
}
namespace_items = _argument_completion_labels("namespace ")
assert namespace_items is not None
assert {"children", "ensemble", "eval", "which"} <= namespace_items
assert _argument_completion_labels("namespace which ") == {
"-command",
"-variable",
}
assert _argument_completion_labels("namespace ensemble ") == {
"configure",
"create",
"exists",
}
file_items = _argument_completion_labels("file ")
assert file_items is not None
assert {"copy", "delete", "exists", "normalize", "rename"} <= file_items
assert _argument_completion_labels("file copy ") == {"--", "-force"}
info_items = _argument_completion_labels("info ")
assert info_items is not None
assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items
def test_variable_context_still_takes_priority_inside_tcl_command(
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch)
command_source = source.replace(
" puts $local\n",
" puts $local\n string compare $localValue other\n",
)
current = _document(tmp_path / "current.tcl", command_source)
lsp_server.LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(
uri=current.uri,
language_id="tcl",
version=2,
text=command_source,
)
)
items = _complete(current, _position_after(command_source, "$localValue"))
assert items
assert all(item.kind in VARIABLE_KINDS for item in items)
assert "localValue" in {item.label for item in items}
def test_lsp_completion_returns_only_matching_command_options(
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch)
source = "string compare "
current = _document(tmp_path / "current.tcl", source)
lsp_server.LSP_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 {item.label for item in items} == {"-length", "-nocase"}
assert all(item.kind == lsp.CompletionItemKind.Keyword for item in items)
assert all(item.sort_text.startswith("000:") for item in items)
def test_space_trigger_does_not_open_broad_fallback_completion(
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch)
source = "set value "
current = _document(tmp_path / "current.tcl", source)
lsp_server.LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(
uri=current.uri,
language_id="tcl",
version=2,
text=source,
)
)
result = lsp_server.on_completion(
lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=current.uri),
position=_position_after(source, source),
context=lsp.CompletionContext(
trigger_kind=lsp.CompletionTriggerKind.TriggerCharacter,
trigger_character=" ",
),
)
)
assert result.items == []
-1
View File
@@ -117,7 +117,6 @@ proc SERVICE_get_tool_data {} {
} }
} }
LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG { LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG {
MOM_do_template "end_of_program_rewind" MOM_do_template "end_of_program_rewind"
} EndOfProgramRewind } EndOfProgramRewind