Files
2026-06-18 17:21:58 +02:00

392 lines
14 KiB
Python

"""Helpers for checking command arguments."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Optional
from tclint.syntax_tree import ArgExpansion, BareWord, BracedWord, Node, QuotedWord
# This lets us use Parser in type annotations without introducing a cyclic dependency.
if TYPE_CHECKING:
from tclint.parser import Parser
class CommandArgError(Exception):
"""Exception raised by command handlers to indicate invalid arguments."""
pass
def arg_count(args: list[Node], parser: Parser) -> tuple[int, bool]:
"""Returns the number of arguments in args, taking {*} into account.
If an argument list contains an argument expansion operator that cannot be
statically expanded, the second return value is True, and the count is the minimum
possible number of arguments. Otherwise, the return value is False and the count
reflects the exact number of arguments.
This function should always be used for validating argument count, rather than
relying on `len(args)`.
"""
# TODO: Replace this with or add a similar `expand_args` function that returns an
# expanded argument list. One tricky thing is we want to handle cases like:
# - foreach {*}$iters { ...body... }
# - catch {puts "my script"} {*}$catchopts
# With something like a list structure we can either forward or reverse index to
# still parse the body even with an unexpanded {*}.
# TODO: Add a violation that flags `{*}{a b c}` for rewriting as `a b c`. A future
# version of tclfmt that allows rewrites that break the syntax tree could do this
# automatically.
arg_count = 0
has_arg_expansion = False
for arg in args:
if isinstance(arg, ArgExpansion):
if arg.contents is None:
has_arg_expansion = True
continue
arg_count += len(parser.parse_list(arg).children)
else:
arg_count += 1
return arg_count, has_arg_expansion
def check_count(command, min=None, max=None):
def check(args, parser):
if min is None and max is None:
return None
count, has_arg_expansion = arg_count(args, parser)
if not has_arg_expansion and min == max and count != min:
raise CommandArgError(
f"wrong # of args for {command}: got {count}, expected {min}"
)
if not has_arg_expansion and min is not None and count < min:
raise CommandArgError(
f"not enough args for {command}: got {count}, expected at least {min}"
)
if max is not None and count > max:
raise CommandArgError(
f"too many args for {command}: got {count}, expected no more than {max}"
)
return None
return check
def eval(args: list[Node], parser: Parser, command: str) -> list[Node]:
if len(args) > 1 and any(isinstance(arg, (QuotedWord, BracedWord)) for arg in args):
# Slightly odd restriction, but our syntax tree doesn't have a great way
# to handle this case. We require each command argument to correspond to
# one child node, but multiple quoted or braced word arguments can be
# combined into a single subcommand when interpreted eval-style. This
# requirement exists to facilitate style checking, if we had a separate
# CST for style checks and AST for logical checks we may be able to
# handle it.
raise CommandArgError(
f"unable to parse multiple {command} arguments when one includes a braced"
" or quoted word"
)
# Construct the body of the eval taking whitespace into account to ensure we get
# style checking.
eval_script = ""
prev_arg_end_pos = None
for arg in args:
contents = arg.contents
if contents is None:
# TODO: flag sort of eval-specific violation? Common patterns will
# often trigger this, and it seems useful to be able to turn it off
raise CommandArgError(
f"{command} received an argument with a substitution, unable to parse"
" its arguments"
)
if prev_arg_end_pos is not None:
if prev_arg_end_pos[0] != arg.line:
# If we have multiple args on the same line, we know there must be a
# backslash newline. Add it so the parsing works.
eval_script += "\\\n" * (arg.line - prev_arg_end_pos[0])
eval_script += " " * (arg.col - 1)
else:
eval_script += " " * (arg.col - prev_arg_end_pos[1])
eval_script += contents
prev_arg_end_pos = arg.end_pos
script = parser.parse(eval_script, pos=(args[0].contents_pos))
script.end_pos = args[-1].end_pos
return [script]
def check_command(
command: str,
args: list[Node],
parser: Parser,
command_spec: Callable | dict | None,
) -> Optional[list[Node]]:
if command_spec is None:
return None
if isinstance(command_spec, dict):
return check_arg_spec(command, args, parser, command_spec)
return command_spec(args, parser)
def _positional_has_type(type: str, arg_spec: dict, indices: list[int]) -> bool:
return any([arg_spec["positionals"][i]["value"]["type"] == type for i in indices])
def check_arg_spec(
command: str, args: list[Node], parser: Parser, arg_spec: dict
) -> Optional[list[Node]]:
if "subcommands" in arg_spec:
return dispatch_subcommands(command, args, parser, arg_spec["subcommands"])
switches = arg_spec["switches"]
mapped, positional_args = map_switches(args, switches, command)
args_required = {switch for switch in switches if switches[switch]["required"]}
missing_required = args_required.difference(mapped)
if len(missing_required) > 1:
raise CommandArgError(
f"missing required arguments for {command}: {', '.join(missing_required)}"
)
elif len(missing_required) == 1:
raise CommandArgError(
f"missing required argument for {command}: {missing_required.pop()}"
)
positionals = [args[i] for i in positional_args]
mapping = map_positionals(positionals, arg_spec["positionals"], command)
args = list(args)
for arg_i, map_to_spec in zip(positional_args, mapping):
if _positional_has_type("script", arg_spec, map_to_spec):
args[arg_i] = parser.parse_script(args[arg_i])
elif _positional_has_type("expression", arg_spec, map_to_spec):
args[arg_i] = parser.parse_expression(args[arg_i])
return args
def dispatch_subcommands(
command: str, args: list[Node], parser: Parser, spec: dict
) -> Optional[list[Node]]:
try:
subcommand = args[0].contents
except IndexError:
subcommand = None
if subcommand in spec:
new_args = check_command(
f"{command} {subcommand}", args[1:], parser, spec[subcommand]
)
if new_args is None:
return new_args
return args[0:1] + new_args
if "" in spec:
return check_command(command, args, parser, spec[""])
if subcommand is not None:
msg = f"invalid subcommand for {command}: got {subcommand}"
else:
msg = f"no subcommand provided for {command}"
raise CommandArgError(f"{msg}, expected one of {', '.join(spec.keys())}")
def map_switches(
args: list[Node], switches: dict, command_name: str
) -> tuple[set[str], list[int]]:
"""Separates switch arguments from positional arguments in a command's argument
list.
`switches` represents the "switches" entry of the spec for the given command. The
return value is a tuple of (mapped_switches, positional_indices). mapped_switches is
a set of switch names that were found in args. positional_indices is a list of
indices into args for arguments that are not switches.
If the switches found do not map correctly to the spec, this function raises
CommandArgError.
The `command_name` argument is used to generate descriptive error messages.
"""
mapped: set[str] = set()
if not switches:
return mapped, list(range(len(args)))
positional_args = []
arg_i = 0
while arg_i < len(args):
arg = args[arg_i]
arg_i += 1
# To facilitate better error messages, we expect that switches are always
# specified as BareWords that start with "-" or ">". This lets us throw an
# error when a switch-like thing doesn't match any supported arguments,
# rather than counting it towards the positional arguments (which usually
# ends up in a vague "too many arguments" error). To make tclint interpret a
# switch-like word as a positional argument, users should wrap it in "", and
# any switches should be BareWords.
contents = arg.contents
if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}):
positional_args.append(arg_i - 1)
continue
if contents in switches:
if contents in mapped and not switches[contents]["repeated"]:
raise CommandArgError(
f"duplicate argument for {command_name}: {contents}"
)
if switches[contents]["value"]:
arg_i += 1
if arg_i > len(args):
raise CommandArgError(
f"invalid arguments for {command_name}: expected value after"
f" {contents}"
)
mapped.add(contents)
continue
prefix_matches = []
for switch in switches:
if switch.startswith(contents):
prefix_matches.append(switch)
if len(prefix_matches) == 1:
raise CommandArgError(
f"shortened argument for {command_name}: expand {contents} to"
f" {prefix_matches[0]}"
)
if len(prefix_matches) > 1:
raise CommandArgError(
f"ambiguous argument for {command_name}: {contents} could be any of"
f" {', '.join(prefix_matches)}"
)
raise CommandArgError(f"unrecognized argument for {command_name}: {contents}")
return mapped, positional_args
def map_positionals(
args: list[Node], spec: list[dict], command_name: str
) -> list[list[int]]:
"""Maps a list of nodes representing positional command arguments to the specific
positional arguments of a command. spec represents the "positionals" entry of the
spec for the given command.
The return value is a list whose entries correspond one-to-one to the entries in
`args`. Each item in the return value is a list of indices into `spec`, indicating
which argument(s) in the spec the corresponding argument maps to.
A given index into `spec` may appear multiple times in the list (e.g. if it's a
variadic argument), and a list may contain more than one index for the mapping of an
arg expansion.
If the arguments do not map correctly to the spec, this function raises
CommandArgError.
Given a set of args and a spec, there may be multiple possible mappings. This
function will return some mapping if one exists.
The `command_name` argument is used to generate descriptive error messages.
"""
if len(args) == len(spec):
# Self explanatory: a 1:1 match in argument count should be a legal mapping.
return [[i] for i in range(len(args))]
mapping: list[list[int]] = []
i = 0
if len(args) > len(spec):
# If there are more arguments than specified positionals, we map every argument
# greedily and assign the extra # of arguments to the first variadic we find.
extra = len(args) - len(spec)
for arg in args:
if i >= len(spec):
# We never found a variadic to save us, raise an error.
raise CommandArgError(
f"too many arguments for {command_name}: got {len(args)}, expected"
f" no more than {len(spec)}"
)
mapping.append([i])
if spec[i]["value"]["type"] == "variadic" and extra > 0:
extra -= 1
else:
i += 1
return mapping
required = []
for argspec in spec:
if argspec["required"]:
required.append(argspec["name"])
num_required = len(required)
if len(args) < num_required:
# If there are fewer arguments than required positionals, we map only required
# arguments and expand the first arg expansion we find to account for what's
# missing.
missing = num_required - len(args)
for arg in args:
while not spec[i]["required"]:
i += 1
mapping.append([i])
i += 1
if isinstance(arg, ArgExpansion):
# Map missing arguments.
while missing > 0:
if spec[i]["required"]:
mapping[-1] += [i]
missing -= 1
i += 1
if missing > 0:
missing_names = ", ".join(required[-missing:])
raise CommandArgError(
f"missing required argument{'s' if missing > 1 else ''} for"
f" {command_name}: {missing_names}"
)
return mapping
optionals = len(args) - num_required
for arg in args:
# If our argument count falls somewhere in between the required and total
# specified numbers of positionals, we map all required arguments and map as
# many optionals as needed (as we find them).
if not spec[i]["required"] and optionals > 0:
mapping.append([i])
i += 1
optionals -= 1
continue
while not spec[i]["required"]:
i += 1
mapping.append([i])
i += 1
return mapping