diff --git a/openscad_packer/packer.py b/openscad_packer/packer.py index 0c3b811..4f6a095 100644 --- a/openscad_packer/packer.py +++ b/openscad_packer/packer.py @@ -42,9 +42,11 @@ def __init__(self, entry_file: str, library_paths: list[str]) -> None: self.entry_file = os.path.abspath(entry_file) self.library_paths = [os.path.abspath(p) for p in library_paths] - # Definition pool: name → node, insertion-ordered via _pool_order. - # Last-writer-wins for the value; first-encounter order preserved. - self._pool: dict[str, FunctionDeclaration | ModuleDeclaration] = {} + # Definition pool: name → list of nodes, insertion-ordered via _pool_order. + # A name may have both a FunctionDeclaration and a ModuleDeclaration (OpenSCAD + # dual-namespace pattern used by BOSL). Last-writer-wins per type; first-encounter + # order preserved across names. + self._pool: dict[str, list[FunctionDeclaration | ModuleDeclaration]] = {} self._pool_order: list[str] = [] # Non-definition top-level nodes (assignments, module calls, etc.) @@ -60,7 +62,12 @@ def pack(self) -> str: seed = collect_called_names(self._body_nodes) reachable = compute_reachable(seed, self._pool) - used_defs = [self._pool[n] for n in self._pool_order if n in reachable] + used_defs = [ + defn + for n in self._pool_order + if n in reachable + for defn in self._pool[n] + ] return to_openscad(used_defs + self._body_nodes) @@ -116,4 +123,9 @@ def _add_to_pool(self, node: FunctionDeclaration | ModuleDeclaration) -> None: name = node.name.name if name not in self._pool: self._pool_order.append(name) - self._pool[name] = node + self._pool[name] = [] + for i, existing in enumerate(self._pool[name]): + if type(existing) is type(node): + self._pool[name][i] = node + return + self._pool[name].append(node) diff --git a/openscad_packer/shaker.py b/openscad_packer/shaker.py index 0ca43b3..5f468e3 100644 --- a/openscad_packer/shaker.py +++ b/openscad_packer/shaker.py @@ -56,7 +56,7 @@ def _walk_node(node: object, found: set[str]) -> None: def compute_reachable( seed_names: set[str], - pool: dict[str, FunctionDeclaration | ModuleDeclaration], + pool: dict[str, list[FunctionDeclaration | ModuleDeclaration]], ) -> set[str]: """Fixed-point reachability from seed_names through the definition pool. @@ -69,7 +69,7 @@ def compute_reachable( reachable |= frontier next_frontier: set[str] = set() for name in frontier: - called = collect_called_names([pool[name]]) + called = collect_called_names(pool[name]) for callee in called: if callee in pool and callee not in reachable: next_frontier.add(callee) diff --git a/tests/test_packer.py b/tests/test_packer.py index d2d2300..788cdb2 100644 --- a/tests/test_packer.py +++ b/tests/test_packer.py @@ -312,3 +312,63 @@ def test_mutual_recursion_smoke(self, tmp_path): assert "is_even" in result assert "is_odd" in result assert "unrelated" not in result + + +# --------------------------------------------------------------------------- +# Dual-namespace: same name defined as both function and module (BOSL pattern) +# --------------------------------------------------------------------------- + +class TestDualNamespace: + def test_both_defs_emitted_when_called_as_module(self, tmp_path): + write(tmp_path, "lib.scad", + "module foo(x) { cube(x); } " + "function foo(x) = x + 1;") + entry = write(tmp_path, "entry.scad", "use \nfoo(5);") + result = pack(entry) + assert "module foo" in result + assert "function foo" in result + + def test_both_defs_emitted_when_called_as_function(self, tmp_path): + write(tmp_path, "lib.scad", + "module foo(x) { cube(x); } " + "function foo(x) = x + 1;") + entry = write(tmp_path, "entry.scad", "use \ny = foo(3);") + result = pack(entry) + assert "module foo" in result + assert "function foo" in result + + def test_neither_def_emitted_when_not_called(self, tmp_path): + write(tmp_path, "lib.scad", + "module foo(x) { cube(x); } " + "function foo(x) = x + 1;") + entry = write(tmp_path, "entry.scad", "use \ncube(10);") + result = pack(entry) + assert "foo" not in result + + def test_bosl_assertion_pattern(self, tmp_path): + write(tmp_path, "compat.scad", + "module assertion(succ, msg) { if (!succ) echo(str(\"ASSERT: \", msg)); }\n" + "function assertion(succ, msg) = succ ? true : undef;") + entry = write(tmp_path, "entry.scad", + "use \nassertion(true, \"ok\");") + result = pack(entry) + assert "module assertion" in result + assert "function assertion" in result + + def test_same_type_last_writer_wins(self, tmp_path): + write(tmp_path, "lib.scad", + "function foo(x) = x + 1;\n" + "function foo(x) = x * 99;") + entry = write(tmp_path, "entry.scad", "use \ny = foo(1);") + result = pack(entry) + assert "x * 99" in result + assert "x + 1" not in result + + def test_order_preserved_module_before_function(self, tmp_path): + write(tmp_path, "lib.scad", + "module assertion(succ, msg) { if (!succ) echo(msg); }\n" + "function assertion(succ, msg) = succ ? true : undef;") + entry = write(tmp_path, "entry.scad", + "use \nassertion(true, \"ok\");") + result = pack(entry) + assert result.index("module assertion") < result.index("function assertion") diff --git a/tests/test_shaker.py b/tests/test_shaker.py index 063866b..1c0a0c8 100644 --- a/tests/test_shaker.py +++ b/tests/test_shaker.py @@ -15,12 +15,20 @@ def parse(tmp_path: Path, content: str) -> list: def make_pool(tmp_path: Path, content: str) -> dict: - """Parse content and return a pool dict of all function/module declarations.""" + """Parse content and return a list-valued pool dict (mirrors _add_to_pool logic).""" nodes = parse(tmp_path, content) - pool = {} + pool: dict[str, list] = {} for node in nodes: if isinstance(node, (FunctionDeclaration, ModuleDeclaration)): - pool[node.name.name] = node + name = node.name.name + if name not in pool: + pool[name] = [] + for i, existing in enumerate(pool[name]): + if type(existing) is type(node): + pool[name][i] = node + break + else: + pool[name].append(node) return pool @@ -160,3 +168,20 @@ def test_deep_chain(self, tmp_path): ) reachable = compute_reachable({"a"}, pool) assert reachable == {"a", "b", "c", "d"} + + def test_dual_namespace_traverses_all_defs(self, tmp_path): + """When pool["foo"] holds both a module and a function, reachability must + traverse both — names called by either definition become reachable.""" + pool = make_pool( + tmp_path, + "module foo(x) { bar(x); } " + "function foo(x) = baz(x); " + "module bar(x) { cube(x); } " + "function baz(x) = x * 2; " + "function unused(x) = 0;" + ) + reachable = compute_reachable({"foo"}, pool) + assert "foo" in reachable + assert "bar" in reachable # called by module foo + assert "baz" in reachable # called by function foo + assert "unused" not in reachable