diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f354d7..4550126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers - Add document highlights for procedure and variable occurrences - 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 - 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 diff --git a/README.md b/README.md index 16c2d78..2279e4f 100644 --- a/README.md +++ b/README.md @@ -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 - **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 -- **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 ## Supported File Types @@ -101,7 +101,7 @@ Simply open any supported file type and enjoy: - Signature help while entering procedure arguments - Incoming and outgoing call hierarchy for custom procedures and MOM event handlers - 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 ## Contributing diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 5693410..72a435b 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -38,13 +38,18 @@ update_sys_path( # Imports needed for the language server goes below this. # ********************************************************** # pylint: disable=wrong-import-position,import-error -import lsp_jsonrpc as jsonrpc import lsprotocol.types as lsp -from common.load_data import standard_items -from lsp_tclserver import TclLanguageServer from pygls import uris 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.inlay_hint import ( InlayHintGenerator, @@ -69,6 +74,11 @@ from tools.semantic_tokens import ( _Highlighter, ) 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 = {} GLOBAL_SETTINGS = {} @@ -82,10 +92,12 @@ LSP_SERVER = TclLanguageServer( BUILTIN_PROC_NAMES = { item.label 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} +_STATIC_TCL_LABELS = {item.label for item in standard_items.tcl_keyword_list} STATIC_COMPLETION_ITEMS = tuple( 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_variables ) @@ -250,16 +262,40 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): @LSP_SERVER.feature( lsp.TEXT_DOCUMENT_COMPLETION, - lsp.CompletionOptions(trigger_characters=["$"]), + lsp.CompletionOptions(trigger_characters=["$", " ", "-"]), ) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: 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) globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document( doc, tree ) - position = params.position local_names: set[str] = set() for proc_range in proc_ranges: 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((300, item) for item in STATIC_COMPLETION_ITEMS) - context = completion_context(LSP_SERVER.get_lines(doc), position) items = ranked_completion_items(candidates, context) return lsp.CompletionList(is_incomplete=False, items=items) diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index 3d5fd96..43454f7 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -6,9 +6,11 @@ from collections.abc import Iterable, Sequence from enum import Enum import lsprotocol.types as lsp -from common.load_data import standard_items 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_PROC_LABELS = {ci.label for ci in standard_items.nx_procs} @@ -35,27 +37,12 @@ _VARIABLE_PREFIX_RE = re.compile(r"(? 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( source_lines: Sequence[str], position: lsp.Position ) -> 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 - - line = source_lines[position.line] - prefix = line[: _codepoint_offset(line, position.character)] if _VARIABLE_PREFIX_RE.search(prefix): return CompletionContext.VARIABLE if _COMMAND_PREFIX_RE.search(prefix): diff --git a/server/src/tools/tcl_command_completion.py b/server/src/tools/tcl_command_completion.py new file mode 100644 index 0000000..8744102 --- /dev/null +++ b/server/src/tools/tcl_command_completion.py @@ -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 diff --git a/server/tests/python_tests/test_completion_context.py b/server/tests/python_tests/test_completion_context.py index 3148ae7..1a03618 100644 --- a/server/tests/python_tests/test_completion_context.py +++ b/server/tests/python_tests/test_completion_context.py @@ -6,18 +6,20 @@ SRC_DIR = THIS_DIR.parent.parent / "src" if str(SRC_DIR) not in sys.path: sys.path.insert(0, str(SRC_DIR)) -import lsp_server 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.text_document import TextDocument + +import lsp_server +from common.load_data import standard_items +from lsp_tclserver import TclLanguageServer from tools.completion_items import ( COMMAND_KINDS, VARIABLE_KINDS, CompletionContext, completion_context, ) +from tools.tcl_command_completion import tcl_argument_completions def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position: @@ -90,6 +92,18 @@ def _complete(document: TextDocument, position: lsp.Position): ).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): _, current, source = _completion_server(tmp_path, monkeypatch) 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)) == 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 == [] diff --git a/test/test.tcl b/test/test.tcl index f4f8826..eb9f333 100644 --- a/test/test.tcl +++ b/test/test.tcl @@ -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 { MOM_do_template "end_of_program_rewind" } EndOfProgramRewind