Skip to content

Commit 8b3a2da

Browse files
authored
Warn at compile time on arity mismatch for function invocation (#842)
Add a compile-time warning for arity mismatches on function invocations. The warning is enabled by default (if the dev logger is enabled and configured for at least `WARNING` level logs). In order to facilitate this work, we also had to change the computation of the Basilisp function `arities` attribute set. Previously this included only fixed arities (the argument count for each non-variadic function arity) and a `:rest` keyword if the function included a variadic arity. Now `arities` will include all fixed arities including the number of fixed (non-variadic) arguments to the varidic arity. This allows for more sophisticated warnings. As part of this bill of work, it was also required to update `partial` to support computing a new set of `arities` for partial applications. `functools.wraps` copies all values from `__dict__` to wrapped functions, so partials were appearing to have the same set of arities as their wrapped function which was never correct. Fixes #671 Fixes #847
1 parent b4d9c2d commit 8b3a2da

File tree

12 files changed

+298
-32
lines changed

12 files changed

+298
-32
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## [Unreleased]
88
### Added
99
* Added filename metadata to compiler exceptions (#844)
10+
* Added a compile-time warning for attempting to call a function with an unsupported number of arguments (#671)
1011

1112
### Fixed
1213
* Fix a bug where `basilisp.lang.compiler.exception.CompilerException` would nearly always suppress line information in it's `data` map (#845)
14+
* 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)
1315

1416
## [v0.1.0b1]
1517
### Added

docs/compiler.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ Warnings
3535

3636
The following settings enable and disable warnings from the Basilisp compiler during compilation.
3737

38+
* ``warn-on-arity-mismatch`` - if ``true``, emit warnings if a Basilisp function invocation is detected with an unsupported number of arguments
39+
40+
* Environment Variable: ``BASILISP_WARN_ON_ARITY_MISMATCH``
41+
* Default: ``true``
42+
43+
3844
* ``warn-on-shadowed-name`` - if ``true``, emit warnings if a local name is shadowed by another local name
3945

4046
* Environment Variable: ``BASILISP_WARN_ON_SHADOWED_NAME``

src/basilisp/cli.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ def _add_compiler_arg_group(parser: argparse.ArgumentParser) -> None:
136136
f"{DEFAULT_COMPILER_OPTS['inline-functions']})"
137137
),
138138
)
139+
group.add_argument(
140+
"--warn-on-arity-mismatch",
141+
action="store",
142+
nargs="?",
143+
const=os.getenv("BASILISP_WARN_ON_ARITY_MISMATCH"),
144+
type=_to_bool,
145+
help=(
146+
"if true, emit warnings if a Basilisp function invocation is detected with "
147+
"an unsupported number of arguments "
148+
"(env: BASILISP_WARN_ON_ARITY_MISMATCH; default: "
149+
f"{DEFAULT_COMPILER_OPTS['warn-on-arity-mismatch']})"
150+
),
151+
)
139152
group.add_argument(
140153
"--warn-on-shadowed-name",
141154
action="store",

src/basilisp/contrib/pytest/testrunner.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ def pytest_collect_file(file_path: Path, path, parent):
3030
"""Primary PyTest hook to identify Basilisp test files."""
3131
if file_path.suffix == ".lpy":
3232
if file_path.name.startswith("test_") or file_path.stem.endswith("_test"):
33-
return BasilispFile.from_parent(parent, fspath=path, path=file_path)
33+
return BasilispFile.from_parent(parent, path=file_path)
3434
return None
3535

3636

src/basilisp/lang/compiler/__init__.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from basilisp.lang.compiler.analyzer import ( # noqa
1111
GENERATE_AUTO_INLINES,
1212
INLINE_FUNCTIONS,
13+
WARN_ON_ARITY_MISMATCH,
1314
WARN_ON_NON_DYNAMIC_SET,
1415
WARN_ON_SHADOWED_NAME,
1516
WARN_ON_SHADOWED_VAR,
@@ -32,6 +33,7 @@
3233
from basilisp.lang.compiler.optimizer import PythonASTOptimizer
3334
from basilisp.lang.typing import CompilerOpts, ReaderForm
3435
from basilisp.lang.util import genname
36+
from basilisp.util import Maybe
3537

3638
_DEFAULT_FN = "__lisp_expr__"
3739

@@ -97,6 +99,7 @@ def py_ast_optimizer(self) -> PythonASTOptimizer:
9799
def compiler_opts( # pylint: disable=too-many-arguments
98100
generate_auto_inlines: Optional[bool] = None,
99101
inline_functions: Optional[bool] = None,
102+
warn_on_arity_mismatch: Optional[bool] = None,
100103
warn_on_shadowed_name: Optional[bool] = None,
101104
warn_on_shadowed_var: Optional[bool] = None,
102105
warn_on_unused_names: Optional[bool] = None,
@@ -108,15 +111,16 @@ def compiler_opts( # pylint: disable=too-many-arguments
108111
return lmap.map(
109112
{
110113
# Analyzer options
111-
GENERATE_AUTO_INLINES: generate_auto_inlines or True,
112-
INLINE_FUNCTIONS: inline_functions or True,
113-
WARN_ON_SHADOWED_NAME: warn_on_shadowed_name or False,
114-
WARN_ON_SHADOWED_VAR: warn_on_shadowed_var or False,
115-
WARN_ON_UNUSED_NAMES: warn_on_unused_names or True,
116-
WARN_ON_NON_DYNAMIC_SET: warn_on_non_dynamic_set or True,
114+
GENERATE_AUTO_INLINES: Maybe(generate_auto_inlines).or_else_get(True),
115+
INLINE_FUNCTIONS: Maybe(inline_functions).or_else_get(True),
116+
WARN_ON_ARITY_MISMATCH: Maybe(warn_on_arity_mismatch).or_else_get(True),
117+
WARN_ON_SHADOWED_NAME: Maybe(warn_on_shadowed_name).or_else_get(False),
118+
WARN_ON_SHADOWED_VAR: Maybe(warn_on_shadowed_var).or_else_get(False),
119+
WARN_ON_UNUSED_NAMES: Maybe(warn_on_unused_names).or_else_get(True),
120+
WARN_ON_NON_DYNAMIC_SET: Maybe(warn_on_non_dynamic_set).or_else_get(True),
117121
# Generator options
118-
USE_VAR_INDIRECTION: use_var_indirection or False,
119-
WARN_ON_VAR_INDIRECTION: warn_on_var_indirection or True,
122+
USE_VAR_INDIRECTION: Maybe(use_var_indirection).or_else_get(False),
123+
WARN_ON_VAR_INDIRECTION: Maybe(warn_on_var_indirection).or_else_get(True),
120124
}
121125
)
122126

src/basilisp/lang/compiler/analyzer.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
LINE_KW,
5555
NAME_KW,
5656
NS_KW,
57+
REST_KW,
5758
SYM_ABSTRACT_META_KEY,
5859
SYM_ASYNC_META_KEY,
5960
SYM_CLASSMETHOD_META_KEY,
@@ -145,6 +146,7 @@
145146
# Analyzer options
146147
GENERATE_AUTO_INLINES = kw.keyword("generate-auto-inlines")
147148
INLINE_FUNCTIONS = kw.keyword("inline-functions")
149+
WARN_ON_ARITY_MISMATCH = kw.keyword("warn-on-arity-mismatch")
148150
WARN_ON_SHADOWED_NAME = kw.keyword("warn-on-shadowed-name")
149151
WARN_ON_SHADOWED_VAR = kw.keyword("warn-on-shadowed-var")
150152
WARN_ON_UNUSED_NAMES = kw.keyword("warn-on-unused-names")
@@ -362,6 +364,12 @@ def should_inline_functions(self) -> bool:
362364
"""If True, function calls may be inlined if an inline def is provided."""
363365
return self._opts.val_at(INLINE_FUNCTIONS, True)
364366

367+
@property
368+
def warn_on_arity_mismatch(self) -> bool:
369+
"""If True, warn when a Basilisp function invocation is detected with an
370+
unsupported number of arguments."""
371+
return self._opts.val_at(WARN_ON_ARITY_MISMATCH, True)
372+
365373
@property
366374
def warn_on_unused_names(self) -> bool:
367375
"""If True, warn when local names are unused."""
@@ -2456,6 +2464,40 @@ def __handle_macroexpanded_ast(
24562464
)
24572465

24582466

2467+
def _do_warn_on_arity_mismatch(
2468+
fn: VarRef, form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext
2469+
) -> None:
2470+
if ctx.warn_on_arity_mismatch and getattr(fn.var.value, "_basilisp_fn", False):
2471+
arities: Optional[Tuple[Union[int, kw.Keyword]]] = getattr(
2472+
fn.var.value, "arities", None
2473+
)
2474+
if arities is not None:
2475+
has_variadic = REST_KW in arities
2476+
fixed_arities = set(filter(lambda v: v != REST_KW, arities))
2477+
max_fixed_arity = max(fixed_arities) if fixed_arities else None
2478+
# This count could be off by 1 for cases where kwargs are being passed,
2479+
# but only Basilisp functions intended to be called by Python code
2480+
# (e.g. with a :kwargs strategy) should ever be called with kwargs,
2481+
# so this seems unlikely enough.
2482+
num_args = runtime.count(form.rest)
2483+
if has_variadic and (max_fixed_arity is None or num_args > max_fixed_arity):
2484+
return
2485+
if num_args not in fixed_arities:
2486+
report_arities = cast(Set[Union[int, str]], set(fixed_arities))
2487+
if has_variadic:
2488+
report_arities.discard(cast(int, max_fixed_arity))
2489+
report_arities.add(f"{max_fixed_arity}+")
2490+
loc = (
2491+
f" ({fn.env.file}:{fn.env.line})"
2492+
if fn.env.line is not None
2493+
else f" ({fn.env.file})"
2494+
)
2495+
logger.warning(
2496+
f"calling function {fn.var}{loc} with {num_args} arguments; "
2497+
f"expected any of: {', '.join(sorted(map(str, report_arities)))}",
2498+
)
2499+
2500+
24592501
def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) -> Node:
24602502
with ctx.expr_pos():
24612503
fn = _analyze_form(form.first, ctx)
@@ -2492,6 +2534,8 @@ def _invoke_ast(form: Union[llist.PersistentList, ISeq], ctx: AnalyzerContext) -
24922534
phase=CompilerPhase.INLINING,
24932535
) from e
24942536

2537+
_do_warn_on_arity_mismatch(fn, form, ctx)
2538+
24952539
args, kwargs = _call_args_ast(form.rest, ctx)
24962540
return Invoke(
24972541
form=form,

src/basilisp/lang/compiler/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ class SpecialForm:
5252
SYM_TAG_META_KEY = kw.keyword("tag")
5353

5454
ARGLISTS_KW = kw.keyword("arglists")
55+
INTERFACE_KW = kw.keyword("interface")
56+
REST_KW = kw.keyword("rest")
5557
COL_KW = kw.keyword("col")
5658
DOC_KW = kw.keyword("doc")
5759
FILE_KW = kw.keyword("file")

src/basilisp/lang/compiler/generator.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
from basilisp.lang import vector as vec
4646
from basilisp.lang.compiler.constants import (
4747
DEFAULT_COMPILER_FILE_PATH,
48+
INTERFACE_KW,
49+
REST_KW,
4850
SYM_DYNAMIC_META_KEY,
4951
SYM_NO_WARN_ON_REDEF_META_KEY,
5052
SYM_REDEF_META_KEY,
@@ -132,10 +134,6 @@
132134
_TRY_PREFIX = "lisp_try"
133135
_NS_VAR = "_NS"
134136

135-
# Keyword constants used in generating code
136-
_INTERFACE_KW = kw.keyword("interface")
137-
_REST_KW = kw.keyword("rest")
138-
139137

140138
@attr.frozen
141139
class SymbolTableEntry:
@@ -209,7 +207,7 @@ class RecurType(Enum):
209207
class RecurPoint:
210208
loop_id: str
211209
type: RecurType
212-
binding_names: Optional[Collection[str]] = None
210+
binding_names: Optional[Iterable[str]] = None
213211
is_variadic: Optional[bool] = None
214212
has_recur: bool = False
215213

@@ -273,15 +271,15 @@ def has_var_indirection_override(self) -> bool:
273271
return False
274272

275273
@property
276-
def recur_point(self):
274+
def recur_point(self) -> RecurPoint:
277275
return self._recur_points[-1]
278276

279277
@contextlib.contextmanager
280278
def new_recur_point(
281279
self,
282280
loop_id: str,
283281
type_: RecurType,
284-
binding_names: Optional[Collection[str]] = None,
282+
binding_names: Optional[Iterable[str]] = None,
285283
is_variadic: Optional[bool] = None,
286284
):
287285
self._recur_points.append(
@@ -305,7 +303,7 @@ def new_symbol_table(self, name: str, is_context_boundary: bool = False):
305303
self._st.pop()
306304

307305
@property
308-
def current_this(self):
306+
def current_this(self) -> sym.Symbol:
309307
return self._this[-1]
310308

311309
@contextlib.contextmanager
@@ -1417,7 +1415,7 @@ def __deftype_or_reify_bases_to_py_ast(
14171415
ast.Call(
14181416
func=_NEW_KW_FN_NAME,
14191417
args=[
1420-
ast.Constant(hash(_INTERFACE_KW)),
1418+
ast.Constant(hash(INTERFACE_KW)),
14211419
ast.Constant("interface"),
14221420
],
14231421
keywords=[],
@@ -1695,7 +1693,7 @@ def __fn_decorator(
16951693
ast.Call(
16961694
func=_NEW_KW_FN_NAME,
16971695
args=[
1698-
ast.Constant(hash(_REST_KW)),
1696+
ast.Constant(hash(REST_KW)),
16991697
ast.Constant("rest"),
17001698
],
17011699
keywords=[],
@@ -1810,11 +1808,7 @@ def __single_arity_fn_to_py_ast( # pylint: disable=too-many-locals
18101808
meta_decorators,
18111809
[
18121810
__fn_decorator(
1813-
(
1814-
(len(fn_args),)
1815-
if not method.is_variadic
1816-
else ()
1817-
),
1811+
(len(fn_args),),
18181812
has_rest_arg=method.is_variadic,
18191813
)
18201814
],
@@ -1847,6 +1841,7 @@ def __multi_arity_dispatch_fn( # pylint: disable=too-many-arguments,too-many-lo
18471841
arity_map: Mapping[int, str],
18481842
return_tags: Iterable[Optional[Node]],
18491843
default_name: Optional[str] = None,
1844+
rest_arity_fixed_arity: Optional[int] = None,
18501845
max_fixed_arity: Optional[int] = None,
18511846
meta_node: Optional[MetaNode] = None,
18521847
is_async: bool = False,
@@ -2013,7 +2008,16 @@ def fn(*args):
20132008
meta_decorators,
20142009
[
20152010
__fn_decorator(
2016-
arity_map.keys(),
2011+
list(
2012+
chain(
2013+
arity_map.keys(),
2014+
(
2015+
[rest_arity_fixed_arity]
2016+
if rest_arity_fixed_arity is not None
2017+
else []
2018+
),
2019+
)
2020+
),
20172021
has_rest_arg=default_name is not None,
20182022
)
20192023
],
@@ -2046,6 +2050,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals
20462050

20472051
arity_to_name = {}
20482052
rest_arity_name: Optional[str] = None
2053+
rest_arity_fixed_arity: Optional[int] = None
20492054
fn_defs = []
20502055
all_arity_def_deps: List[ast.AST] = []
20512056
for arity in arities:
@@ -2054,6 +2059,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals
20542059
)
20552060
if arity.is_variadic:
20562061
rest_arity_name = arity_name
2062+
rest_arity_fixed_arity = arity.fixed_arity
20572063
else:
20582064
arity_to_name[arity.fixed_arity] = arity_name
20592065

@@ -2109,6 +2115,7 @@ def __multi_arity_fn_to_py_ast( # pylint: disable=too-many-locals
21092115
arity_to_name,
21102116
return_tags=[arity.tag for arity in arities],
21112117
default_name=rest_arity_name,
2118+
rest_arity_fixed_arity=rest_arity_fixed_arity,
21122119
max_fixed_arity=node.max_fixed_arity,
21132120
meta_node=meta_node,
21142121
is_async=node.is_async,
@@ -2567,6 +2574,7 @@ def __loop_recur_to_py_ast(
25672574
) -> GeneratedPyAST[ast.expr]:
25682575
"""Return a Python AST node for `recur` occurring inside a `loop`."""
25692576
assert node.op == NodeOp.RECUR
2577+
assert ctx.recur_point.binding_names is not None
25702578

25712579
recur_deps: List[ast.AST] = []
25722580
recur_targets: List[ast.Name] = []
@@ -3223,7 +3231,6 @@ def _interop_prop_to_py_ast(
32233231
assert node.op == NodeOp.HOST_FIELD
32243232

32253233
target_ast = gen_py_ast(ctx, node.target)
3226-
assert not target_ast.dependencies
32273234

32283235
return GeneratedPyAST(
32293236
node=ast.Attribute(

0 commit comments

Comments
 (0)