Skip to content

Commit 95ee3b0

Browse files
authored
Emit a compiler warning when a local symbol is unused (#314)
* Emit a compiler warning when a local symbol is unused * Fix test failures * Remove unused debug * Add tests for this warning
1 parent 57968b6 commit 95ee3b0

File tree

5 files changed

+239
-79
lines changed

5 files changed

+239
-79
lines changed

src/basilisp/core/__init__.lpy

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -445,12 +445,12 @@
445445

446446
(defn any?
447447
"Return true for any x."
448-
[x]
448+
[_]
449449
true)
450450

451451
(defn =
452452
"Return true if x and y are equal, otherwise false."
453-
([x] true)
453+
([_] true)
454454
([x & args]
455455
(if (seq (rest args))
456456
(if (basilisp.lang.runtime/equals x (first args))
@@ -465,7 +465,7 @@
465465

466466
(defn >
467467
"Return true if arguments are monotonically decreasing, otherwise false."
468-
([x] true)
468+
([_] true)
469469
([x & args]
470470
(if (seq (rest args))
471471
(if (operator/gt x (first args))
@@ -475,7 +475,7 @@
475475

476476
(defn >=
477477
"Return true if arguments are decreasing, otherwise false."
478-
([x] true)
478+
([_] true)
479479
([x & args]
480480
(if (seq (rest args))
481481
(if (operator/ge x (first args))
@@ -485,7 +485,7 @@
485485

486486
(defn <
487487
"Return true if arguments are monotonically increasing, otherwise false."
488-
([x] true)
488+
([_] true)
489489
([x & args]
490490
(if (seq (rest args))
491491
(if (operator/lt x (first args))
@@ -495,7 +495,7 @@
495495

496496
(defn <=
497497
"Return true if arguments are increasing, otherwise false."
498-
([x] true)
498+
([_] true)
499499
([x & args]
500500
(if (seq (rest args))
501501
(if (operator/le x (first args))
@@ -769,7 +769,7 @@
769769
(defn constantly
770770
"Returns a function that accepts any number of arguments and returns x."
771771
[x]
772-
(fn [& args] x))
772+
(fn [& ^:no-warn-when-unused args] x))
773773

774774
(defn reduce
775775
"Reduce coll by f.
@@ -1053,7 +1053,7 @@
10531053

10541054
(defmacro comment
10551055
"Ignore all the forms passed, returning nil."
1056-
[& forms]
1056+
[& ^:no-warn-when-unused forms]
10571057
nil)
10581058

10591059
(defmacro condp
@@ -1530,11 +1530,11 @@
15301530
"Return a map of all the mapped symbols in the namespace.
15311531

15321532
Includes the return values of ns-interns and ns-refers in one map."
1533-
[ns]
1534-
(let [current-ns *ns*]
1535-
(merge
1536-
(ns-interns current-ns)
1537-
(ns-refers current-ns))))
1533+
([] (ns-map *ns*))
1534+
([ns]
1535+
(merge
1536+
(ns-interns ns)
1537+
(ns-refers ns))))
15381538

15391539
(defn ns-resolve
15401540
"Return the Var which will be resolved by the symbol in the given namespace."

src/basilisp/lang/compiler.py

Lines changed: 119 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,17 @@
4848
from basilisp.lang.util import genname, munge
4949
from basilisp.util import Maybe, partition
5050

51+
# Compiler logging
5152
logger = logging.getLogger(__name__)
5253

5354
# Compiler options
5455
USE_VAR_INDIRECTION = "use_var_indirection"
5556
WARN_ON_SHADOWED_NAME = "warn_on_shadowed_name"
5657
WARN_ON_SHADOWED_VAR = "warn_on_shadowed_var"
58+
WARN_ON_UNUSED_NAMES = "warn_on_unused_names"
5759
WARN_ON_VAR_INDIRECTION = "warn_on_var_indirection"
5860

61+
# String constants used in generating code
5962
_BUILTINS_NS = "builtins"
6063
_CORE_NS = "basilisp.core"
6164
_DEFAULT_FN = "__lisp_expr__"
@@ -69,6 +72,7 @@
6972
_NS_VAR = "__NS"
7073
_LISP_NS_VAR = "*ns*"
7174

75+
# Special form symbols
7276
_AMPERSAND = sym.symbol("&")
7377
_CATCH = sym.symbol("catch")
7478
_DEF = sym.symbol("def")
@@ -104,16 +108,30 @@
104108
_UNQUOTE = sym.symbol("unquote", _CORE_NS)
105109
_UNQUOTE_SPLICING = sym.symbol("unquote-splicing", _CORE_NS)
106110

111+
# Symbols to be ignored for unused symbol warnings
112+
_IGNORED_SYM = sym.symbol("_")
113+
_MACRO_ENV_SYM = sym.symbol("&env")
114+
_MACRO_FORM_SYM = sym.symbol("&form")
115+
_NO_WARN_UNUSED_SYMS = lset.s(_IGNORED_SYM, _MACRO_ENV_SYM, _MACRO_FORM_SYM)
116+
117+
# Symbol table contexts
107118
_SYM_CTX_LOCAL_STARRED = kw.keyword(
108119
"local-starred", ns="basilisp.lang.compiler.var-context"
109120
)
110121
_SYM_CTX_LOCAL = kw.keyword("local", ns="basilisp.lang.compiler.var-context")
122+
_SYM_CTX_RECUR = kw.keyword("recur", ns="basilisp.lang.compiler.var-context")
123+
111124

112-
SymbolTableEntry = Tuple[str, kw.Keyword, sym.Symbol]
125+
class SymbolTableEntry(NamedTuple):
126+
munged: str
127+
context: kw.Keyword
128+
symbol: sym.Symbol
129+
used: bool = False
130+
warn_if_unused: bool = True
113131

114132

115133
class SymbolTable:
116-
CONTEXTS = frozenset([_SYM_CTX_LOCAL, _SYM_CTX_LOCAL_STARRED])
134+
CONTEXTS = frozenset([_SYM_CTX_LOCAL, _SYM_CTX_LOCAL_STARRED, _SYM_CTX_RECUR])
117135

118136
__slots__ = ("_name", "_parent", "_table", "_children")
119137

@@ -139,10 +157,18 @@ def __repr__(self):
139157
f"table={repr(self._table)}, children={len(self._children)})"
140158
)
141159

142-
def new_symbol(self, s: sym.Symbol, munged: str, ctx: kw.Keyword) -> "SymbolTable":
143-
if ctx not in SymbolTable.CONTEXTS:
144-
raise TypeError(f"Context {ctx} not a valid Symbol Context")
145-
self._table[s] = (munged, ctx, s)
160+
def new_symbol(
161+
self, s: sym.Symbol, munged: str, ctx: kw.Keyword, warn_if_unused: bool = True
162+
) -> "SymbolTable":
163+
assert ctx in SymbolTable.CONTEXTS, f"Symbol context {ctx} must be in CONTEXTS"
164+
if s in self._table:
165+
self._table[s] = self._table[s]._replace(
166+
munged=munged, context=ctx, symbol=s, warn_if_unused=warn_if_unused
167+
)
168+
else:
169+
self._table[s] = SymbolTableEntry(
170+
munged, ctx, s, warn_if_unused=warn_if_unused
171+
)
146172
return self
147173

148174
def find_symbol(self, s: sym.Symbol) -> Optional[SymbolTableEntry]:
@@ -152,6 +178,51 @@ def find_symbol(self, s: sym.Symbol) -> Optional[SymbolTableEntry]:
152178
return None
153179
return self._parent.find_symbol(s)
154180

181+
def mark_used(self, s: sym.Symbol) -> None:
182+
"""Mark the symbol s used in the current table or the first ancestor table
183+
which contains the symbol."""
184+
if s in self._table:
185+
old: SymbolTableEntry = self._table[s]
186+
if old.used:
187+
return
188+
self._table[s] = old._replace(used=True)
189+
elif self._parent is not None:
190+
self._parent.mark_used(s)
191+
else:
192+
assert False, f"Symbol {s} not defined in any symbol table"
193+
194+
def _warn_unused_names(self):
195+
"""Log a warning message for locally bound names whose values are not used
196+
by the time the symbol table frame is being popped off the stack.
197+
198+
The symbol table contains locally-bound symbols, recur point symbols, and
199+
symbols bound to var-args in generated Python functions. Only the locally-
200+
bound symbols are eligible for an unused warning, since it is not common
201+
that recur points will be used and user code is not permitted to directly
202+
access the var-args symbol (the compiler inserts an intermediate symbol
203+
which user code uses).
204+
205+
Warnings will not be issued for symbols named '_', '&form', and '&env'. The
206+
latter symbols appear in macros and a great many macros will never use them."""
207+
assert logger.isEnabledFor(
208+
logging.WARNING
209+
), "Only warn when logger is configured for WARNING level"
210+
ns = runtime.get_current_ns()
211+
for _, entry in self._table.items():
212+
if entry.context != _SYM_CTX_LOCAL:
213+
continue
214+
if entry.symbol in _NO_WARN_UNUSED_SYMS:
215+
continue
216+
if entry.warn_if_unused and not entry.used:
217+
code_loc = (
218+
Maybe(entry.symbol.meta)
219+
.map(lambda m: f": {m.entry(reader.READER_LINE_KW)}")
220+
.or_else_get("")
221+
)
222+
logger.warning(
223+
f"symbol '{entry.symbol}' defined but not used ({ns}{code_loc})"
224+
)
225+
155226
def append_frame(self, name: str, parent: "SymbolTable" = None) -> "SymbolTable":
156227
new_frame = SymbolTable(name, parent=parent)
157228
self._children[name] = new_frame
@@ -161,9 +232,14 @@ def pop_frame(self, name: str) -> None:
161232
del self._children[name]
162233

163234
@contextlib.contextmanager
164-
def new_frame(self, name):
235+
def new_frame(self, name, warn_on_unused_names):
236+
"""Context manager for creating a new stack frame. If warn_on_unused_names is
237+
True and the logger is enabled for WARNING, call _warn_unused_names() on the
238+
child SymbolTable before it is popped."""
165239
new_frame = self.append_frame(name, parent=self)
166240
yield new_frame
241+
if warn_on_unused_names and logger.isEnabledFor(logging.WARNING):
242+
new_frame._warn_unused_names()
167243
self.pop_frame(name)
168244

169245

@@ -215,6 +291,11 @@ def warn_on_shadowed_var(self) -> bool:
215291
WARN_ON_SHADOWED_VAR, False
216292
)
217293

294+
@property
295+
def warn_on_unused_names(self) -> bool:
296+
"""If True, warn when local names are unused."""
297+
return self._opts.entry(WARN_ON_UNUSED_NAMES, True)
298+
218299
@property
219300
def warn_on_var_indirection(self) -> bool:
220301
"""If True, warn when a Var reference cannot be direct linked (iff
@@ -260,7 +341,7 @@ def symbol_table(self) -> SymbolTable:
260341
@contextlib.contextmanager
261342
def new_symbol_table(self, name):
262343
old_st = self.symbol_table
263-
with old_st.new_frame(name) as st:
344+
with old_st.new_frame(name, self.warn_on_unused_names) as st:
264345
self._st.append(st)
265346
yield st
266347
self._st.pop()
@@ -503,6 +584,7 @@ def _meta_kwargs_ast( # pylint:disable=inconsistent-return-statements
503584
_SYM_DYNAMIC_META_KEY = kw.keyword("dynamic")
504585
_SYM_MACRO_META_KEY = kw.keyword("macro")
505586
_SYM_NO_WARN_ON_REDEF_META_KEY = kw.keyword("no-warn-on-redef")
587+
_SYM_NO_WARN_WHEN_UNUSED_META_KEY = kw.keyword("no-warn-when-unused")
506588
_SYM_REDEF_META_KEY = kw.keyword("redef")
507589

508590

@@ -542,6 +624,7 @@ def _new_symbol( # pylint: disable=too-many-arguments
542624
st: Optional[SymbolTable] = None,
543625
warn_on_shadowed_name: bool = True,
544626
warn_on_shadowed_var: bool = True,
627+
warn_if_unused: bool = True,
545628
):
546629
"""Add a new symbol to the symbol table.
547630
@@ -566,7 +649,9 @@ def _new_symbol( # pylint: disable=too-many-arguments
566649
if (warn_on_shadowed_name or warn_on_shadowed_var) and ctx.warn_on_shadowed_var:
567650
if ctx.current_ns.find(s) is not None:
568651
logger.warning(f"name '{s}' shadows def'ed Var from outer scope")
569-
st.new_symbol(s, munged, sym_ctx)
652+
if s.meta is not None and s.meta.entry(_SYM_NO_WARN_WHEN_UNUSED_META_KEY, None):
653+
warn_if_unused = False
654+
st.new_symbol(s, munged, sym_ctx, warn_if_unused=warn_if_unused)
570655

571656

572657
def _def_ast(ctx: CompilerContext, form: llist.List) -> ASTStream:
@@ -928,7 +1013,7 @@ def _single_arity_fn_ast(
9281013
with ctx.new_symbol_table(py_fn_name), ctx.new_recur_point(py_fn_name, fndef.first):
9291014
# Allow named anonymous functions to recursively call themselves
9301015
if name is not None:
931-
_new_symbol(ctx, name, py_fn_name, _SYM_CTX_LOCAL)
1016+
_new_symbol(ctx, name, py_fn_name, _SYM_CTX_RECUR, warn_if_unused=False)
9321017

9331018
args, body, vargs = _fn_args_body(ctx, fndef.first, fndef.rest)
9341019

@@ -997,7 +1082,7 @@ def __f_68(*multi_arity_args):
9971082
with ctx.new_recur_point(py_fn_name, arity.first):
9981083
# Allow named anonymous functions to recursively call themselves
9991084
if name is not None:
1000-
_new_symbol(ctx, name, py_fn_name, _SYM_CTX_LOCAL)
1085+
_new_symbol(ctx, name, py_fn_name, _SYM_CTX_RECUR, warn_if_unused=False)
10011086

10021087
has_rest = any([has_rest, is_rest])
10031088
arity_name = f"{py_fn_name}__arity{'_rest' if is_rest else arg_count}"
@@ -1352,17 +1437,17 @@ def let_32(a_33, b_34, c_35):
13521437

13531438
# Generate a function to hold the body of the let expression
13541439
letname = genname("let")
1355-
with ctx.new_symbol_table(letname):
1356-
# Suppress shadowing warnings below since the shadow warnings will be
1357-
# emitted by calling _new_symbol in the loop above
1358-
args, body, vargs = _fn_args_body(
1359-
ctx,
1360-
vec.vector(arg_syms.keys()),
1361-
runtime.nthrest(form, 2),
1362-
warn_on_shadowed_var=False,
1363-
warn_on_shadowed_name=False,
1364-
)
1365-
let_fn_body.append(_expressionize(body, letname, args=args, vargs=vargs))
1440+
1441+
# Suppress shadowing warnings below since the shadow warnings will be
1442+
# emitted by calling _new_symbol in the loop above
1443+
args, body, vargs = _fn_args_body(
1444+
ctx,
1445+
vec.vector(arg_syms.keys()),
1446+
runtime.nthrest(form, 2),
1447+
warn_on_shadowed_var=False,
1448+
warn_on_shadowed_name=False,
1449+
)
1450+
let_fn_body.append(_expressionize(body, letname, args=args, vargs=vargs))
13661451

13671452
# Generate local variable assignments for processing let bindings
13681453
var_names = seq(var_names).map(lambda n: ast.Name(id=n, ctx=ast.Store()))
@@ -1960,18 +2045,19 @@ def _sym_ast(ctx: CompilerContext, form: sym.Symbol) -> ASTStream:
19602045
return
19612046

19622047
# Look up local symbols (function parameters, let bindings, etc.)
1963-
st = ctx.symbol_table
1964-
st_sym = st.find_symbol(form)
1965-
1966-
if st_sym is not None:
1967-
munged, sym_ctx, _ = st_sym
1968-
assert munged is not None, f"Lisp symbol '{form}' not found in symbol table"
1969-
1970-
if sym_ctx == _SYM_CTX_LOCAL:
1971-
yield _node(ast.Name(id=munged, ctx=ast.Load()))
2048+
sym_entry = ctx.symbol_table.find_symbol(form)
2049+
if sym_entry is not None:
2050+
assert (
2051+
sym_entry.munged is not None
2052+
), f"Lisp symbol '{form}' not found in symbol table"
2053+
assert (
2054+
sym_entry.context != _SYM_CTX_LOCAL_STARRED
2055+
), "Direct access to varargs forbidden"
2056+
2057+
if sym_entry.context in {_SYM_CTX_LOCAL, _SYM_CTX_RECUR}:
2058+
ctx.symbol_table.mark_used(form)
2059+
yield _node(ast.Name(id=sym_entry.munged, ctx=ast.Load()))
19722060
return
1973-
elif sym_ctx == _SYM_CTX_LOCAL_STARRED:
1974-
raise CompilerException("Direct access to varargs forbidden")
19752061

19762062
# Resolve def'ed symbols, namespace aliases, imports, etc.
19772063
resolved = _resolve_sym(ctx, form)

src/basilisp/logconfig.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import logging
2+
import os
3+
4+
5+
def get_level() -> str:
6+
"""Get the default logging level for Basilisp."""
7+
return os.getenv("BASILISP_LOGGING_LEVEL", "WARNING")
8+
9+
10+
def get_handler(level: str, fmt: str) -> logging.Handler:
11+
"""Get the default logging handler for Basilisp."""
12+
handler: logging.Handler = logging.NullHandler()
13+
if os.getenv("BASILISP_USE_DEV_LOGGER") == "true":
14+
handler = logging.StreamHandler()
15+
16+
handler.setFormatter(logging.Formatter(fmt))
17+
handler.setLevel(level)
18+
return handler
19+
20+
21+
DEFAULT_FORMAT = (
22+
"%(asctime)s %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] - %(message)s"
23+
)
24+
DEFAULT_LEVEL = get_level()
25+
DEFAULT_HANDLER = get_handler(DEFAULT_LEVEL, DEFAULT_FORMAT)

0 commit comments

Comments
 (0)