from enum import Enum from tclint.syntax_tree import Visitor, BareWord, Command, VarSub, QuotedWord, BracedWord from tclint.violations import Violation, Rule class Rules(Enum): VALIDATION = "validation" OPTIONAL_ARG_POSITION = "optinal_args" UNDECLARED_VARIABLE = "undeclared-variable" def __str__(self): return self.value class CommandArgsCheck(Visitor): def __init__(self): self._violations = [] def check(self, _, tree): self._violations.clear() tree.accept(self, recurse=True) return self._violations def visit_command(self, command: Command): if not hasattr(command.routine, "contents") or command.routine.contents != "proc": return if len(command.args) < 2: return args_node = command.args[1] if not hasattr(args_node, "children"): return found_optional = False for arg in args_node.children: # Required argument if isinstance(arg, BareWord): if found_optional: self._violations.append( Violation( Rules.OPTIONAL_ARG_POSITION, "Required argument follows optional one", arg.pos, arg.end_pos, ) ) # Optional argument elif hasattr(arg, "children") and len(arg.children) >= 2: found_optional = True class UndeclaredVariableCheck(Visitor): """ Warn when inside a proc a variable is used ($var) that is neither: - declared global in the proc via `global var`, nor - referenced as namespaced global (e.g. $::var), nor - assigned locally in the proc via `set var ...`, nor - a proc argument. """ def __init__(self): self._violations = [] def check(self, source, tree): self._violations.clear() # Traverse to find procs; inner scanning is handled per-proc tree.accept(self, recurse=True) return self._violations def visit_command(self, command: Command): # Only interested in procs if not hasattr(command.routine, "contents"): return if command.routine.contents != "proc": return # Expect: proc if len(command.args) < 3: return proc_name_node = command.args[0] args_node = command.args[1] body_node = command.args[2] # Collect proc arg names arg_names = set() if hasattr(args_node, "children"): for child in args_node.children: if isinstance(child, BareWord): if child.contents: arg_names.add(child.contents) elif hasattr(child, "children") and child.children: # Defaulted arg: first element should be the arg name first = child.children[0] if hasattr(first, "contents") and first.contents: arg_names.add(first.contents) # Two-pass scan over body: first collect locals (set) and declared globals locals_set = set(arg_names) declared_globals = set() def _word_text(node) -> str | None: if isinstance(node, BareWord) or isinstance(node, QuotedWord) or isinstance(node, BracedWord): return node.contents return getattr(node, "contents", None) def _collect(node): # Depth-first walk to collect set/global declarations if isinstance(node, Command) and hasattr(node.routine, "contents"): name = node.routine.contents if name == "global": for an in node.args: text = _word_text(an) if text: declared_globals.add(text) elif name == "set" and node.args: # set var [value] - first arg is the variable name first_arg = node.args[0] text = _word_text(first_arg) if text: base = text.split("(", 1)[0] # Handle array syntax if not base.startswith("::"): locals_set.add(base) elif name == "regsub" and node.args: # regsub ?switches? exp string subSpec ?varName? # If a varName is provided as the last arg, treat it as a local last_arg = node.args[-1] text = _word_text(last_arg) if text: base = text.split("(", 1)[0] if base and not base.startswith("::"): locals_set.add(base) elif name == "foreach" and len(node.args) >= 3: # foreach varlist1 list1 ?varlist2 list2 ...? body # Treat loop variables as locals within the proc # Iterate pairs (varlist, list) over all but the last arg (body) pair_args = node.args[:-1] i = 0 while i + 1 < len(pair_args): varnode = pair_args[i] text = _word_text(varnode) or "" # Split var list by whitespace if braced list, else single name names = text.split() for nm in names: base = nm.split("(", 1)[0] if base and not base.startswith("::"): locals_set.add(base) i += 2 # Recurse for ch in getattr(node, "children", []): _collect(ch) _collect(body_node) # Second pass: flag VarSub usages that are not accounted for def _scan(node): # 1) Variable substitutions like $var if isinstance(node, VarSub): var_name = node.value or "" if var_name.startswith("::"): return base = var_name if base not in locals_set and base not in declared_globals: msg = f"Variable '{base}' used in proc is not set locally and not declared global; use 'global {base}' or '$::{base}'" self._violations.append(Violation(Rules.UNDECLARED_VARIABLE, msg, node.pos, node.end_pos)) # 2) Commands that take a variable name as first argument, e.g., 'incr var [amount]' elif isinstance(node, Command) and hasattr(node.routine, "contents"): if node.routine.contents == "incr" and node.args: first_arg = node.args[0] text = _word_text(first_arg) or "" base = text.split("(", 1)[0] if base and not base.startswith("::"): if base not in locals_set and base not in declared_globals: msg = f"Variable '{base}' used in proc is not set locally and not declared global; use 'global {base}'" self._violations.append(Violation(Rules.UNDECLARED_VARIABLE, msg, first_arg.pos, first_arg.end_pos)) for ch in getattr(node, "children", []): _scan(ch) _scan(body_node) def get_checkers(): checkers = ( CommandArgsCheck(), UndeclaredVariableCheck(), ) return checkers