diff --git a/SYMBOLS_MANIFEST.txt b/SYMBOLS_MANIFEST.txt
index 87a2cbf3e1..8380e82db8 100644
--- a/SYMBOLS_MANIFEST.txt
+++ b/SYMBOLS_MANIFEST.txt
@@ -215,6 +215,7 @@ System`ChebyshevT
System`ChebyshevU
System`Check
System`CheckAbort
+System`CheckArguments
System`ChessboardDistance
System`Chop
System`Circle
diff --git a/mathics/builtin/directories/directory_operations.py b/mathics/builtin/directories/directory_operations.py
index 11f72a6230..d462f04cf7 100644
--- a/mathics/builtin/directories/directory_operations.py
+++ b/mathics/builtin/directories/directory_operations.py
@@ -106,7 +106,6 @@ class DeleteDirectory(Builtin):
),
"nodir": "Directory `1` not found.",
"dirne": "Directory `1` not empty.",
- "optx": "Unknown option `1` in `2`",
"idcts": "DeleteContents expects either True or False.", # MMA Bug
}
options = {
diff --git a/mathics/builtin/messages.py b/mathics/builtin/messages.py
index a3320e3ee3..19a9e81e8c 100644
--- a/mathics/builtin/messages.py
+++ b/mathics/builtin/messages.py
@@ -233,12 +233,14 @@ class General(Builtin):
"noopen": "Cannot open `1`.",
"nord": "Invalid comparison with `1` attempted.",
"normal": "Nonatomic expression expected at position `1` in `2`.",
+ "notnorm": "Argument `1` must be a nonatomic expression.",
"noval": ("Symbol `1` in part assignment does not have an immediate value."),
"obspkg": "In WL, this package is obsolete.",
"openx": "`1` is not open.",
"optb": "Optional object `1` in `2` is not a single blank.",
"optnf": "Option name `1` is not a known option for `2`.",
"opttf": "Value of option `1` -> `2` should be True or False.",
+ "optx": "Unknown option `1` in `2`.",
"ovfl": "Overflow occurred in computation.",
"partd": "Part specification is longer than depth of object.",
"partw": "Part `1` of `2` does not exist.",
diff --git a/mathics/builtin/options/functions.py b/mathics/builtin/options/functions.py
index 50ebb5415b..e3977e2330 100644
--- a/mathics/builtin/options/functions.py
+++ b/mathics/builtin/options/functions.py
@@ -2,18 +2,142 @@
Setting Up Options for Functions
"""
-from mathics.core.atoms import Integer1, String
+import sys
+from typing import Optional
+
+from mathics.core.atoms import Integer, Integer1, String
+from mathics.core.attributes import A_HOLD_FIRST, A_PROTECTED, A_READ_PROTECTED
from mathics.core.builtin import Builtin, get_option
from mathics.core.evaluation import Evaluation
from mathics.core.expression import Expression
from mathics.core.list import ListExpression
-from mathics.core.symbols import Symbol, ensure_context
-from mathics.core.systemsymbols import SymbolRule
-from mathics.eval.options.function import options_to_rules
+from mathics.core.rules import is_option_rule
+from mathics.core.symbols import BooleanType, Symbol, ensure_context
+from mathics.core.systemsymbols import SymbolDirectedInfinity, SymbolRule
+from mathics.eval.options.functions import (
+ eval_CheckArguments,
+ eval_CheckArguments_with_association,
+ options_to_rules,
+)
from mathics.eval.options.options import eval_Option_with_names, eval_Options
from mathics.eval.patterns import Matcher
+class CheckArguments(Builtin):
+ """
+
+ :WMA link:
+ https://reference.wolfram.com/language/ref/CheckArguments.html
+
+
+ - 'CheckArguments'[$f[args]$, $n$]
+
- returns 'True" if args consists of $n$ positional arguments followed by valid options \
+ for $f$ and 'False' if not.
+
+
- 'CheckArguments'[$f[args]$, ${min, max$]
+
- same as above but checks only the positional arguments between $min$, and $max$.
+
+
+ First declare an option for function $f$:
+ >> Options[f] = {a -> 0}
+ = {a ⇾ 0}
+
+ Now check that $f$ is called with one positional argument and known options:
+ >> CheckArguments[f[1, a->5], 1]
+ = True
+
+ >> CheckArguments[f[1, 2], 1]
+ : f called with 2 arguments; 1 argument is expected.
+ = False
+
+ Allow the option named "hidden" as well as any option of 'Graphics' to be set:
+ >> Options[f] = {normal -> Automatic}
+ = {normal ⇾ Automatic}
+ >> CheckArguments[f[1, normal -> 3, hidden -> 2, AspectRatio -> 1], {1, 2}, <|"ExtraOptions" -> {hidden -> 0, Graphics}|>]
+ = True
+ """
+
+ attributes = A_HOLD_FIRST | A_PROTECTED | A_READ_PROTECTED
+ # Set to check the number of arguments.
+ eval_error = Builtin.generic_argument_error
+ expected_args = (2, 3)
+ messages = {
+ "rspec": "The range specification `1` should have the form m, {m, n} or {m, Infinity}, "
+ "where m and n are integers and 0 <= m <= n."
+ }
+
+ summary_text = "check arguments of a function"
+
+ def eval_with_min_max(
+ self, expr, arg, evaluation: Evaluation
+ ) -> Optional[BooleanType]:
+ "CheckArguments[expr_, arg_]"
+ if not isinstance(expr, Expression):
+ evaluation.message("CheckArguments", "notnorm", Integer1)
+ return
+
+ invalid_range_spec = False
+ if isinstance(arg, Integer):
+ invalid_range_spec = False
+ high_int = arg.value
+ low_int = min(1, high_int)
+ elif not (isinstance(arg, ListExpression) and len(arg.elements) == 2):
+ invalid_range_spec = True
+ elif not (isinstance((low := arg.elements[0]), Integer)):
+ invalid_range_spec = True
+
+ if invalid_range_spec:
+ evaluation.message("CheckArguments", "rspec", arg)
+ return
+
+ if isinstance(arg, ListExpression):
+ element1 = arg.elements[1]
+ low_int = arg.elements[0].value
+ if isinstance(element1, Integer):
+ high_int = element1.value
+ elif element1.has_form(SymbolDirectedInfinity, 1):
+ high_int = sys.maxsize
+
+ if not (0 <= low_int <= high_int):
+ evaluation.message("CheckArguments", "rspec", arg)
+ return
+
+ return eval_CheckArguments(expr, low_int, high_int, evaluation)
+
+ def eval_with_assoc(
+ self, expr, spec, assoc, evaluation: Evaluation
+ ) -> Optional[Expression] | Symbol:
+ "CheckArguments[expr_, spec_, assoc_]"
+
+ if not isinstance(expr, Expression):
+ evaluation.message("CheckArguments", "notnorm", Integer1)
+ return
+
+ # Parse the spec argument
+ invalid_range_spec = False
+ if isinstance(spec, Integer):
+ invalid_range_spec = False
+ high_int = spec.value
+ low_int = min(1, high_int)
+ elif not (isinstance(spec, ListExpression) and len(spec.elements) == 2):
+ invalid_range_spec = True
+ elif not (
+ isinstance((low := spec.elements[0]), Integer)
+ and isinstance((high := spec.elements[1]), Integer)
+ ):
+ invalid_range_spec = True
+ elif not (0 <= (low_int := low.value) <= (high_int := high.value)):
+ invalid_range_spec = True
+
+ if invalid_range_spec:
+ evaluation.message("CheckArguments", "rspec", spec)
+ return
+
+ return eval_CheckArguments_with_association(
+ expr, low_int, high_int, assoc, evaluation
+ )
+
+
class FilterRules(Builtin):
"""
@@ -45,11 +169,11 @@ class FilterRules(Builtin):
def eval(self, rules, pattern, evaluation):
"FilterRules[rules_List, pattern_]"
- match = Matcher(pattern, evaluation).match
+ match_result = Matcher(pattern, evaluation).match
def matched():
for rule in rules.elements:
- if rule.has_form("Rule", 2) and match(rule.elements[0], evaluation):
+ if is_option_rule(rule) and match_result(rule.elements[0], evaluation):
yield rule
return ListExpression(*list(matched()))
diff --git a/mathics/core/rules.py b/mathics/core/rules.py
index 0a0b83e5a6..615ada9184 100644
--- a/mathics/core/rules.py
+++ b/mathics/core/rules.py
@@ -80,7 +80,7 @@ def is_option_rule(element: Any) -> bool:
or:
key :> value
"""
- return element.has_form(RULE_SYMBOL_HEADS, 2)
+ return isinstance(element, Expression) and element.has_form(RULE_SYMBOL_HEADS, 2)
def is_rule(element: Any, include_delayed: bool = True) -> bool:
diff --git a/mathics/eval/options/__init__.py b/mathics/eval/options/__init__.py
index 0d8d78b9f0..b4b8ef0a16 100644
--- a/mathics/eval/options/__init__.py
+++ b/mathics/eval/options/__init__.py
@@ -3,7 +3,7 @@
mathics.builtins.options.
"""
-from mathics.eval.options.function import filter_from_iterable, options_to_rules
+from mathics.eval.options.functions import filter_from_iterable, options_to_rules
from mathics.eval.options.options import eval_Options
from mathics.eval.options.values import filter_non_default_values
diff --git a/mathics/eval/options/function.py b/mathics/eval/options/function.py
deleted file mode 100644
index 72068e55de..0000000000
--- a/mathics/eval/options/function.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from typing import Callable, Optional
-
-from mathics.core.expression import Expression
-from mathics.core.symbols import Symbol, strip_context
-from mathics.core.systemsymbols import SymbolRule
-
-
-def filter_from_iterable(elems) -> Callable:
- """
- Build a filter function from an iterable.
- The filter function returns `True` if
- the name after striping its context is in
- the interable.
- """
-
- def filter(name, value):
- return strip_context(name) in elems
-
- return filter
-
-
-def options_to_rules(options, filter: Optional[Callable] = None):
- items = sorted(options.items())
- if filter is not None:
- items = [(name, value) for name, value in items if filter(name, value)]
- return [Expression(SymbolRule, Symbol(name), value) for name, value in items]
diff --git a/mathics/eval/options/functions.py b/mathics/eval/options/functions.py
new file mode 100644
index 0000000000..197bc368b4
--- /dev/null
+++ b/mathics/eval/options/functions.py
@@ -0,0 +1,198 @@
+from typing import Callable, Optional
+
+from mathics.core.atoms import Integer, String
+from mathics.core.atoms.associations import Association
+from mathics.core.evaluation import Evaluation
+from mathics.core.expression import Expression
+from mathics.core.list import ListExpression
+from mathics.core.rules import is_option_rule, is_rule
+from mathics.core.symbols import (
+ BooleanType,
+ Symbol,
+ SymbolFalse,
+ SymbolTrue,
+ strip_context,
+)
+from mathics.core.systemsymbols import SymbolRule
+
+
+def _extract_option_name(arg) -> str | None:
+ """
+ Extract the option name from a rule or list of rules.
+ Returns the option name as a string or None.
+ """
+ if is_rule(arg):
+ # arg is Rule[name, value] or RuleDelayed[name, value] or Expression[SymbolRule..]
+ option_symbol = arg.elements[0]
+ if isinstance(option_symbol, Symbol):
+ return option_symbol.name
+ elif isinstance(arg, ListExpression):
+ # For a list of rules, all must have the same option names
+ # or be valid options. We only check the first for now.
+ if arg.elements and is_option_rule(arg.elements[0]):
+ return _extract_option_name(arg.elements[0])
+ return None
+
+
+def _partition_arguments(
+ args: tuple, head, extra_options: dict, evaluation: Evaluation
+) -> tuple:
+ """
+ An argument is treated as an option if:
+ * It matches an option pattern (Rule, RuleDelayed, or List of Rules), and
+ * Its left-hand side (option name) is declared in the head's Options
+
+ If no Options are declared for the head, trailing option patterns
+ are still recognized as options.
+ Split args into positional arguments and trailing options/rules.
+ Returns (pos_args, opt_args, isValidOptions).
+ """
+ positional_args = []
+ option_args = []
+ options_start_index = len(args)
+
+ # Get the declared options for this head
+ declared_options = evaluation.definitions.get_options(head.name) | extra_options
+
+ # Trailing rules matching OptionsPattern are treated as options
+ # if they match declared options or if they are option patterns.
+ for arg in reversed(args):
+ # Check if arg is Rule[k, v], RuleDelayed[k, v], or List of Rules
+ if is_option_pattern(arg, evaluation):
+ # If we have declared options, check if this rule matches a declared option
+ if declared_options:
+ option_name = _extract_option_name(arg)
+ if option_name:
+ if option_name in declared_options:
+ option_args.insert(0, arg)
+ options_start_index -= 1
+ else:
+ evaluation.message(head.name, "optx", String(option_name), args)
+ return [], [], -1
+ else:
+ break
+ else:
+ # No declared options, so all trailing rules are treated as positional arguments
+ break
+ # option_args.insert(0, arg)
+ # options_start_index -= 1
+ else:
+ # Once a non-option argument is encountered from the right,
+ # remaining items to the left are all positional arguments.
+ break
+
+ positional_args = list(args[: len(args) - len(option_args)])
+ return positional_args, option_args, options_start_index
+
+
+def eval_CheckArguments(
+ expr,
+ min_arg_index: int,
+ max_arg_index: int,
+ evaluation: Evaluation,
+ extra_options: dict = {},
+) -> BooleanType:
+
+ head = expr.head
+ args = expr.elements # tuple of argument Expression nodes
+
+ positional_args, option_args, options_start_index = _partition_arguments(
+ args, head, extra_options, evaluation
+ )
+
+ # -1 is a sentinal that we failed on options checking,
+ # and _partition_arguments has already given a message.
+ if options_start_index == -1:
+ return SymbolFalse
+
+ if option_args and options_start_index > max_arg_index:
+ evaluation.message(
+ "CheckArguments",
+ "nonopt",
+ args[min_arg_index],
+ Integer(min_arg_index),
+ expr,
+ )
+ return SymbolFalse
+
+ len_positional_args = len(positional_args)
+
+ if not (min_arg_index <= len_positional_args <= max_arg_index):
+ # FIXME: should we distingish between
+ # CheckArguments[x[], 1] and CheckArguments[x[], {1, 1}]
+ # kinds of errors?
+ if max_arg_index == 1:
+ evaluation.message(
+ "CheckArguments",
+ "argx",
+ head,
+ Integer(len_positional_args),
+ )
+ else:
+ evaluation.message(
+ "CheckArguments",
+ "argrx",
+ head,
+ Integer(len_positional_args),
+ Integer(max_arg_index),
+ )
+ return SymbolFalse
+
+ return SymbolTrue
+
+
+def eval_CheckArguments_with_association(
+ expr,
+ min_arg_index: int,
+ max_arg_index: int,
+ assoc: Association,
+ evaluation: Evaluation,
+) -> BooleanType:
+
+ # Extract ExtraOptions from assoc.
+ extra_options_dict = {}
+ if isinstance(assoc, Association):
+ extra_options = assoc.get(String("ExtraOptions"))
+ if isinstance(extra_options, ListExpression):
+ for option in extra_options:
+ if is_option_rule(option):
+ key, value = option.elements
+ extra_options_dict[key.name] = value
+ elif isinstance(option, Symbol):
+ extra_options_dict |= evaluation.definitions.get_options(
+ option.name
+ )
+
+ return eval_CheckArguments(
+ expr, min_arg_index, max_arg_index, evaluation, extra_options_dict
+ )
+
+
+def filter_from_iterable(elems) -> Callable:
+ """
+ Build a filter function from an iterable.
+ The filter function returns `True` if
+ the name after striping its context is in
+ the interable.
+ """
+
+ def filter(name, value):
+ return strip_context(name) in elems
+
+ return filter
+
+
+def is_option_pattern(expr, evaluation: Evaluation):
+ """Check if an expression is a Rule, RuleDelayed, or List of Rules."""
+ if is_option_rule(expr):
+ return True
+ if isinstance(expr, ListExpression):
+ return all(is_option_pattern(e, evaluation) for e in expr.elements)
+ return False
+
+
+def options_to_rules(options, filter: Optional[Callable] = None):
+ items = sorted(options.items())
+ if filter is not None:
+ items = [(name, value) for name, value in items if filter(name, value)]
+ return [Expression(SymbolRule, Symbol(name), value) for name, value in items]
diff --git a/test/builtin/options/__init__.py b/test/builtin/options/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/test/builtin/options/test_options.py b/test/builtin/options/test_options.py
new file mode 100644
index 0000000000..7f60925791
--- /dev/null
+++ b/test/builtin/options/test_options.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+"""
+Unit tests from mathics.builtin.options.
+"""
+
+from test.helper import check_arg_counts, check_evaluation
+
+import pytest
+
+
+@pytest.mark.parametrize(
+ ("str_expr", "msgs", "str_expected", "assert_msg"),
+ [
+ (
+ (
+ 'f[x_, OptionsPattern[f]] := x ^ OptionValue["m"];'
+ 'Options[f] = {"m" -> 7};f[x]'
+ ),
+ None,
+ "x ^ 7",
+ None,
+ ),
+ ("f /: Options[f] = {a -> b}", None, "{a ⇾ b}", None),
+ ("Options[f]", None, "{a ⇾ b}", None),
+ (
+ "f /: Options[g] := {a -> b}",
+ ("Rule for Options can only be attached to g.",),
+ "$Failed",
+ None,
+ ),
+ (
+ "Options[f] = a /; True",
+ ("a /; True is not a valid list of option rules.",),
+ "a /; True",
+ None,
+ ),
+ (
+ "Options[Plot, Ticks]",
+ None,
+ "{Ticks ⇾ Automatic}",
+ None,
+ ),
+ (
+ 'Options[Plot, "Ticks"]',
+ None,
+ "{Ticks ⇾ Automatic}",
+ None,
+ ),
+ (
+ "Options[AtomQ, Foo]",
+ ["Option name Foo is not a known option for AtomQ."],
+ "Options[AtomQ, Foo]",
+ None,
+ ),
+ (
+ 'Options[AtomQ, "Foo"]',
+ ["Option name Foo is not a known option for AtomQ."],
+ "Options[AtomQ, Foo]",
+ None,
+ ),
+ ],
+)
+def test_checkarguments(str_expr, msgs, str_expected, assert_msg):
+ """ """
+ check_evaluation(
+ str_expr,
+ str_expected,
+ to_string_expr=True,
+ to_string_expected=True,
+ hold_expected=True,
+ failure_message=assert_msg,
+ expected_messages=msgs,
+ )
+
+
+@pytest.mark.parametrize(
+ ("str_expr", "msgs", "str_expected", "assert_msg"),
+ [
+ (
+ "CheckArguments[x[], 0]",
+ None,
+ "True",
+ "CheckArguments with zero arguments",
+ ),
+ (
+ "CheckArguments[x[], 1]",
+ ("x called with 0 arguments; 1 argument is expected.",),
+ "False",
+ "CheckArguments mismatch with 0-argument call",
+ ),
+ (
+ 'CheckArguments[x[x], "foo"]',
+ (
+ "The range specification foo should have the form m, {m, n} or {m, Infinity}, where m and n are integers and 0 <= m <= n.",
+ ),
+ "CheckArguments[x[x], foo]",
+ "CheckArguments called with an invalid string 2nd argument",
+ ),
+ (
+ "Clear[g]; CheckArguments[g[1, a->5], 0]",
+ ("g called with 2 arguments; 0 arguments are expected.",),
+ "False",
+ "CheckArguments with undeclared option parameter and mismatched count",
+ ),
+ (
+ "Options[f] = {a -> 0}; CheckArguments[f[1, a->5], 0]",
+ (
+ "Options expected (instead of 1) beyond position 0 in f[1, a ⇾ 5]. An option must be a rule or a list of rules.",
+ ),
+ "False",
+ "CheckArguments with declared option parameter and mismatched count",
+ ),
+ (
+ "CheckArguments[f[1, 2, 3, a -> 0], {1, 3}]",
+ None,
+ "True",
+ "CheckArguments {min, max} form of CheckArguments",
+ ),
+ ],
+)
+def test_options(str_expr, msgs, str_expected, assert_msg):
+ """ """
+ check_evaluation(
+ str_expr,
+ str_expected,
+ to_string_expr=True,
+ to_string_expected=True,
+ hold_expected=True,
+ failure_message=assert_msg,
+ expected_messages=msgs,
+ )
+
+
+@pytest.mark.parametrize(
+ ("function_name", "msg_fragment"),
+ [
+ (
+ "CheckArguments",
+ "2 or 3 arguments are",
+ ),
+ ],
+)
+def test_arg_errors(function_name, msg_fragment):
+ """ """
+ check_arg_counts(function_name, msg_fragment)
diff --git a/test/builtin/test_options.py b/test/builtin/test_options.py
deleted file mode 100644
index c3b7cd87b3..0000000000
--- a/test/builtin/test_options.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# -*- coding: utf-8 -*-
-"""
-Unit tests from mathics.builtin.options.
-"""
-
-
-from test.helper import check_evaluation
-
-import pytest
-
-
-@pytest.mark.parametrize(
- ("str_expr", "msgs", "str_expected", "fail_msg"),
- [
- (
- (
- 'f[x_, OptionsPattern[f]] := x ^ OptionValue["m"];'
- 'Options[f] = {"m" -> 7};f[x]'
- ),
- None,
- "x ^ 7",
- None,
- ),
- ("f /: Options[f] = {a -> b}", None, "{a ⇾ b}", None),
- ("Options[f]", None, "{a ⇾ b}", None),
- (
- "f /: Options[g] := {a -> b}",
- ("Rule for Options can only be attached to g.",),
- "$Failed",
- None,
- ),
- (
- "Options[f] = a /; True",
- ("a /; True is not a valid list of option rules.",),
- "a /; True",
- None,
- ),
- (
- "Options[Plot, Ticks]",
- None,
- "{Ticks ⇾ Automatic}",
- None,
- ),
- (
- 'Options[Plot, "Ticks"]',
- None,
- "{Ticks ⇾ Automatic}",
- None,
- ),
- (
- "Options[AtomQ, Foo]",
- ["Option name Foo is not a known option for AtomQ."],
- "Options[AtomQ, Foo]",
- None,
- ),
- (
- 'Options[AtomQ, "Foo"]',
- ["Option name Foo is not a known option for AtomQ."],
- "Options[AtomQ, Foo]",
- None,
- ),
- ],
-)
-def test_options(str_expr, msgs, str_expected, fail_msg):
- """ """
- check_evaluation(
- str_expr,
- str_expected,
- to_string_expr=True,
- to_string_expected=True,
- hold_expected=True,
- failure_message=fail_msg,
- expected_messages=msgs,
- )