diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index a73491f..42b939f 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -125,10 +125,46 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: - _ = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + from tools.variable_index import build_variable_index + from tools.completion_items import BUILTIN_VAR_LABELS + + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + + # Base items poco = [item for items in LSP_SERVER.poco_completion.values() for item in items] - items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco - return lsp.CompletionList(is_incomplete=False, items=items) + base_items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco + + # Build variable index from current document + globals_set, procs_locals, proc_ranges = build_variable_index(doc.source) + + # Always include globals (excluding built-ins) + dynamic_items = [] + for name in sorted(globals_set): + if name not in BUILTIN_VAR_LABELS: + dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable)) + + # Include proc-local variables when cursor is inside that proc + pos = params.position + if pos is not None: + for pr in proc_ranges: + if pr.start_line <= pos.line <= (pr.end_line or pr.start_line): + for name in sorted(procs_locals.get(pr.name, set())): + # Exclude built-ins and globals to avoid duplication + if name not in BUILTIN_VAR_LABELS and name not in globals_set: + dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable)) + break + + # Merge with de-duplication for variables only + merged: list[lsp.CompletionItem] = [] + seen_var_labels: set[str] = set() + for it in base_items + dynamic_items: + if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable: + if it.label in seen_var_labels: + continue + seen_var_labels.add(it.label) + merged.append(it) + + return lsp.CompletionList(is_incomplete=False, items=merged) # @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index f5b42fe..3cade14 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -2,6 +2,8 @@ from tclint.syntax_tree import Visitor, Command, BareWord, List import lsprotocol.types as lsp from common.load_data import standard_items +BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables} + class CompletionItems: def __init__(self): @@ -34,23 +36,25 @@ class _Completion(Visitor): self._custom_functions = [] self._proc_signatures = {} + def _append_unique(self, item: lsp.CompletionItem): + # Avoid duplicate labels within the same file scan + if not any(ci.label == item.label for ci in self._custom_functions): + self._custom_functions.append(item) + def visit_command(self, command: Command): routine = command.routine + # Collect custom proc names and their signatures if routine.contents == "proc" and command.args: first_arg = command.args[0] - if not first_arg.value: + if not getattr(first_arg, "value", None): return if any(item.label == first_arg.value for item in standard_items.nx_procs): return - try: - self._custom_functions.remove(first_arg.value) - except ValueError: - pass - - self._custom_functions.append(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function)) + # Record proc name as a completion item + self._append_unique(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function)) if len(command.args) < 2: return @@ -69,6 +73,24 @@ class _Completion(Visitor): self._proc_signatures[first_arg.value] = param_names + # Collect global variables declared with: global var1 var2 ... + elif routine.contents == "global" and command.args: + for arg in command.args: + if isinstance(arg, BareWord) and getattr(arg, "value", None): + if arg.value not in BUILTIN_VAR_LABELS: + self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable)) + + # Collect variables set with explicit global namespace: set ::var_name ... + elif routine.contents == "set" and command.args: + first = command.args[0] + if isinstance(first, BareWord) and getattr(first, "value", None): + var_name = first.value + if var_name.startswith("::"): + base_name = var_name.split("(", 1)[0] + clean_name = base_name[2:] # remove leading '::' for completion display + if clean_name not in BUILTIN_VAR_LABELS: + self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable)) + def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None: """ diff --git a/server/src/tools/variable_index.py b/server/src/tools/variable_index.py new file mode 100644 index 0000000..b04514c --- /dev/null +++ b/server/src/tools/variable_index.py @@ -0,0 +1,100 @@ +import re +from dataclasses import dataclass +from typing import Dict, Set, List, Tuple + +# Reuse patterns similar to document_symbols +NS_RE = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)") +PROC_RE = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{") +SET_RE = re.compile(r"^\s*set\s+([^\s\}]+)") + + +@dataclass +class ProcRange: + name: str + start_line: int + end_line: int | None = None + + +def build_variable_index(source: str) -> tuple[Set[str], Dict[str, Set[str]], List[ProcRange]]: + """ + Parse Tcl source text and build: + - globals: set of variable names considered global suggestions + - procs: mapping proc_name -> set of local variable names (set without :: inside that proc) + - proc_ranges: list of ProcRange (name, start_line, end_line) + + Rules: + - set ::var -> global var suggestion (strip leading :: and any array index "(") + - set var without :: at top level (not in namespace/proc) -> global suggestion + - set var without :: inside proc -> local to that proc + - set var inside namespace (no ::) is ignored for global suggestions + """ + lines = source.split("\n") + + class Scope: + def __init__(self, name: str, kind: str, start_line: int): + self.name = name + self.kind = kind # "namespace" or "proc" or "root" + self.start_line = start_line + self.brace_count = 0 + + globals_set: Set[str] = set() + procs: Dict[str, Set[str]] = {} + proc_ranges: List[ProcRange] = [] + + scope_stack: List[Scope] = [Scope("", "root", 0)] + + for i, line in enumerate(lines): + ns_match = NS_RE.match(line) + proc_match = PROC_RE.match(line) + set_match = SET_RE.match(line) + + # Namespace scope + if ns_match: + scope_stack.append(Scope(ns_match.group(1), "namespace", i)) + + # Proc scope + elif proc_match: + pname = proc_match.group(1) + scope_stack.append(Scope(pname, "proc", i)) + proc_ranges.append(ProcRange(name=pname, start_line=i, end_line=None)) + + # Track set statements + if set_match: + raw_name = set_match.group(1) + # Normalize array names and leading :: + base = raw_name.split("(", 1)[0] + if base.startswith("::"): + clean = base[2:] + globals_set.add(clean) + else: + top = scope_stack[-1] + if top.kind == "root": + globals_set.add(base) + elif top.kind == "proc": + procs.setdefault(top.name, set()).add(base) + else: + # inside namespace without :: -> ignore for globals + pass + + # Brace balancing for current top scope + open_count = line.count("{") + close_count = line.count("}") + scope_stack[-1].brace_count += open_count - close_count + + # Close finished scopes + while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0: + finished = scope_stack.pop() + if finished.kind == "proc": + # Update the last matching proc range end_line + for pr in reversed(proc_ranges): + if pr.name == finished.name and pr.end_line is None: + pr.end_line = i + break + + # Finalize any unterminated proc ranges + for pr in proc_ranges: + if pr.end_line is None: + pr.end_line = len(lines) - 1 + + return globals_set, procs, proc_ranges +