add updates libs

This commit is contained in:
Christoph Brandau
2026-06-18 17:21:58 +02:00
parent 91ce762570
commit 41f331d4c4
150 changed files with 8249 additions and 2550 deletions
+256 -105
View File
@@ -1,25 +1,45 @@
"""Helpers for checking command arguments."""
from collections.abc import Callable
from typing import List, Optional, Union
from __future__ import annotations
from tclint.syntax_tree import ArgExpansion, QuotedWord, BracedWord, BareWord, Node
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, parser):
# TODO: graceful handling of argsub going into things with recursive parsing.
# if the argsub happens to be "concrete", we can technically do the right
# thing (although this should probably be flagged as a readability issue...)
# otherwise, we should flag that the non-concrete argsub is not okay for
# these cases. however, I think its not okay-ness doesn't need to be absolute, e.g.
# I think we could allow:
#
# catch {puts "my script"} {*}$catchopts
#
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
@@ -28,14 +48,14 @@ def arg_count(args, parser):
if arg.contents is None:
has_arg_expansion = True
continue
arg_count += len(parser.parse_list(arg.contents))
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, args_name="args"):
def check_count(command, min=None, max=None):
def check(args, parser):
if min is None and max is None:
return None
@@ -44,19 +64,17 @@ def check_count(command, min=None, max=None, args_name="args"):
if not has_arg_expansion and min == max and count != min:
raise CommandArgError(
f"wrong # of {args_name} for {command}: got {count}, expected {min}"
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_name} for {command}: got {count}, expected at least"
f" {min}"
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_name} for {command}: got {count}, expected no more"
f" than {max}"
f"too many args for {command}: got {count}, expected no more than {max}"
)
return None
@@ -64,7 +82,7 @@ def check_count(command, min=None, max=None, args_name="args"):
return check
def eval(args, parser, command):
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
@@ -106,15 +124,18 @@ def eval(args, parser, command):
prev_arg_end_pos = arg.end_pos
script = parser.parse(eval_script, pos=(args[0].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, command_spec: Union[Callable, dict, None]
) -> Optional[List[Node]]:
command: str,
args: list[Node],
parser: Parser,
command_spec: Callable | dict | None,
) -> Optional[list[Node]]:
if command_spec is None:
return None
@@ -124,42 +145,97 @@ def check_command(
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, arg_spec: dict
) -> Optional[List[Node]]:
command: str, args: list[Node], parser: Parser, arg_spec: dict
) -> Optional[list[Node]]:
if "subcommands" in arg_spec:
subcommands = arg_spec["subcommands"]
try:
subcommand = args[0].contents
except IndexError:
subcommand = None
if subcommand in subcommands:
new_args = check_command(
f"{command} {subcommand}", args[1:], parser, subcommands[subcommand]
)
if new_args is None:
return new_args
return args[0:1] + new_args
if "" in subcommands:
return check_command(command, args, parser, subcommands[""])
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(subcommands.keys())}")
return dispatch_subcommands(command, args, parser, arg_spec["subcommands"])
switches = arg_spec["switches"]
args_allowed = set(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 = []
args = list(args)
while len(args) > 0:
arg = args.pop(0)
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
@@ -170,71 +246,146 @@ def check_arg_spec(
# any switches should be BareWords.
contents = arg.contents
if not (isinstance(arg, BareWord) and contents and contents[0] in {"-", ">"}):
positional_args.append(arg)
positional_args.append(arg_i - 1)
continue
# TODO check required arguments
if contents in args_allowed:
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"]:
try:
args.pop(0)
except IndexError:
arg_i += 1
if arg_i > len(args):
raise CommandArgError(
f"invalid arguments for {command}: expected value after"
f"invalid arguments for {command_name}: expected value after"
f" {contents}"
)
if not switches[contents]["repeated"]:
args_allowed.remove(contents)
if contents in args_required:
args_required.remove(contents)
elif contents in arg_spec:
raise CommandArgError(f"duplicate argument for {command}: {contents}")
else:
prefix_matches = []
for switch in switches:
if switch.startswith(contents):
prefix_matches.append(switch)
mapped.add(contents)
continue
if len(prefix_matches) == 1:
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"shortened argument for {command}: expand {contents} to"
f" {prefix_matches[0]}"
f"too many arguments for {command_name}: got {len(args)}, expected"
f" no more than {len(spec)}"
)
if len(prefix_matches) > 1:
raise CommandArgError(
f"ambiguous argument for {command}: {contents} could be any of"
f" {', '.join(prefix_matches)}"
)
mapping.append([i])
if spec[i]["value"]["type"] == "variadic" and extra > 0:
extra -= 1
else:
i += 1
raise CommandArgError(f"unrecognized argument for {command}: {contents}")
return mapping
if len(args_required) > 1:
raise CommandArgError(
f"missing required arguments for {command}: {', '.join(args_required)}"
)
elif len(args_required) == 1:
raise CommandArgError(
f"missing required argument for {command}: {args_required.pop()}"
)
required = []
for argspec in spec:
if argspec["required"]:
required.append(argspec["name"])
num_required = len(required)
min_positionals = 0
max_positionals: Optional[int] = 0
for positional in arg_spec["positionals"]:
if positional["value"]["type"] == "variadic":
max_positionals = None
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
if positional["required"]:
min_positionals += 1
if max_positionals is not None:
max_positionals += 1
mapping.append([i])
i += 1
check = check_count(
command,
min=min_positionals,
max=max_positionals,
args_name="positional args",
)
check(positional_args, None)
if isinstance(arg, ArgExpansion):
# Map missing arguments.
while missing > 0:
if spec[i]["required"]:
mapping[-1] += [i]
missing -= 1
i += 1
return None
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