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