Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions openscad_packer/packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand All @@ -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)

Expand Down Expand Up @@ -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)
4 changes: 2 additions & 2 deletions openscad_packer/shaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions tests/test_packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <lib.scad>\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 <lib.scad>\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 <lib.scad>\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 <compat.scad>\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 <lib.scad>\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 <lib.scad>\nassertion(true, \"ok\");")
result = pack(entry)
assert result.index("module assertion") < result.index("function assertion")
31 changes: 28 additions & 3 deletions tests/test_shaker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Loading