diff --git a/CHANGELOG.md b/CHANGELOG.md index 4550126..5073b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - 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 +- Add semantic argument completion for variables, procedures, namespaces, and local file paths +- Add placeholder-based snippets for common Tcl structures and `dict for` - 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 2279e4f..9ccd5be 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ 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, commands, Tcl subcommands, and valid options based on cursor context +- **Context-aware Completion** - Prioritizes local symbols and suggests variables, procedures, namespaces, paths, Tcl subcommands, valid argument values, and options based on cursor context +- **Tcl Snippets** - Inserts placeholder-based structures for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for` - **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 +102,8 @@ 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, plus Tcl subcommands and options such as `string compare -nocase` +- Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase` +- Placeholder-based snippets for common Tcl control structures and procedures - 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 72a435b..e9b1c32 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -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) diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index 43454f7..27586a6 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -31,6 +31,7 @@ COMMAND_KINDS = { lsp.CompletionItemKind.Method, lsp.CompletionItemKind.Constructor, lsp.CompletionItemKind.Keyword, + lsp.CompletionItemKind.Snippet, } _VARIABLE_PREFIX_RE = re.compile(r"(? 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("") diff --git a/server/src/tools/variable_index.py b/server/src/tools/variable_index.py index 8cc6e8a..564ef22 100644 --- a/server/src/tools/variable_index.py +++ b/server/src/tools/variable_index.py @@ -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, diff --git a/server/tests/python_tests/test_completion_context.py b/server/tests/python_tests/test_completion_context.py index 1a03618..f0c7346 100644 --- a/server/tests/python_tests/test_completion_context.py +++ b/server/tests/python_tests/test_completion_context.py @@ -19,7 +19,11 @@ from tools.completion_items import ( CompletionContext, completion_context, ) -from tools.tcl_command_completion import tcl_argument_completions +from tools.tcl_command_completion import ( + DynamicCompletionKind, + path_completion_items, + tcl_argument_completion, +) def _position_after(source: str, token: str, occurrence: int = 0) -> lsp.Position: @@ -93,15 +97,19 @@ def _complete(document: TextDocument, position: lsp.Position): def _argument_completion_labels(source: str) -> set[str] | None: + completion = _argument_completion_request(source) + if completion is None: + return None + return {item.label for item in completion.items} + + +def _argument_completion_request(source: str): lines = source.split("\n") character = len(lines[-1].encode("utf-16-le")) // 2 - items = tcl_argument_completions( + return tcl_argument_completion( lines, lsp.Position(line=len(lines) - 1, character=character), ) - if items is None: - return None - return {item.label for item in items} def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkeypatch): @@ -308,3 +316,172 @@ def test_space_trigger_does_not_open_broad_fallback_completion( ) assert result.items == [] + + +def test_argument_values_are_suggested_at_the_expected_position(): + assert _argument_completion_labels("return -code ") == { + "break", + "continue", + "error", + "ok", + "return", + } + assert _argument_completion_labels("open output.txt ") == { + "a", + "a+", + "r", + "r+", + "w", + "w+", + } + assert _argument_completion_labels("seek channel 0 ") == { + "current", + "end", + "start", + } + + +def test_dynamic_argument_categories_are_detected(): + variable = _argument_completion_request("set ") + procedure = _argument_completion_request("info body ") + namespace = _argument_completion_request("namespace eval ") + path = _argument_completion_request("source scripts/") + + assert variable is not None + assert variable.dynamic_kind == DynamicCompletionKind.VARIABLE + assert procedure is not None + assert procedure.dynamic_kind == DynamicCompletionKind.PROCEDURE + assert namespace is not None + assert namespace.dynamic_kind == DynamicCompletionKind.NAMESPACE + assert path is not None + assert path.dynamic_kind == DynamicCompletionKind.PATH + assert path.path_extensions == (".tcl",) + + +def test_path_completion_is_relative_filtered_and_tcl_safe(tmp_path: Path): + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "first file.tcl").write_text("puts ok", encoding="utf-8") + (scripts / "ignored.txt").write_text("ignored", encoding="utf-8") + + source = "source scripts/f" + completion = _argument_completion_request(source) + assert completion is not None + items = path_completion_items( + tmp_path, + completion, + _position_after(source, source), + ) + + assert [item.label for item in items] == ["scripts/first\\ file.tcl"] + assert items[0].kind == lsp.CompletionItemKind.File + assert items[0].text_edit is not None + assert items[0].text_edit.new_text == "scripts/first\\ file.tcl" + assert items[0].text_edit.range.start.character == len("source ") + + +def test_lsp_source_completion_reads_paths_from_document_directory( + tmp_path: Path, monkeypatch +): + server, current, _ = _completion_server(tmp_path, monkeypatch) + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "helper.tcl").write_text("proc helper {} {}", encoding="utf-8") + (scripts / "ignored.txt").write_text("ignored", encoding="utf-8") + source = "source scripts/" + current = _document(tmp_path / "current.tcl", source) + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=2, + text=source, + ) + ) + + items = _complete(current, _position_after(source, source)) + labels = {item.label for item in items} + + assert "scripts/helper.tcl" in labels + assert "scripts/ignored.txt" not in labels + + +def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders( + tmp_path: Path, monkeypatch +): + _, current, source = _completion_server(tmp_path, monkeypatch) + command_items = _complete(current, _position_after(source, "localP", occurrence=1)) + command_by_label = {item.label: item for item in command_items} + + assert command_by_label["if"].kind == lsp.CompletionItemKind.Snippet + assert command_by_label["if"].insert_text_format == lsp.InsertTextFormat.Snippet + assert "${1:condition}" in (command_by_label["if"].insert_text or "") + + dict_for = _argument_completion_request("dict f") + assert dict_for is not None + for_item = next(item for item in dict_for.items if item.label == "for") + assert for_item.kind == lsp.CompletionItemKind.Snippet + assert "${3:dictionary}" in (for_item.insert_text or "") + + switch_arguments = _argument_completion_request("switch ") + assert switch_arguments is not None + assert {"switch block", "-exact", "-glob", "-regexp"} <= { + item.label for item in switch_arguments.items + } + + +def test_semantic_variable_and_procedure_argument_completion( + tmp_path: Path, monkeypatch +): + _, current, source = _completion_server(tmp_path, monkeypatch) + + variable_items = _complete(current, _position_after(source, " set ")) + assert variable_items + assert all(item.kind in VARIABLE_KINDS for item in variable_items) + assert {"argument", "localValue"} <= {item.label for item in variable_items} + + procedure_source = source + "info body localProc\n" + current = _document(tmp_path / "current.tcl", procedure_source) + lsp_server.LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=2, + text=procedure_source, + ) + ) + procedure_items = _complete( + current, + _position_after(procedure_source, "info body "), + ) + procedure_labels = {item.label for item in procedure_items} + + assert "localProc" in procedure_labels + assert "workspaceProc" in procedure_labels + assert "MOM_abort" in procedure_labels + assert "localValue" not in procedure_labels + assert "string" not in procedure_labels + + +def test_namespace_argument_completion_uses_navigation_index( + tmp_path: Path, monkeypatch +): + server, current, _ = _completion_server(tmp_path, monkeypatch) + namespace_source = "namespace eval tools { proc helper {} { return } }\n" + namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source) + assert server.update_poco_completion_for_file(namespace_document) + + source = "namespace eval " + current = _document(tmp_path / "current.tcl", source) + server.workspace.put_text_document( + lsp.TextDocumentItem( + uri=current.uri, + language_id="tcl", + version=2, + text=source, + ) + ) + items = _complete(current, _position_after(source, source)) + + assert "tools" in {item.label for item in items} + assert all(item.kind == lsp.CompletionItemKind.Module for item in items)