Skip to content

Commit 705d486

Browse files
evhubclaude
andcommitted
Implement scope-aware undefined name detection (#889)
Replace flat module-level undefined name check with scope-aware tracking. Each scope now maintains an all_vars set with parent references, enabling the compiler to detect when a name defined in one function is incorrectly referenced from a sibling function or outer scope. Key changes: - Add all_vars set and parent reference to scope contexts - Introduce outer_setname parameter for names visible in parent scope (function defs, class defs, type aliases) - Move undefined name check from run_final_checks() to deferred checks during name_handle(), walking the scope chain at check time - Add py_bytes to coconut_specific_builtins (was missing) - Update tests for new error message formats and NOQA suppression Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6b4bf51 commit 705d486

File tree

5 files changed

+79
-65
lines changed

5 files changed

+79
-65
lines changed

coconut/compiler/compiler.py

Lines changed: 58 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -903,23 +903,23 @@ def bind(cls):
903903
cls.method("name_handle", assign=True, is_final=True),
904904
greedy=True,
905905
)
906-
cls.nonfinal_funcname <<= attach(
906+
cls.nonfinal_outer_setname <<= attach(
907907
cls.name_ref,
908-
cls.method("name_handle", assign=True, funcname=True),
908+
cls.method("name_handle", assign=True, outer_setname=True),
909909
)
910-
cls.final_funcname <<= attach(
910+
cls.final_outer_setname <<= attach(
911911
cls.final_setname_ref,
912-
cls.method("name_handle", assign=True, funcname=True, is_final=True),
912+
cls.method("name_handle", assign=True, outer_setname=True, is_final=True),
913913
greedy=True,
914914
)
915915
cls.nonfinal_classname <<= attach(
916916
cls.name_ref,
917-
cls.method("name_handle", assign=True, classname=True),
917+
cls.method("name_handle", assign=True, outer_setname=True, classname=True),
918918
greedy=True,
919919
)
920920
cls.final_classname <<= attach(
921921
cls.final_setname_ref,
922-
cls.method("name_handle", assign=True, classname=True, is_final=True),
922+
cls.method("name_handle", assign=True, outer_setname=True, classname=True, is_final=True),
923923
greedy=True,
924924
)
925925
cls.expr_setname <<= attach(
@@ -4072,7 +4072,7 @@ def ensure_module_or_create_fake(self, mod_name):
40724072
type_ignore=self.type_ignore_comment(),
40734073
)
40744074

4075-
def _make_import_stmt(self, imp_from, imp, imp_as, raw=False, lazy=False):
4075+
def make_import_stmt(self, imp_from, imp, imp_as, raw=False, lazy=False):
40764076
"""Generate an import statement."""
40774077
if not raw and imp != "*":
40784078
module_path = (imp if imp_from is None else imp_from).split(".", 1)
@@ -4089,7 +4089,7 @@ def _make_import_stmt(self, imp_from, imp, imp_as, raw=False, lazy=False):
40894089
raise _coconut.ImportError(_coconut.str(_coconut_imp_err))
40904090
""",
40914091
).format(
4092-
raw_import=self._make_import_stmt(imp_from, imp, imp_as, raw=True),
4092+
raw_import=self.make_import_stmt(imp_from, imp, imp_as, raw=True),
40934093
imp_name=imp_as if imp_as is not None else imp,
40944094
imp_lookup=".".join([existing_imp] + module_path[1:] + ([imp] if imp_from is not None else [])),
40954095
)
@@ -4135,14 +4135,14 @@ def single_import(self, loc, path, imp_as, type_ignore=False, lazy=False):
41354135

41364136
if imp_as is not None and "." in imp_as:
41374137
import_as_var = self.get_temp_var("import", loc)
4138-
out.append(self._make_import_stmt(imp_from, imp, import_as_var, lazy=lazy))
4138+
out.append(self.make_import_stmt(imp_from, imp, import_as_var, lazy=lazy))
41394139
fake_mods = imp_as.split(".")
41404140
for i in range(1, len(fake_mods)):
41414141
mod_name = ".".join(fake_mods[:i])
41424142
out.append(self.ensure_module_or_create_fake(mod_name))
41434143
out.append(".".join(fake_mods) + " = " + import_as_var)
41444144
else:
4145-
out.append(self._make_import_stmt(imp_from, imp, imp_as, lazy=lazy))
4145+
out.append(self.make_import_stmt(imp_from, imp, imp_as, lazy=lazy))
41464146

41474147
if type_ignore:
41484148
for i, line in enumerate(out):
@@ -4593,7 +4593,7 @@ def stmt_lambdef_handle(self, original, loc, tokens):
45934593

45944594
self.add_code_before[name] = self.decoratable_funcdef_stmt_handle(original, loc, [decorators, funcdef], is_async, is_stmt_lambda=True)
45954595

4596-
return self._handle_expr_scope_closure(name, loc)
4596+
return self.handle_expr_scope_closure(name, loc)
45974597

45984598
def match_comp_expr_handle(self, original, loc, tokens, dict_val=None):
45994599
"""Build a match comprehension by creating a temp match function.
@@ -4633,26 +4633,31 @@ def {func_name}({iter_var}):
46334633

46344634
self.add_code_before[func_name] = self.decoratable_funcdef_stmt_handle(original, loc, [funcdef], is_stmt_lambda=True)
46354635

4636-
func_expr = self._handle_expr_scope_closure(func_name, loc)
4636+
func_expr = self.handle_expr_scope_closure(func_name, loc)
46374637

46384638
if dict_val is not None:
46394639
return "_coconut.dict((" + func_expr + "(" + iter_var + ") for " + iter_var + " in " + iterable + "))"
46404640
else:
46414641
return func_expr + "(" + iter_var + ") for " + iter_var + " in " + iterable
46424642

4643-
def _handle_expr_scope_closure(self, name, loc):
4643+
def get_parent_expr_setnames(self):
4644+
"""Get all expr_setnames in parent contexts, but not the current context."""
4645+
expr_setname_context = self.current_parsing_context("expr_setnames")
4646+
parent_context = expr_setname_context["parent"]
4647+
parent_setnames = set()
4648+
while parent_context:
4649+
parent_setnames |= parent_context["new_names"]
4650+
parent_context = parent_context["parent"]
4651+
return parent_setnames
4652+
4653+
def handle_expr_scope_closure(self, name, loc):
46444654
"""Extracts the definition of a name to a separate function that closes on all local expr setnames."""
46454655
expr_setname_context = self.current_parsing_context("expr_setnames")
46464656
if expr_setname_context is None:
46474657
return name
46484658
else:
46494659
builder_name = self.get_temp_var("lambda_builder", loc)
4650-
4651-
parent_context = expr_setname_context["parent"]
4652-
parent_setnames = set()
4653-
while parent_context:
4654-
parent_setnames |= parent_context["new_names"]
4655-
parent_context = parent_context["parent"]
4660+
parent_setnames = self.get_parent_expr_setnames()
46564661

46574662
def stmt_lambdef_callback():
46584663
expr_setnames = parent_setnames | expr_setname_context["new_names"]
@@ -5583,12 +5588,14 @@ def has_expr_setname_manage(self, original, loc, item):
55835588
):
55845589
yield
55855590

5586-
def name_handle(self, original, loc, tokens, assign=False, classname=False, funcname=False, expr_setname=False, is_final=False):
5591+
def name_handle(self, original, loc, tokens, assign=False, outer_setname=False, expr_setname=False, classname=False, is_final=False):
55875592
"""Handle the given base name."""
5588-
if classname or funcname or expr_setname:
5589-
internal_assert(assign, "classname/funcname/expr_setname should always imply assign", (classname, funcname, expr_setname, assign))
5593+
if classname:
5594+
internal_assert(outer_setname and not expr_setname, "classname should always imply outer_setname", tokens)
5595+
if outer_setname or expr_setname:
5596+
internal_assert(assign, "classname/funcname/expr_setname should always imply assign", tokens)
55905597
if is_final:
5591-
internal_assert(assign and not expr_setname, "only setnames should ever be final", (assign, is_final))
5598+
internal_assert(assign and not expr_setname, "only setnames should ever be final", tokens)
55925599

55935600
name, = tokens
55945601

@@ -5609,24 +5616,6 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
56095616
always_wrap=is_greedy,
56105617
)
56115618

5612-
# register non-mid-expression variable assignments inside of where statements for later mangling
5613-
if assign and not expr_setname:
5614-
where_context = self.current_parsing_context("where")
5615-
if where_context is not None:
5616-
where_assigns = where_context["assigns"]
5617-
if where_assigns is not None:
5618-
where_assigns.add(name)
5619-
5620-
if classname:
5621-
cls_context = self.current_parsing_context("class")
5622-
self.internal_assert(cls_context is not None, original, loc, "found classname outside of class", tokens)
5623-
cls_context["name"] = name
5624-
5625-
if expr_setname:
5626-
expr_setnames_context = self.current_parsing_context("expr_setnames")
5627-
self.internal_assert(expr_setnames_context is not None, original, loc, "found expr_setname outside of has_expr_setname_manage", tokens)
5628-
expr_setnames_context["new_names"].add(name)
5629-
56305619
if not escaped:
56315620
typevar_info = self.current_parsing_context("typevars")
56325621
if typevar_info is not None:
@@ -5647,6 +5636,25 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
56475636
# note that this is the one case where we return early that isn't an error
56485637
return typevars[name]
56495638

5639+
# register non-mid-expression variable assignments inside of where statements for later mangling
5640+
if assign and not expr_setname:
5641+
where_context = self.current_parsing_context("where")
5642+
if where_context is not None:
5643+
where_assigns = where_context["assigns"]
5644+
if where_assigns is not None:
5645+
where_assigns.add(name)
5646+
5647+
cls_context = self.current_parsing_context("class")
5648+
expr_setnames_context = self.current_parsing_context("expr_setnames")
5649+
5650+
if classname:
5651+
self.internal_assert(cls_context is not None, original, loc, "found classname outside of class", tokens)
5652+
cls_context["name"] = name
5653+
5654+
if expr_setname:
5655+
self.internal_assert(expr_setnames_context is not None, original, loc, "found expr_setname outside of has_expr_setname_manage", tokens)
5656+
expr_setnames_context["new_names"].add(name)
5657+
56505658
scope = self.current_parsing_context("scope")
56515659
self.internal_assert(scope is not None, original, loc, "no scope context")
56525660

@@ -5656,12 +5664,12 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
56565664
is_new = loc not in self.name_info[name]["assigned"]
56575665
self.name_info[name]["assigned"].add(loc)
56585666
if (
5659-
(classname or funcname)
5667+
outer_setname
56605668
and scope["parent"] is not None
56615669
and scope["parent"]["all_vars"] is not None
56625670
):
56635671
scope["parent"]["all_vars"].add(name)
5664-
elif scope["all_vars"] is not None:
5672+
elif not expr_setname and scope["all_vars"] is not None:
56655673
scope["all_vars"].add(name)
56665674
else:
56675675
is_new = loc not in self.name_info[name]["referenced"]
@@ -5716,7 +5724,7 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
57165724

57175725
is_class_attr = (
57185726
assign
5719-
and self.current_parsing_context("class")
5727+
and cls_context
57205728
and not self.in_method
57215729
and (
57225730
# for classnames, we need special handling for nested classes
@@ -5758,7 +5766,7 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
57585766
# the import statement itself, not an assignment shadowing an import
57595767
if any(not same_line(original, loc, imp_loc) for imp_loc in self.name_info[name]["imported"]):
57605768
err = self.strict_err_or_warn(
5761-
"assignment shadows import '{name}' (use explicit '\\{name}' syntax when purposefully redefining imported names)".format(name=name),
5769+
"assignment shadows imported '{name}' (use explicit '\\{name}' syntax when purposefully redefining imported names)".format(name=name),
57625770
original,
57635771
loc,
57645772
raise_err_func=local_raise_or_wrap_error,
@@ -5774,6 +5782,11 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
57745782
and not self.star_import
57755783
and scope["all_vars"] is not None
57765784
and name not in all_builtins
5785+
and name != wildcard
5786+
and (expr_setnames_context is None or (
5787+
name not in expr_setnames_context["new_names"]
5788+
and name not in self.get_parent_expr_setnames()
5789+
))
57775790
):
57785791
self.final_checks.append(partial(self.check_undefined_name, original, loc, name, scope, self.outer_ln))
57795792

@@ -5806,7 +5819,6 @@ def name_handle(self, original, loc, tokens, assign=False, classname=False, func
58065819
return "_coconut_exec"
58075820
elif not assign and name in super_names and not self.target.startswith("3"):
58085821
if self.in_method:
5809-
cls_context = self.current_parsing_context("class")
58105822
enclosing_cls = cls_context["name_prefix"] + cls_context["name"]
58115823
return self.add_code_before_marker_with_replacement(name, "__class__ = " + enclosing_cls + "\n", add_spaces=False)
58125824
else:

coconut/compiler/grammar.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -861,22 +861,23 @@ class Grammar(object):
861861
expr_setname = Forward()
862862
final_setname = Forward()
863863
nonfinal_setname = Forward()
864+
# classname and outer_setname define their vars in the outer scope
864865
final_classname = Forward()
865866
nonfinal_classname = Forward()
866-
final_funcname = Forward()
867-
nonfinal_funcname = Forward()
867+
final_outer_setname = Forward()
868+
nonfinal_outer_setname = Forward()
868869

869870
name_ref = combine(Optional(backslash) + base_name)
870871
final_setname_ref = keyword("final").suppress() + name_ref
871872
setname = final_setname | nonfinal_setname
872873
classname = final_classname | nonfinal_classname
873-
funcname = final_funcname | nonfinal_funcname
874+
outer_setname = final_outer_setname | nonfinal_outer_setname
874875
unsafe_name = combine(Optional(backslash.suppress()) + base_name)
875876

876877
# use unsafe_name for dotted components since name should only be used for base names
877878
dotted_refname = condense(refname + ZeroOrMore(dot + unsafe_name))
878879
dotted_setname = condense(setname + ZeroOrMore(dot + unsafe_name))
879-
dotted_funcname = condense(funcname + ZeroOrMore(dot + unsafe_name))
880+
dotted_outer_setname = condense(outer_setname + ZeroOrMore(dot + unsafe_name))
880881
unsafe_dotted_name = condense(unsafe_name + ZeroOrMore(dot + unsafe_name))
881882
must_be_dotted_name = condense(refname + OneOrMore(dot + unsafe_name))
882883

@@ -1570,7 +1571,7 @@ class Grammar(object):
15701571
type_params = Group(lbrack.suppress() + tokenlist(type_param, comma) + rbrack.suppress())
15711572

15721573
type_alias_stmt = Forward()
1573-
type_alias_stmt_ref = keyword("type").suppress() + setname + Optional(type_params) + equals.suppress() + typedef_test
1574+
type_alias_stmt_ref = keyword("type").suppress() + outer_setname + Optional(type_params) + equals.suppress() + typedef_test
15741575

15751576
await_expr = Forward()
15761577
await_expr_ref = keyword("await").suppress() + atom_item
@@ -2314,7 +2315,7 @@ class Grammar(object):
23142315
with_stmt = Forward()
23152316

23162317
funcname_typeparams = Forward()
2317-
funcname_typeparams_tokens = dotted_funcname + Optional(type_params)
2318+
funcname_typeparams_tokens = dotted_outer_setname + Optional(type_params)
23182319
name_funcdef = condense(funcname_typeparams + parameters)
23192320
op_tfpdef = unsafe_typedef_default | condense(setname + Optional(default))
23202321
op_funcdef_arg = setname | condense(lparen.suppress() + op_tfpdef + rparen.suppress())

coconut/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ def get_path_env_var(env_var, default):
845845
"and_then",
846846
"and_then_await",
847847
"async_map",
848+
"py_bytes",
848849
"py_chr",
849850
"py_dict",
850851
"py_hex",

coconut/tests/src/cocotest/agnostic/primary_1.coco

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -445,10 +445,10 @@ def primary_test_1() -> bool:
445445
price = 100
446446
assert 0 == (requested_quantity ?? default_quantity) * price
447447
assert range(10) |> .[1] .. .[1:] == 2 == range(10) |> .[1:] |> .[1]
448-
assert None?.herp(derp) is None # type: ignore
449-
assert None?[herp].derp is None # type: ignore
450-
assert None?(derp)[herp] is None # type: ignore
451-
assert None?$(herp)(derp) is None # type: ignore
448+
assert None?.herp(derp) is None # type: ignore # NOQA
449+
assert None?[herp].derp is None # type: ignore # NOQA
450+
assert None?(derp)[herp] is None # type: ignore # NOQA
451+
assert None?$(herp)(derp) is None # type: ignore # NOQA
452452
a: int[]? = None # type: ignore
453453
assert a is None
454454
assert range(5) |> iter |> reiterable |> .[1] == 1
@@ -962,7 +962,7 @@ def primary_test_1() -> bool:
962962
def ret_abc():
963963
return "abc"
964964
)
965-
assert ret_abc() == "abc"
965+
assert ret_abc() == "abc" # NOQA
966966
assert """" """ == '" '
967967
assert "" == """"""
968968
assert (,)(*(1, 2), 3) == (1, 2, 3)

coconut/tests/src/extras.coco

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -490,28 +490,28 @@ line 6''')
490490
assert_raises(-> parse(d"""
491491
import abc
492492
abc: int = 1
493-
"""), CoconutStyleError, err_has="shadows imported name")
493+
"""), CoconutStyleError, err_has="shadows import")
494494
assert_raises(-> parse(d"""
495495
from os import path
496496
path = 'test'
497-
"""), CoconutStyleError, err_has="shadows imported name")
497+
"""), CoconutStyleError, err_has="shadows import")
498498
assert_raises(-> parse(d"""
499499
from collections import OrderedDict
500500
def OrderedDict() = None
501-
"""), CoconutStyleError, err_has="shadows imported name")
501+
"""), CoconutStyleError, err_has="shadows import")
502502
assert_raises(-> parse(d"""
503503
import abc
504504
class abc:
505505
pass
506-
"""), CoconutStyleError, err_has="shadows imported name")
506+
"""), CoconutStyleError, err_has="shadows import")
507507
assert_raises(-> parse(d"""
508508
from os import path
509509
[path for path in []]
510-
"""), CoconutStyleError, err_has="shadows imported name")
510+
"""), CoconutStyleError, err_has="shadows import")
511511
assert_raises(-> parse(d"""
512512
import abc
513513
final abc = 1
514-
"""), CoconutStyleError, err_has="shadows imported name")
514+
"""), CoconutStyleError, err_has="shadows import")
515515
# class attribute shadowing should be allowed (not module-level shadowing)
516516
# (use NOQA to suppress unused import warning since we're intentionally shadowing)
517517
assert parse(d"""
@@ -531,7 +531,7 @@ line 6''')
531531
class Foo:
532532
def bar(self):
533533
abc = 1
534-
"""), CoconutStyleError, err_has="shadows imported name")
534+
"""), CoconutStyleError, err_has="shadows import")
535535
assert_raises(-> parse(d"""
536536
class Foo:
537537
def bar(self):
@@ -575,7 +575,7 @@ line 6''')
575575
def foo(self):
576576
class abc:
577577
pass
578-
"""), CoconutStyleError, err_has="shadows imported name")
578+
"""), CoconutStyleError, err_has="shadows import")
579579
# deeply nested: class inside class inside method - no error because super is a class attribute of B
580580
assert parse(d"""
581581
class A:

0 commit comments

Comments
 (0)