diff --git a/CHANGELOG.md b/CHANGELOG.md index c0cedea8..685091fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added * Added filename metadata to compiler exceptions (#844) + * Added a compile-time warning for attempting to call a function with an unsupported number of arguments (#671) ### Fixed * Fix a bug where `basilisp.lang.compiler.exception.CompilerException` would nearly always suppress line information in it's `data` map (#845) + * Fix a bug where the function returned by `partial` retained the meta, arities, and `with_meta` method of the wrapped function rather than creating new ones (#847) ## [v0.1.0b1] ### Added diff --git a/docs/compiler.rst b/docs/compiler.rst index 102cbf68..d262c031 100644 --- a/docs/compiler.rst +++ b/docs/compiler.rst @@ -35,6 +35,12 @@ Warnings The following settings enable and disable warnings from the Basilisp compiler during compilation. +* ``warn-on-arity-mismatch`` - if ``true``, emit warnings if a Basilisp function invocation is detected with an unsupported number of arguments + + * Environment Variable: ``BASILISP_WARN_ON_ARITY_MISMATCH`` + * Default: ``true`` + + * ``warn-on-shadowed-name`` - if ``true``, emit warnings if a local name is shadowed by another local name * Environment Variable: ``BASILISP_WARN_ON_SHADOWED_NAME`` diff --git a/src/basilisp/cli.py b/src/basilisp/cli.py index 60e9003b..d3da5a69 100644 --- a/src/basilisp/cli.py +++ b/src/basilisp/cli.py @@ -136,6 +136,19 @@ def _add_compiler_arg_group(parser: argparse.ArgumentParser) -> None: f"{DEFAULT_COMPILER_OPTS['inline-functions']})" ), ) + group.add_argument( + "--warn-on-arity-mismatch", + action="store", + nargs="?", + const=os.getenv("BASILISP_WARN_ON_ARITY_MISMATCH"), + type=_to_bool, + help=( + "if true, emit warnings if a Basilisp function invocation is detected with " + "an unsupported number of arguments " + "(env: BASILISP_WARN_ON_ARITY_MISMATCH; default: " + f"{DEFAULT_COMPILER_OPTS['warn-on-arity-mismatch']})" + ), + ) group.add_argument( "--warn-on-shadowed-name", action="store", diff --git a/src/basilisp/contrib/pytest/testrunner.py b/src/basilisp/contrib/pytest/testrunner.py index 0b6c7c30..d3582aae 100644 --- a/src/basilisp/contrib/pytest/testrunner.py +++ b/src/basilisp/contrib/pytest/testrunner.py @@ -30,7 +30,7 @@ def pytest_collect_file(file_path: Path, path, parent): """Primary PyTest hook to identify Basilisp test files.""" if file_path.suffix == ".lpy": if file_path.name.startswith("test_") or file_path.stem.endswith("_test"): - return BasilispFile.from_parent(parent, fspath=path, path=file_path) + return BasilispFile.from_parent(parent, path=file_path) return None diff --git a/src/basilisp/lang/compiler/__init__.py b/src/basilisp/lang/compiler/__init__.py index 08231dd8..f23e6c40 100644 --- a/src/basilisp/lang/compiler/__init__.py +++ b/src/basilisp/lang/compiler/__init__.py @@ -10,6 +10,7 @@ from basilisp.lang.compiler.analyzer import ( # noqa GENERATE_AUTO_INLINES, INLINE_FUNCTIONS, + WARN_ON_ARITY_MISMATCH, WARN_ON_NON_DYNAMIC_SET, WARN_ON_SHADOWED_NAME, WARN_ON_SHADOWED_VAR, @@ -32,6 +33,7 @@ from basilisp.lang.compiler.optimizer import PythonASTOptimizer from basilisp.lang.typing import CompilerOpts, ReaderForm from basilisp.lang.util import genname +from basilisp.util import Maybe _DEFAULT_FN = "__lisp_expr__" @@ -97,6 +99,7 @@ def py_ast_optimizer(self) -> PythonASTOptimizer: def compiler_opts( # pylint: disable=too-many-arguments generate_auto_inlines: Optional[bool] = None, inline_functions: Optional[bool] = None, + warn_on_arity_mismatch: Optional[bool] = None, warn_on_shadowed_name: Optional[bool] = None, warn_on_shadowed_var: Optional[bool] = None, warn_on_unused_names: Optional[bool] = None, @@ -108,15 +111,16 @@ def compiler_opts( # pylint: disable=too-many-arguments return lmap.map( { # Analyzer options - GENERATE_AUTO_INLINES: generate_auto_inlines or True, - INLINE_FUNCTIONS: inline_functions or True, - WARN_ON_SHADOWED_NAME: warn_on_shadowed_name or False, - WARN_ON_SHADOWED_VAR: warn_on_shadowed_var or False, - WARN_ON_UNUSED_NAMES: warn_on_unused_names or True, - WARN_ON_NON_DYNAMIC_SET: warn_on_non_dynamic_set or True, + GENERATE_AUTO_INLINES: Maybe(generate_auto_inlines).or_else_get(True), + INLINE_FUNCTIONS: Maybe(inline_functions).or_else_get(True), + WARN_ON_ARITY_MISMATCH: Maybe(warn_on_arity_mismatch).or_else_get(True), + WARN_ON_SHADOWED_NAME: Maybe(warn_on_shadowed_name).or_else_get(False), + WARN_ON_SHADOWED_VAR: Maybe(warn_on_shadowed_var).or_else_get(False), + WARN_ON_UNUSED_NAMES: Maybe(warn_on_unused_names).or_else_get(True), + WARN_ON_NON_DYNAMIC_SET: Maybe(warn_on_non_dynamic_set).or_else_get(True), # Generator options - USE_VAR_INDIRECTION: use_var_indirection or False, - WARN_ON_VAR_INDIRECTION: warn_on_var_indirection or True, + USE_VAR_INDIRECTION: Maybe(use_var_indirection).or_else_get(False), + WARN_ON_VAR_INDIRECTION: Maybe(warn_on_var_indirection).or_else_get(True), } ) diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index 0c2adcff..ef43a10f 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -54,6 +54,7 @@ LINE_KW, NAME_KW, NS_KW, + REST_KW, SYM_ABSTRACT_META_KEY, SYM_ASYNC_META_KEY, SYM_CLASSMETHOD_META_KEY, @@ -145,6 +146,7 @@ # Analyzer options GENERATE_AUTO_INLINES = kw.keyword("generate-auto-inlines") INLINE_FUNCTIONS = kw.keyword("inline-functions") +WARN_ON_ARITY_MISMATCH = kw.keyword("warn-on-arity-mismatch") WARN_ON_SHADOWED_NAME = kw.keyword("warn-on-shadowed-name") WARN_ON_SHADOWED_VAR = kw.keyword("warn-on-shadowed-var") WARN_ON_UNUSED_NAMES = kw.keyword("warn-on-unused-names") @@ -362,6 +364,12 @@ def should_inline_functions(self) -> bool: """If True, function calls may be inlined if an inline def is provided.""" return self._opts.val_at(INLINE_FUNCTIONS, True) + @property + def warn_on_arity_mismatch(self) -> bool: + """If True, warn when a Basilisp function invocation is detected with an + unsupported number of arguments.""" + return self._opts.val_at(WARN_ON_ARITY_MISMATCH, True) + @property def warn_on_unused_names(self) -> bool: """If True, warn when local names are unused.""" @@ -2456,6 +2464,40 @@ def __handle_macroexpanded_ast( ) +def _do_warn_on_arity_mismatch( + fn: VarRef, form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext +) -> None: + if ctx.warn_on_arity_mismatch and getattr(fn.var.value, "_basilisp_fn", False): + arities: Optional[Tuple[Union[int, kw.Keyword]]] = getattr( + fn.var.value, "arities", None + ) + if arities is not None: + has_variadic = REST_KW in arities + fixed_arities = set(filter(lambda v: v != REST_KW, arities)) + max_fixed_arity = max(fixed_arities) if fixed_arities else None + # This count could be off by 1 for cases where kwargs are being passed, + # but only Basilisp functions intended to be called by Python code + # (e.g. with a :kwargs strategy) should ever be called with kwargs, + # so this seems unlikely enough. + num_args = runtime.count(form.rest) + if has_variadic and (max_fixed_arity is None or num_args > max_fixed_arity): + return + if num_args not in fixed_arities: + report_arities = cast(Set[Union[int, str]], set(fixed_arities)) + if has_variadic: + report_arities.discard(cast(int, max_fixed_arity)) + report_arities.add(f"{max_fixed_arity}+") + loc = ( + f" ({fn.env.file}:{fn.env.line})" + if fn.env.line is not None + else f" ({fn.env.file})" + ) + logger.warning( + f"calling function {fn.var}{loc} with {num_args} arguments; " + f"expected any of: {', '.join(sorted(map(str, report_arities)))}", + ) + + def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) -> Node: with ctx.expr_pos(): fn = _analyze_form(form.first, ctx) @@ -2492,6 +2534,8 @@ def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) - phase=CompilerPhase.INLINING, ) from e + _do_warn_on_arity_mismatch(fn, form, ctx) + args, kwargs = _call_args_ast(form.rest, ctx) return Invoke( form=form, diff --git a/src/basilisp/lang/compiler/constants.py b/src/basilisp/lang/compiler/constants.py index 8861d2ab..022e4587 100644 --- a/src/basilisp/lang/compiler/constants.py +++ b/src/basilisp/lang/compiler/constants.py @@ -52,6 +52,8 @@ class SpecialForm: SYM_TAG_META_KEY = kw.keyword("tag") ARGLISTS_KW = kw.keyword("arglists") +INTERFACE_KW = kw.keyword("interface") +REST_KW = kw.keyword("rest") COL_KW = kw.keyword("col") DOC_KW = kw.keyword("doc") FILE_KW = kw.keyword("file") diff --git a/src/basilisp/lang/compiler/generator.py b/src/basilisp/lang/compiler/generator.py index 7dba5057..eef8964f 100644 --- a/src/basilisp/lang/compiler/generator.py +++ b/src/basilisp/lang/compiler/generator.py @@ -45,6 +45,8 @@ from basilisp.lang import vector as vec from basilisp.lang.compiler.constants import ( DEFAULT_COMPILER_FILE_PATH, + INTERFACE_KW, + REST_KW, SYM_DYNAMIC_META_KEY, SYM_NO_WARN_ON_REDEF_META_KEY, SYM_REDEF_META_KEY, @@ -132,10 +134,6 @@ _TRY_PREFIX = "lisp_try" _NS_VAR = "_NS" -# Keyword constants used in generating code -_INTERFACE_KW = kw.keyword("interface") -_REST_KW = kw.keyword("rest") - @attr.frozen class SymbolTableEntry: @@ -209,7 +207,7 @@ class RecurType(Enum): class RecurPoint: loop_id: str type: RecurType - binding_names: Optional[Collection[str]] = None + binding_names: Optional[Iterable[str]] = None is_variadic: Optional[bool] = None has_recur: bool = False @@ -273,7 +271,7 @@ def has_var_indirection_override(self) -> bool: return False @property - def recur_point(self): + def recur_point(self) -> RecurPoint: return self._recur_points[-1] @contextlib.contextmanager @@ -281,7 +279,7 @@ def new_recur_point( self, loop_id: str, type_: RecurType, - binding_names: Optional[Collection[str]] = None, + binding_names: Optional[Iterable[str]] = None, is_variadic: Optional[bool] = None, ): self._recur_points.append( @@ -305,7 +303,7 @@ def new_symbol_table(self, name: str, is_context_boundary: bool = False): self._st.pop() @property - def current_this(self): + def current_this(self) -> sym.Symbol: return self._this[-1] @contextlib.contextmanager @@ -1417,7 +1415,7 @@ def __deftype_or_reify_bases_to_py_ast( ast.Call( func=_NEW_KW_FN_NAME, args=[ - ast.Constant(hash(_INTERFACE_KW)), + ast.Constant(hash(INTERFACE_KW)), ast.Constant("interface"), ], keywords=[], @@ -1695,7 +1693,7 @@ def __fn_decorator( ast.Call( func=_NEW_KW_FN_NAME, args=[ - ast.Constant(hash(_REST_KW)), + ast.Constant(hash(REST_KW)), ast.Constant("rest"), ], keywords=[], @@ -1810,11 +1808,7 @@ def __single_arity_fn_to_py_ast( # pylint: disable=too-many-locals meta_decorators, [ __fn_decorator( - ( - (len(fn_args),) - if not method.is_variadic - else () - ), + (len(fn_args),), has_rest_arg=method.is_variadic, ) ], @@ -1847,6 +1841,7 @@ def __multi_arity_dispatch_fn( # pylint: disable=too-many-arguments,too-many-lo arity_map: Mapping[int, str], return_tags: Iterable[Optional[Node]], default_name: Optional[str] = None, + rest_arity_fixed_arity: Optional[int] = None, max_fixed_arity: Optional[int] = None, meta_node: Optional[MetaNode] = None, is_async: bool = False, @@ -2013,7 +2008,16 @@ def fn(*args): meta_decorators, [ __fn_decorator( - arity_map.keys(), + list( + chain( + arity_map.keys(), + ( + [rest_arity_fixed_arity] + if rest_arity_fixed_arity is not None + else [] + ), + ) + ), has_rest_arg=default_name is not None, ) ], @@ -2046,6 +2050,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals arity_to_name = {} rest_arity_name: Optional[str] = None + rest_arity_fixed_arity: Optional[int] = None fn_defs = [] all_arity_def_deps: List[ast.AST] = [] for arity in arities: @@ -2054,6 +2059,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals ) if arity.is_variadic: rest_arity_name = arity_name + rest_arity_fixed_arity = arity.fixed_arity else: arity_to_name[arity.fixed_arity] = arity_name @@ -2109,6 +2115,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals arity_to_name, return_tags=[arity.tag for arity in arities], default_name=rest_arity_name, + rest_arity_fixed_arity=rest_arity_fixed_arity, max_fixed_arity=node.max_fixed_arity, meta_node=meta_node, is_async=node.is_async, @@ -2567,6 +2574,7 @@ def __loop_recur_to_py_ast( ) -> GeneratedPyAST[ast.expr]: """Return a Python AST node for `recur` occurring inside a `loop`.""" assert node.op == NodeOp.RECUR + assert ctx.recur_point.binding_names is not None recur_deps: List[ast.AST] = [] recur_targets: List[ast.Name] = [] @@ -3223,7 +3231,6 @@ def _interop_prop_to_py_ast( assert node.op == NodeOp.HOST_FIELD target_ast = gen_py_ast(ctx, node.target) - assert not target_ast.dependencies return GeneratedPyAST( node=ast.Attribute( diff --git a/src/basilisp/lang/runtime.py b/src/basilisp/lang/runtime.py index a0bc300f..6ad35a37 100644 --- a/src/basilisp/lang/runtime.py +++ b/src/basilisp/lang/runtime.py @@ -61,7 +61,7 @@ ITransientSet, ) from basilisp.lang.reference import RefBase, ReferenceBase -from basilisp.lang.typing import CompilerOpts, LispNumber +from basilisp.lang.typing import BasilispFunction, CompilerOpts, LispNumber from basilisp.lang.util import OBJECT_DUNDER_METHODS, demunge, is_abstract, munge from basilisp.util import Maybe @@ -1286,12 +1286,46 @@ def _conj_ipersistentcollection(coll: IPersistentCollection, *xs): return coll.cons(*xs) +def _update_signature_for_partial(f: BasilispFunction, num_args: int) -> None: + """Update the various properties of a Basilisp function for wrapped partials. + + Partial applications change the number of arities a function appears to have. + This function computes the new `arities` set for the partial function by removing + any now-invalid fixed arities from the original function's set. + + Additionally, partials do not take the meta from the wrapped function, so that + value should be cleared and the `with-meta` method should be replaced with a + new method.""" + existing_arities: IPersistentSet[Union[kw.Keyword, int]] = f.arities + new_arities: Set[Union[kw.Keyword, int]] = set() + for arity in existing_arities: + if isinstance(arity, kw.Keyword): + new_arities.add(arity) + elif arity > num_args: + new_arities.add(arity - num_args) + if not new_arities: + if num_args in existing_arities: + new_arities.add(0) + else: + logger.warning( + f"invalid partial function application of '{f.__name__}' detected: " # type: ignore[attr-defined] + f"{num_args} arguments given; expected any of: " + f"{', '.join(sorted(map(str, existing_arities)))}" + ) + f.arities = lset.set(new_arities) + f.meta = None + f.with_meta = partial(_fn_with_meta, f) # type: ignore[method-assign] + + def partial(f, *args, **kwargs): """Return a function which is the partial application of f with args and kwargs.""" @functools.wraps(f) def partial_f(*inner_args, **inner_kwargs): - return f(*itertools.chain(args, inner_args), **{**kwargs, **inner_kwargs}) + return f(*args, *inner_args, **{**kwargs, **inner_kwargs}) + + if hasattr(partial_f, "_basilisp_fn"): + _update_signature_for_partial(cast(BasilispFunction, partial_f), len(args)) return partial_f @@ -1760,11 +1794,13 @@ def wrapped_f(*args, **kwargs): return wrapped_f -def _basilisp_fn(arities: Tuple[Union[int, kw.Keyword]]): +def _basilisp_fn( + arities: Tuple[Union[int, kw.Keyword], ...] +) -> Callable[..., BasilispFunction]: """Create a Basilisp function, setting meta and supplying a with_meta method implementation.""" - def wrap_fn(f): + def wrap_fn(f) -> BasilispFunction: assert not hasattr(f, "meta") f._basilisp_fn = True f.arities = lset.set(arities) diff --git a/src/basilisp/lang/typing.py b/src/basilisp/lang/typing.py index 7aa69a75..f5b5e631 100644 --- a/src/basilisp/lang/typing.py +++ b/src/basilisp/lang/typing.py @@ -2,7 +2,7 @@ from datetime import datetime from decimal import Decimal from fractions import Fraction -from typing import Pattern, Union +from typing import Optional, Pattern, Protocol, Union from basilisp.lang import keyword as kw from basilisp.lang import list as llist @@ -11,7 +11,13 @@ from basilisp.lang import set as lset from basilisp.lang import symbol as sym from basilisp.lang import vector as vec -from basilisp.lang.interfaces import IPersistentMap, IRecord, ISeq, IType +from basilisp.lang.interfaces import ( + IPersistentMap, + IPersistentSet, + IRecord, + ISeq, + IType, +) CompilerOpts = IPersistentMap[kw.Keyword, bool] @@ -43,3 +49,13 @@ PyCollectionForm = Union[dict, list, set, tuple] ReaderForm = Union[LispForm, IRecord, ISeq, IType, PyCollectionForm] SpecialForm = Union[llist.PersistentList, ISeq] + + +class BasilispFunction(Protocol): + _basilisp_fn: bool + arities: IPersistentSet[Union[kw.Keyword, int]] + meta: Optional[IPersistentMap] + + def __call__(self, *args, **kwargs): ... + + def with_meta(self, meta: Optional[IPersistentMap]) -> "BasilispFunction": ... diff --git a/tests/basilisp/compiler_test.py b/tests/basilisp/compiler_test.py index e84ae323..3dac682c 100644 --- a/tests/basilisp/compiler_test.py +++ b/tests/basilisp/compiler_test.py @@ -5805,6 +5805,115 @@ def test_cross_ns_macro_symbol_resolution_with_refers( runtime.Namespace.remove(third_ns_name) +class TestWarnOnArityMismatch: + def test_warning_on_arity_mismatch( + self, + lcompile: CompileFn, + ns: runtime.Namespace, + compiler_file_path: str, + caplog, + ): + var = lcompile("(defn jdkdka [a b c] [a b c])") + lcompile( + "(fn* [] (jdkdka :a :b))", + opts={compiler.WARN_ON_ARITY_MISMATCH: True}, + ) + assert ( + "basilisp.lang.compiler.analyzer", + logging.WARNING, + f"calling function {var} ({compiler_file_path}:1) with 2 arguments; expected any of: 3", + ) in caplog.record_tuples + + def test_warning_on_arity_mismatch_variadic( + self, + lcompile: CompileFn, + ns: runtime.Namespace, + compiler_file_path: str, + caplog, + ): + var = lcompile( + "(defn pqkdha ([] :none) ([a b] [a b]) ([a b c d & others] [a b c d others]))" + ) + lcompile( + "(fn* [] (pqkdha :a))", + opts={compiler.WARN_ON_ARITY_MISMATCH: True}, + ) + assert ( + "basilisp.lang.compiler.analyzer", + logging.WARNING, + f"calling function {var} ({compiler_file_path}:1) with 1 arguments; expected any of: 0, 2, 4+", + ) in caplog.record_tuples + + def test_warning_on_arity_mismatch_variadic_with_partial( + self, + lcompile: CompileFn, + ns: runtime.Namespace, + compiler_file_path: str, + caplog, + ): + var = lcompile( + """ + (defn dazhqpe ([] :none) ([a b] [a b]) ([a b c d & others] [a b c d others])) + (def zjpqeee (partial dazhqpe :a :b :c)) + """ + ) + lcompile( + "(fn* [] (zjpqeee))", + opts={compiler.WARN_ON_ARITY_MISMATCH: True}, + ) + assert ( + "basilisp.lang.compiler.analyzer", + logging.WARNING, + f"calling function {var} ({compiler_file_path}:1) with 0 arguments; expected any of: 1+", + ) in caplog.record_tuples + + def test_no_runtime_warning_on_arity_mismatch_variadic_with_partial( + self, + lcompile: CompileFn, + ns: runtime.Namespace, + compiler_file_path: str, + caplog, + ): + # This case is tricky to detect at compile time, so we implemented a runtime + # check instead. + lcompile( + """ + (defn klkpqkc [a] a) + (def yyqeken (partial klkpqkc :a)) + """ + ) + assert not list( + filter( + lambda rec: re.match( + "invalid partial function application of 'klkpqkc' detected", + rec[2], + ), + caplog.record_tuples, + ) + ) + + def test_runtime_warning_on_arity_mismatch_variadic_with_partial( + self, + lcompile: CompileFn, + ns: runtime.Namespace, + compiler_file_path: str, + caplog, + ): + # This case is tricky to detect at compile time, so we implemented a runtime + # check instead. + lcompile( + """ + (defn ueqhenn []) + (def pqencka (partial ueqhenn :a)) + """ + ) + assert ( + "basilisp.lang.runtime", + logging.WARNING, + "invalid partial function application of 'ueqhenn' detected: 1 arguments given; expected any of: 0", + ) in caplog.record_tuples + + class TestWarnOnVarIndirection: @pytest.fixture def other_ns(self, lcompile: CompileFn, ns: runtime.Namespace): diff --git a/tests/basilisp/test_core_fns.lpy b/tests/basilisp/test_core_fns.lpy index 329f0ca9..15e057ba 100644 --- a/tests/basilisp/test_core_fns.lpy +++ b/tests/basilisp/test_core_fns.lpy @@ -579,6 +579,33 @@ ;; Higher Order and Collection Functions ;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(deftest partial-test + (testing "no arguments" + (let [f-no-args (fn [] :no-args)] + (is (identical? f-no-args (partial f-no-args))))) + + (testing "with arguments" + (let [f ^{:meta-tag :value} + (fn + ([] :no-args) + ([a b] [a b]) + ([a b c d & rest] + [a b c d rest]))] + (is (= #{0 2 4 :rest} (.-arities f))) + (is (= {:meta-tag :value} (meta f))) + + (let [new-f (partial f :a) + new-f-meta (with-meta new-f {:different-tag :yes})] + (is (= #{1 3 :rest} (.-arities new-f))) + (is (nil? (meta new-f))) + (is (= {:different-tag :yes} (meta new-f-meta))) + (is (= [:a :c] (new-f-meta :c))) + + (let [partial-of-partial (partial new-f-meta :b)] + (is (= #{2 :rest} (.-arities partial-of-partial))) + (is (nil? (meta partial-of-partial))) + (is (= [:a :b :e :f '(:g :h)] (partial-of-partial :e :f :g :h)))))))) + (deftest reduce-test (testing "with no init" (are [coll f res] (= res (reduce f coll))