diff --git a/README.md b/README.md index d6f75d0..ac70f7e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ Pack an OpenSCAD entry file and all its `use`/`include` dependencies into a sing Instead of distributing a project as a directory tree of files (and requiring recipients to have the same libraries installed), OpenSCAD Packer bundles everything into one file. It uses **tree-shaking** to include only the functions and modules that are actually called — directly or transitively — so the output stays lean even when pulling from large libraries. +## Why? + +3D printing model sites such as [Printables](https://www.printables.com), [Thingiverse](https://www.thingiverse.com), and [MakerWorld](https://makerworld.com) support **parametric models**: the site parses an uploaded `.scad` file, exposes its configurable parameters to the user (via OpenSCAD's [Customizer](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Customizer)), and renders a download-ready 3MF or STL with the chosen values — no local OpenSCAD installation required. + +The catch: **these sites only accept a single `.scad` file**. A design that pulls in [BOSL2](https://github.com/BelfrySCAD/BOSL2), [NopSCADlib](https://github.com/nophead/NopSCADlib), or any other library cannot be uploaded as-is. The creator must somehow inline all dependencies into one file before uploading. + +OpenSCAD Packer automates that. It resolves every `use` and `include`, tree-shakes out the unused code, and writes a single self-contained file ready to upload. + +OpenSCAD's Customizer reads **magic comments** in the `.scad` source to build its parameter UI — for example, `width = 20; // [1:100]` creates a slider, and `/* [Section Name] */` creates a collapsible group. By default, OpenSCAD Packer preserves comments from the entry file so these annotations survive packing and the Customizer UI works as expected on the destination site. Pass `--no-preserve-comments` to strip them if you prefer a leaner output. + ## How it works - `use ` — the library is parsed and only the reachable function and module definitions are inlined. Variables and top-level calls are discarded (matching OpenSCAD's own `use` semantics). @@ -38,8 +48,10 @@ Arguments: INPUT Entry .scad file to pack. Options: - -o, --output PATH Output file. Defaults to stdout. - -L, --library-path PATH Extra library search directory (repeatable). + -o, --output PATH Output file. Defaults to stdout. + -L, --library-path PATH Extra library search directory (repeatable). + --preserve-comments/--no-preserve-comments + Preserve entry-file comments in output (default: on). --help ``` diff --git a/openscad_packer/cli.py b/openscad_packer/cli.py index 58d9f69..c754f4f 100644 --- a/openscad_packer/cli.py +++ b/openscad_packer/cli.py @@ -44,10 +44,20 @@ def cli() -> None: type=click.Path(exists=True, file_okay=False, readable=True), help="Extra library search path (may be repeated).", ) -def pack(input: str, output: str | None, library_paths: tuple[str, ...]) -> None: +@click.option( + "--preserve-comments/--no-preserve-comments", + default=True, + help="Preserve comments from the entry file in packed output (default: on).", +) +def pack( + input: str, + output: str | None, + library_paths: tuple[str, ...], + preserve_comments: bool, +) -> None: """Pack INPUT into a single self-contained OpenSCAD file.""" try: - result = Packer(input, list(library_paths)).pack() + result = Packer(input, list(library_paths), preserve_comments=preserve_comments).pack() except PackerError as exc: raise click.ClickException(str(exc)) from exc diff --git a/openscad_packer/packer.py b/openscad_packer/packer.py index 4f6a095..5092a19 100644 --- a/openscad_packer/packer.py +++ b/openscad_packer/packer.py @@ -15,11 +15,17 @@ from __future__ import annotations +import contextlib +import io import os +import sys -from openscad_parser.ast import getASTfromFile +from openscad_parser.ast import getASTfromFile, getASTfromString from openscad_parser.ast.nodes import ( ASTNode, + Assignment, + CommentLine, + CommentSpan, FunctionDeclaration, IncludeStatement, ModuleDeclaration, @@ -31,6 +37,128 @@ from .shaker import collect_called_names, compute_reachable +def _strip_expression_comments(code: str) -> str: + """Strip `//` line comments that appear inside `()` or `[]` expression contexts. + + Statement-level comments (including OpenSCAD Customizer magic annotations like + `// [min:max]` and section headers like `/* [Name] */`) are left untouched. + `/* */` block comments are always preserved regardless of nesting depth. + """ + result: list[str] = [] + i = 0 + depth = 0 # nesting inside ( and [ only; { is a scope, not an expression + + while i < len(code): + c = code[i] + + # String literal — skip its contents verbatim + if c == '"': + result.append(c) + i += 1 + while i < len(code): + c = code[i] + result.append(c) + if c == '\\': + i += 1 + if i < len(code): + result.append(code[i]) + elif c == '"': + break + i += 1 + i += 1 + continue + + # Block comment — always preserve + if c == '/' and i + 1 < len(code) and code[i + 1] == '*': + result.append(c) + i += 1 + result.append(code[i]) + i += 1 + while i < len(code): + if code[i] == '*' and i + 1 < len(code) and code[i + 1] == '/': + result.append(code[i]) + result.append(code[i + 1]) + i += 2 + break + result.append(code[i]) + i += 1 + continue + + # Line comment + if c == '/' and i + 1 < len(code) and code[i + 1] == '/': + if depth > 0: + # Inside an expression — strip to end of line (keep the newline) + while i < len(code) and code[i] != '\n': + i += 1 + else: + # Statement level — preserve + result.append(c) + i += 1 + continue + + # Track expression depth for ( and [ only + if c in '([': + depth += 1 + elif c in ')]': + depth = max(0, depth - 1) + + result.append(c) + i += 1 + + return ''.join(result) + + +def _merge_inline_comments(raw: str, body_nodes: list[ASTNode]) -> str: + """Merge CommentLine nodes that were trailing on the same source line as the + preceding statement back onto that line. + + The parser emits `var = val; // [min:max]` as two separate nodes (Assignment + + CommentLine) even though they shared one source line. to_openscad() renders them + on consecutive lines. This post-processor detects those pairs via position.line + equality, then merges the comment into the preceding output line. + + A multiset (Counter) of comment texts is used so duplicate annotation texts + (e.g. two variables both annotated `// [0:100]`) are each matched at most once. + """ + from collections import Counter + + # Pre-scan: collect comment texts that must be merged, preserving multiplicity. + inline_texts: Counter[str] = Counter() + prev: ASTNode | None = None + for node in body_nodes: + if ( + isinstance(node, CommentLine) + and prev is not None + and not isinstance(prev, (CommentLine, CommentSpan)) + ): + node_line = getattr(getattr(node, "position", None), "line", None) + prev_line = getattr(getattr(prev, "position", None), "line", None) + if node_line is not None and node_line == prev_line: + inline_texts[node.text] += 1 + prev = node + + if not inline_texts: + return raw + + remaining = Counter(inline_texts) + result: list[str] = [] + for line in raw.split("\n"): + stripped = line.strip() + if ( + stripped.startswith("//") + and result + and result[-1].rstrip().endswith(";") + ): + comment_text = stripped[2:] # text after the `//` + if remaining.get(comment_text, 0) > 0: + result[-1] = result[-1].rstrip() + f" //{comment_text}" + remaining[comment_text] -= 1 + continue + result.append(line) + + return "\n".join(result) + + class PackerError(Exception): pass @@ -38,9 +166,15 @@ class PackerError(Exception): class Packer: """Packs an OpenSCAD entry file and all its dependencies into one file.""" - def __init__(self, entry_file: str, library_paths: list[str]) -> None: + def __init__( + self, + entry_file: str, + library_paths: list[str], + preserve_comments: bool = True, + ) -> None: self.entry_file = os.path.abspath(entry_file) self.library_paths = [os.path.abspath(p) for p in library_paths] + self.preserve_comments = preserve_comments # Definition pool: name → list of nodes, insertion-ordered via _pool_order. # A name may have both a FunctionDeclaration and a ModuleDeclaration (OpenSCAD @@ -57,7 +191,7 @@ def __init__(self, entry_file: str, library_paths: list[str]) -> None: def pack(self) -> str: """Run both phases and return pretty-printed packed source.""" - nodes = getASTfromFile(self.entry_file, process_includes=False) or [] + nodes = self._parse_entry_file() self._process_file_nodes(nodes, self.entry_file) seed = collect_called_names(self._body_nodes) @@ -69,7 +203,46 @@ def pack(self) -> str: for defn in self._pool[n] ] - return to_openscad(used_defs + self._body_nodes) + # Put assignments/comments before library defs so Customizer sees them at + # the top of the file; leave geometry calls (module calls etc.) after defs. + # OpenSCAD's declarative semantics mean order doesn't affect rendering. + _is_header = (Assignment, CommentLine, CommentSpan) + header_nodes = [n for n in self._body_nodes if isinstance(n, _is_header)] + footer_nodes = [n for n in self._body_nodes if not isinstance(n, _is_header)] + + raw = to_openscad(header_nodes + used_defs + footer_nodes) + return _merge_inline_comments(raw, self._body_nodes) + + def _parse_entry_file(self) -> list[ASTNode]: + if not self.preserve_comments: + return getASTfromFile(self.entry_file, process_includes=False) or [] + + # Pre-process: strip `//` comments inside expressions so that statement-level + # magic comments (e.g. `// [0:100]`, `/* [Section] */`) survive the parse. + with open(self.entry_file, encoding="utf-8") as fh: + source = fh.read() + safe_source = _strip_expression_comments(source) + + _buf = io.StringIO() + with contextlib.redirect_stdout(_buf): + nodes = getASTfromString( + safe_source, + include_comments=True, + origin=self.entry_file, + ) + + if nodes is not None: + return nodes + + # Fallback: the pre-processed source still couldn't be parsed with comments. + nodes = getASTfromFile(self.entry_file, process_includes=False) or [] + if nodes: + print( + "Warning: comments could not be preserved; packing without comments. " + "Pass --no-preserve-comments to suppress this warning.", + file=sys.stderr, + ) + return nodes # ------------------------------------------------------------------ # Phase 1: collect diff --git a/tests/test_cli.py b/tests/test_cli.py index f226f8e..70155eb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -143,6 +143,30 @@ def test_nonexistent_library_path_dir_exits_nonzero(self, runner, tmp_path): assert result.exit_code != 0 +# --------------------------------------------------------------------------- +# pack — preserve-comments flag +# --------------------------------------------------------------------------- + +class TestPreserveCommentsFlag: + def test_comments_preserved_by_default(self, runner, tmp_path): + entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);") + result = runner.invoke(cli, ["pack", str(entry)]) + assert result.exit_code == 0 + assert "My comment" in result.output + + def test_preserve_comments_flag_explicit(self, runner, tmp_path): + entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);") + result = runner.invoke(cli, ["pack", str(entry), "--preserve-comments"]) + assert result.exit_code == 0 + assert "My comment" in result.output + + def test_no_preserve_comments_strips_comments(self, runner, tmp_path): + entry = write(tmp_path, "entry.scad", "// My comment\ncube(10);") + result = runner.invoke(cli, ["pack", str(entry), "--no-preserve-comments"]) + assert result.exit_code == 0 + assert "My comment" not in result.output + + # --------------------------------------------------------------------------- # pack — help # --------------------------------------------------------------------------- diff --git a/tests/test_packer.py b/tests/test_packer.py index 788cdb2..ebb3f1b 100644 --- a/tests/test_packer.py +++ b/tests/test_packer.py @@ -1,9 +1,11 @@ """Integration tests for openscad_packer.packer.""" from pathlib import Path +import sys + import pytest -from openscad_packer.packer import Packer, PackerError +from openscad_packer.packer import Packer, PackerError, _strip_expression_comments def write(directory: Path, name: str, content: str) -> Path: @@ -225,13 +227,23 @@ def test_output_contains_entry_body_nodes(self, tmp_path): assert "cube" in result assert "sphere" in result - def test_definitions_appear_before_body_nodes(self, tmp_path): + def test_assignments_appear_before_library_definitions(self, tmp_path): + # Parametric assignments go before library defs so Customizer picks them up. write(tmp_path, "lib.scad", "function foo(x) = x;") entry = write(tmp_path, "entry.scad", "use \ny = foo(1);") result = pack(entry) - func_pos = result.index("function foo") assign_pos = result.index("y = foo") - assert func_pos < assign_pos + func_pos = result.index("function foo") + assert assign_pos < func_pos + + def test_geometry_calls_appear_after_library_definitions(self, tmp_path): + # Module calls (geometry) must come after the defs they reference. + write(tmp_path, "lib.scad", "module box(s) { cube(s); }") + entry = write(tmp_path, "entry.scad", "use \nbox(5);") + result = pack(entry) + mod_pos = result.index("module box") + call_pos = result.index("box(5)") + assert mod_pos < call_pos def test_no_use_statements_in_output_for_resolved_libs(self, tmp_path): write(tmp_path, "lib.scad", "function foo(x) = x;") @@ -314,6 +326,166 @@ def test_mutual_recursion_smoke(self, tmp_path): assert "unrelated" not in result +# --------------------------------------------------------------------------- +# _strip_expression_comments unit tests +# --------------------------------------------------------------------------- + +class TestStripExpressionComments: + def test_statement_level_comment_preserved(self): + src = "x = 10; // [0:100]\n" + assert "// [0:100]" in _strip_expression_comments(src) + + def test_inline_expression_comment_stripped(self): + src = "cube([10, // width\n20]);\n" + result = _strip_expression_comments(src) + assert "// width" not in result + assert "cube" in result + + def test_block_comment_always_preserved(self): + src = "/* [Parameters] */\nx = 10;\n" + assert "/* [Parameters] */" in _strip_expression_comments(src) + + def test_block_comment_inside_expression_preserved(self): + src = "cube(/* note */ 10);\n" + assert "/* note */" in _strip_expression_comments(src) + + def test_string_containing_comment_marker_untouched(self): + src = 'echo("see // this"); // real comment\n' + result = _strip_expression_comments(src) + assert '"see // this"' in result + assert "// real comment" in result + + def test_nested_brackets_depth_tracked(self): + src = "x = foo([a, // inner\n b]); // outer\n" + result = _strip_expression_comments(src) + assert "// inner" not in result + assert "// outer" in result + + def test_no_comments_unchanged(self): + src = "x = 10;\ncube(x);\n" + assert _strip_expression_comments(src) == src + + def test_escaped_quote_inside_string_preserved(self): + # `\"` inside a string literal must be consumed as an escape, not treated as + # the closing quote — exercises the `c == '\\'` branch (lines 62-64). + src = 'echo("say \\"hello\\""); // real\n' + result = _strip_expression_comments(src) + assert '\\"hello\\"' in result + assert "// real" in result + + def test_unterminated_string_at_eof_handled(self): + # A string literal that reaches EOF without a closing `"` must not crash — + # exercises the `while i < len(code)` exit-without-break branch (58->68). + result = _strip_expression_comments('"unclosed') + assert '"unclosed' in result + + def test_unterminated_block_comment_at_eof_handled(self): + # A block comment that reaches EOF without `*/` must not crash — + # exercises the `while i < len(code)` exit-without-break branch (77->85). + result = _strip_expression_comments("/* unclosed") + assert "/* unclosed" in result + + +# --------------------------------------------------------------------------- +# Comment preservation +# --------------------------------------------------------------------------- + +class TestCommentPreservation: + def test_line_comment_preserved_by_default(self, tmp_path): + entry = write(tmp_path, "entry.scad", "// A line comment\ncube(10);") + result = pack(entry) + assert "// A line comment" in result + + def test_block_comment_preserved_by_default(self, tmp_path): + entry = write(tmp_path, "entry.scad", "/* A block comment */\ncube(10);") + result = pack(entry) + assert "A block comment" in result + + def test_magic_comment_range_inline_on_same_line(self, tmp_path): + # `// [1:100]` trailing a variable must appear on the same line for Customizer. + entry = write(tmp_path, "entry.scad", "width = 20; // [1:100]\ncube(width);") + result = pack(entry) + assert "width = 20; // [1:100]" in result + + def test_magic_comment_standalone_on_next_line_preserved(self, tmp_path): + # A standalone `// [min:max]` on its own line should stay on its own line. + entry = write(tmp_path, "entry.scad", "width = 20;\n// [1:100]\ncube(width);") + result = pack(entry) + assert "[1:100]" in result + + def test_magic_comment_section_header_preserved(self, tmp_path): + entry = write(tmp_path, "entry.scad", "/* [Parameters] */\nwidth = 20;\ncube(width);") + result = pack(entry) + assert "[Parameters]" in result + + def test_description_comment_not_merged_with_following_assignment(self, tmp_path): + # A description comment on the line BEFORE the assignment must stay standalone. + entry = write(tmp_path, "entry.scad", + "// Wall thickness, in mm\n" + "wall_thickness = 2; // [1:10]\n" + "cube(wall_thickness);") + result = pack(entry) + assert "// Wall thickness, in mm" in result + assert "wall_thickness = 2; // [1:10]" in result + + def test_comments_from_used_library_not_preserved(self, tmp_path): + write(tmp_path, "lib.scad", "// Library internal comment\nfunction foo(x) = x;") + entry = write(tmp_path, "entry.scad", "use \ny = foo(1);") + result = pack(entry) + assert "Library internal comment" not in result + + def test_no_preserve_comments_strips_entry_comments(self, tmp_path): + entry = write(tmp_path, "entry.scad", "// Should be stripped\ncube(10);") + result = Packer(str(entry), [], preserve_comments=False).pack() + assert "Should be stripped" not in result + + def test_inline_expression_comment_stripped_no_warning(self, tmp_path, capsys): + # Comments inside expressions are pre-stripped before parse, so no warning is emitted + # and the output is still correct. + entry = write(tmp_path, "entry.scad", + "cube([\n" + " 10, // width\n" + " 20 // height\n" + "]);") + result = pack(entry) + assert "cube" in result + captured = capsys.readouterr() + assert "warning" not in captured.err.lower() + + def test_fallback_warning_when_string_parse_fails(self, tmp_path, capsys, monkeypatch): + entry = write(tmp_path, "entry.scad", "// A comment\ncube(10);") + monkeypatch.setattr(sys.modules[Packer.__module__], "getASTfromString", lambda *a, **kw: None) + result = Packer(str(entry), []).pack() + assert "cube" in result + captured = capsys.readouterr() + assert "warning" in captured.err.lower() + + def test_fallback_no_warning_for_empty_file(self, tmp_path, capsys, monkeypatch): + # When getASTfromString fails AND the file is empty, the fallback also returns + # nothing — `if nodes:` is False so no warning is printed (branch 239->245). + entry = write(tmp_path, "entry.scad", "") + monkeypatch.setattr(sys.modules[Packer.__module__], "getASTfromString", lambda *a, **kw: None) + result = Packer(str(entry), []).pack() + assert result.strip() == "" + captured = capsys.readouterr() + assert "warning" not in captured.err.lower() + + def test_standalone_comment_after_semicolon_not_merged(self, tmp_path): + # When one assignment has an inline annotation (enters _merge_inline_comments) + # and a second assignment has a standalone comment on the next line, that + # standalone comment must stay separate even though it follows a `;` line — + # exercises the `remaining.get(comment_text, 0) == 0` branch (153->157). + entry = write(tmp_path, "entry.scad", + "width = 20; // [1:100]\n" + "height = 30;\n" + "// [5:50]\n" + "cube(width);") + result = pack(entry) + assert "width = 20; // [1:100]" in result + assert "height = 30; // [5:50]" not in result + assert "// [5:50]" in result + + # --------------------------------------------------------------------------- # Dual-namespace: same name defined as both function and module (BOSL pattern) # ---------------------------------------------------------------------------