openscad-parser 2.6.1__tar.gz → 3.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openscad_parser
3
- Version: 2.6.1
3
+ Version: 3.1.0
4
4
  Summary: A PEG parser to read OpenSCAD language source code, with optional AST tree generation.
5
5
  Keywords: openscad,openscad parser,parser
6
6
  Author: Revar Desmera
@@ -184,12 +184,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
184
184
  The function searches for library files in this order:
185
185
 
186
186
  1. Directory of the current file (if provided)
187
- 2. Directories in the ``OPENSCADPATH`` environment variable
188
- 3. Platform-specific default library directories:
189
- - Windows: ``~/Documents/OpenSCAD/libraries``
187
+ 2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
188
+ directories below rather than replacing them, as in OpenSCAD
189
+ 3. The platform's libraries folder:
190
+
191
+ - Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
192
+ (under OneDrive when Known Folder Move is on)
190
193
  - macOS: ``~/Documents/OpenSCAD/libraries``
191
194
  - Linux: ``~/.local/share/OpenSCAD/libraries``
192
195
 
196
+ 4. A ``libraries`` directory beside the installed package
197
+
198
+ ``librarySearchDirs(currfile)`` returns this list, and a not-found error names
199
+ every directory in it.
200
+
201
+ Strict Commas
202
+ ~~~~~~~~~~~~~
203
+
204
+ OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
205
+ it rejected one after a call's last argument (``cube(1,)``) and after the last
206
+ assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
207
+ accepting it in list literals, list comprehensions and parameter lists. To
208
+ parse as 2021.01 did:
209
+
210
+ .. code-block:: python
211
+
212
+ from openscad_parser import strict_commas
213
+
214
+ with strict_commas():
215
+ ast = getASTfromFile("model.scad") # None if it uses a rejected comma
216
+
217
+ The mode nests, restores on exit, and every AST cache keys on it, so a strict
218
+ parse is never served a lenient tree.
219
+
193
220
  Advanced AST Generation
194
221
  ~~~~~~~~~~~~~~~~~~~~~~~~
195
222
 
@@ -432,6 +459,7 @@ Expressions
432
459
  - ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
433
460
  - ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
434
461
  - ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
462
+ - ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
435
463
  - ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
436
464
  - ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
437
465
 
@@ -535,7 +563,7 @@ Main Functions
535
563
 
536
564
  ``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
537
565
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
538
- Searches in: current file directory, OPENSCADPATH, and platform default paths.
566
+ Searches the directories ``librarySearchDirs(currfile)`` returns.
539
567
 
540
568
  :param currfile: Full path to the current OpenSCAD file (can be empty string)
541
569
  :param libfile: Partial or full path to the library file to find
@@ -565,11 +593,16 @@ Main Functions
565
593
  This function removes all cached AST trees from memory.
566
594
 
567
595
  ``build_scopes(ast: list[ASTNode]) -> Scope``
568
- Build a scope tree over an AST and attach a ``scope`` attribute to every node.
596
+ Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
597
+ hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
569
598
 
570
599
  :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
571
600
  :returns: The root ``Scope`` object
572
601
 
602
+ ``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
603
+ The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
604
+ ``use``\ d file, say) can be read back through one table.
605
+
573
606
  ``Scope``
574
607
  Represents a lexical scope with three independent namespaces (variables, functions,
575
608
  modules), mirroring OpenSCAD's scoping rules.
@@ -1006,14 +1039,21 @@ names according to OpenSCAD's three-namespace scoping rules::
1006
1039
  print(root_scope.lookup_variable("x")) # Assignment node
1007
1040
  print(root_scope.lookup_module("box")) # ModuleDeclaration node
1008
1041
 
1009
- # Each AST node has a .scope attribute pointing to its enclosing scope
1042
+ # Every node's enclosing scope, as this pass built it
1010
1043
  box_decl = ast[1]
1011
1044
  cube_call = box_decl.children[0]
1012
- print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
1013
-
1014
- ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
1015
- to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
1016
- scopes automatically. Declarations (variables, functions, modules) inside a block are
1045
+ print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
1046
+
1047
+ ``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
1048
+ in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
1049
+ enclosing scopes automatically.
1050
+
1051
+ A node's scope is not an attribute of the node. An included file is parsed once and its
1052
+ nodes are shared by every file that includes it, but ``include`` puts them in the
1053
+ *includer's* scope, so the same node sits in a different scope for each includer. Each
1054
+ ``build_scopes()`` call therefore has its own table, and two files that include the same
1055
+ library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
1056
+ scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
1017
1057
  hoisted to the top of that block's scope before child nodes are visited.
1018
1058
 
1019
1059
  Testing
@@ -141,12 +141,39 @@ Use ``getASTfromLibraryFile()`` to find and parse library files using OpenSCAD's
141
141
  The function searches for library files in this order:
142
142
 
143
143
  1. Directory of the current file (if provided)
144
- 2. Directories in the ``OPENSCADPATH`` environment variable
145
- 3. Platform-specific default library directories:
146
- - Windows: ``~/Documents/OpenSCAD/libraries``
144
+ 2. Directories in the ``OPENSCADPATH`` environment variable, which add to the
145
+ directories below rather than replacing them, as in OpenSCAD
146
+ 3. The platform's libraries folder:
147
+
148
+ - Windows: ``OpenSCAD\libraries`` in the Documents folder Windows reports
149
+ (under OneDrive when Known Folder Move is on)
147
150
  - macOS: ``~/Documents/OpenSCAD/libraries``
148
151
  - Linux: ``~/.local/share/OpenSCAD/libraries``
149
152
 
153
+ 4. A ``libraries`` directory beside the installed package
154
+
155
+ ``librarySearchDirs(currfile)`` returns this list, and a not-found error names
156
+ every directory in it.
157
+
158
+ Strict Commas
159
+ ~~~~~~~~~~~~~
160
+
161
+ OpenSCAD accepts a trailing comma almost everywhere. OpenSCAD 2021.01 did not:
162
+ it rejected one after a call's last argument (``cube(1,)``) and after the last
163
+ assignment of a ``let``/``for``/``intersection_for`` (``let(x=1,)``), while
164
+ accepting it in list literals, list comprehensions and parameter lists. To
165
+ parse as 2021.01 did:
166
+
167
+ .. code-block:: python
168
+
169
+ from openscad_parser import strict_commas
170
+
171
+ with strict_commas():
172
+ ast = getASTfromFile("model.scad") # None if it uses a rejected comma
173
+
174
+ The mode nests, restores on exit, and every AST cache keys on it, so a strict
175
+ parse is never served a lenient tree.
176
+
150
177
  Advanced AST Generation
151
178
  ~~~~~~~~~~~~~~~~~~~~~~~~
152
179
 
@@ -389,6 +416,7 @@ Expressions
389
416
  - ``AssertOp(arguments: list[Argument], body: Expression)``: assert clause ``assert(arguments) body``
390
417
  - ``FunctionLiteral(parameters: list[ParameterDeclaration], body: Expression)``: Anonymous function expression ``function(parameters) body``
391
418
  - ``PrimaryCall(left: Expression, arguments: list[Argument])``: Function calls ``left(arguments)``
419
+ - ``RenderExpression(arguments: list[Argument], children: list[ModuleInstantiation])``: Geometry measured as a value ``render(arguments) { children }``. Braces required; ``render`` stays usable as an ordinary name. Not part of upstream OpenSCAD
392
420
  - ``PrimaryIndex(left: Expression, index: Expression)``: Array indexing ``left[index]``
393
421
  - ``PrimaryMember(left: Expression, member: Identifier)``: Member access ``left.member``
394
422
 
@@ -492,7 +520,7 @@ Main Functions
492
520
 
493
521
  ``getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool = False, process_includes: bool = True)``
494
522
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules.
495
- Searches in: current file directory, OPENSCADPATH, and platform default paths.
523
+ Searches the directories ``librarySearchDirs(currfile)`` returns.
496
524
 
497
525
  :param currfile: Full path to the current OpenSCAD file (can be empty string)
498
526
  :param libfile: Partial or full path to the library file to find
@@ -522,11 +550,16 @@ Main Functions
522
550
  This function removes all cached AST trees from memory.
523
551
 
524
552
  ``build_scopes(ast: list[ASTNode]) -> Scope``
525
- Build a scope tree over an AST and attach a ``scope`` attribute to every node.
553
+ Build a scope tree over an AST, recording every node's scope in a ``ScopeTable``
554
+ hung on the root (``root.table``). Read a node's scope with ``root.scope_of(node)``.
526
555
 
527
556
  :param ast: A list of top-level AST nodes (as returned by the ``getAST*`` functions)
528
557
  :returns: The root ``Scope`` object
529
558
 
559
+ ``build_scopes_into(ast: list[ASTNode], table: ScopeTable) -> Scope``
560
+ The same, recording into a ``ScopeTable`` the caller owns, so several roots (one per
561
+ ``use``\ d file, say) can be read back through one table.
562
+
530
563
  ``Scope``
531
564
  Represents a lexical scope with three independent namespaces (variables, functions,
532
565
  modules), mirroring OpenSCAD's scoping rules.
@@ -963,14 +996,21 @@ names according to OpenSCAD's three-namespace scoping rules::
963
996
  print(root_scope.lookup_variable("x")) # Assignment node
964
997
  print(root_scope.lookup_module("box")) # ModuleDeclaration node
965
998
 
966
- # Each AST node has a .scope attribute pointing to its enclosing scope
999
+ # Every node's enclosing scope, as this pass built it
967
1000
  box_decl = ast[1]
968
1001
  cube_call = box_decl.children[0]
969
- print(cube_call.scope.lookup_variable("size")) # ParameterDeclaration node
970
-
971
- ``build_scopes(ast)`` returns the root ``Scope`` object and attaches a ``scope`` attribute
972
- to every node in the tree. Scopes form a parent chain so lookups fall through to enclosing
973
- scopes automatically. Declarations (variables, functions, modules) inside a block are
1002
+ print(root_scope.scope_of(cube_call).lookup_variable("size")) # ParameterDeclaration node
1003
+
1004
+ ``build_scopes(ast)`` returns the root ``Scope`` object and records the scope of every node
1005
+ in the tree in ``root_scope.table``. Scopes form a parent chain so lookups fall through to
1006
+ enclosing scopes automatically.
1007
+
1008
+ A node's scope is not an attribute of the node. An included file is parsed once and its
1009
+ nodes are shared by every file that includes it, but ``include`` puts them in the
1010
+ *includer's* scope, so the same node sits in a different scope for each includer. Each
1011
+ ``build_scopes()`` call therefore has its own table, and two files that include the same
1012
+ library can be scoped, and used, at the same time. (Before 3.0, ``node.scope`` held the
1013
+ scope of whichever file had built its scopes last.) Declarations (variables, functions, modules) inside a block are
974
1014
  hoisted to the top of that block's scope before child nodes are visited.
975
1015
 
976
1016
  Testing
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "openscad_parser"
7
- version = "2.6.1"
7
+ version = "3.1.0"
8
8
  description = "A PEG parser to read OpenSCAD language source code, with optional AST tree generation."
9
9
  readme = "README.rst"
10
10
  authors = [
@@ -5,7 +5,7 @@
5
5
  from __future__ import unicode_literals
6
6
 
7
7
  from arpeggio import ParserPython
8
- from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only
8
+ from .grammar import openscad_language, openscad_language_with_comments, comment, whitespace_only, strict_commas
9
9
 
10
10
 
11
11
  # --- The parser ---
@@ -16,7 +16,10 @@ def getOpenSCADParser(reduce_tree=False, debug=False, include_comments=False):
16
16
  Args:
17
17
  reduce_tree: If True, reduce the parse tree (default: False)
18
18
  debug: If True, enable debug output (default: False)
19
- include_comments: If True, include comments in the AST instead of skipping them (default: False)
19
+ include_comments: If True, a grammar that parses comments at the points
20
+ it lists (default: False). The getAST* functions no longer use it:
21
+ they parse with comments as whitespace and attach them afterwards
22
+ (openscad_parser.ast.comments), which accepts a comment anywhere.
20
23
 
21
24
  Returns:
22
25
  ParserPython instance configured for OpenSCAD parsing
@@ -1,3 +1,4 @@
1
+ import functools
1
2
  import hashlib
2
3
  import json
3
4
  import os
@@ -5,7 +6,9 @@ import pickle
5
6
  import platform
6
7
  from typing import Optional
7
8
  from arpeggio import NoMatch
8
- from openscad_parser import getOpenSCADParser
9
+ from openscad_parser import getOpenSCADParser, strict_commas
10
+ from openscad_parser.grammar import _STRICT_COMMAS
11
+ from .comments import _attach_all_comments
9
12
  from .source_map import SourceMap, process_includes as process_includes_func
10
13
 
11
14
  # Import all AST nodes from nodes
@@ -54,6 +57,7 @@ from .nodes import (
54
57
  LessThanOrEqualOp,
55
58
  FunctionLiteral,
56
59
  PrimaryCall,
60
+ RenderExpression,
57
61
  PrimaryIndex,
58
62
  PrimaryMember,
59
63
  VectorElement,
@@ -87,7 +91,7 @@ from .nodes import (
87
91
  from .builder import ASTBuilderVisitor, Position
88
92
 
89
93
  # Import scope classes
90
- from .scope import Scope, build_scopes
94
+ from .scope import Scope, ScopeTable, build_scopes, build_scopes_into
91
95
 
92
96
  # Import pretty-printer
93
97
  from .pretty_print import to_openscad
@@ -105,57 +109,82 @@ from .serialization import (
105
109
 
106
110
  # --- AST convenience functions ---
107
111
 
112
+ def _windows_documents_dir() -> str:
113
+ """Where Windows says My Documents is -- the same SHGetFolderPathW(
114
+ CSIDL_PERSONAL, SHGFP_TYPE_CURRENT) call OpenSCAD makes -- rather than
115
+ assuming ~/Documents: OneDrive's Known Folder Move, on by default, puts
116
+ it at ~/OneDrive/Documents instead."""
117
+ try:
118
+ import ctypes
119
+ buf = ctypes.create_unicode_buffer(260)
120
+ if ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, buf) == 0: # CSIDL_PERSONAL, SHGFP_TYPE_CURRENT
121
+ return buf.value
122
+ except (AttributeError, OSError): # not actually on Windows
123
+ pass
124
+ return os.path.join(os.path.expanduser("~"), "Documents")
125
+
126
+
127
+ # Libraries shipped beside this package -- OpenSCAD's resourcePath("libraries").
128
+ _BUNDLED_LIBRARY_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "libraries")
129
+
130
+
131
+ def librarySearchDirs(currfile: str) -> list[str]:
132
+ """The directories `include`/`use` search, in order, as OpenSCAD's
133
+ parser_init() builds them: the including file's own directory, then
134
+ every OPENSCADPATH entry, then the user's libraries folder, then
135
+ libraries shipped beside this package.
136
+
137
+ OPENSCADPATH adds to the libraries folder rather than replacing it --
138
+ it used to replace it, so setting it for one library hid every other
139
+ one, a BOSL2 in the default folder included (openscad_cpp_parser #10).
140
+ """
141
+ dirs = []
142
+ if currfile:
143
+ dirs.append(os.path.dirname(os.path.abspath(currfile)))
144
+
145
+ system = platform.system()
146
+ pathsep = ";" if system == "Windows" else ":"
147
+ for path in os.getenv("OPENSCADPATH", "").split(pathsep):
148
+ expanded_path = os.path.expandvars(path)
149
+ if expanded_path:
150
+ dirs.append(expanded_path)
151
+
152
+ if system == "Windows":
153
+ dirs.append(os.path.join(_windows_documents_dir(), "OpenSCAD", "libraries"))
154
+ elif system == "Darwin":
155
+ dirs.append(os.path.expanduser("~/Documents/OpenSCAD/libraries"))
156
+ elif system == "Linux":
157
+ dirs.append(os.path.expanduser("~/.local/share/OpenSCAD/libraries"))
158
+ # ponytail: only the folder beside the package, no ../share/openscad walk
159
+ dirs.append(_BUNDLED_LIBRARY_DIR)
160
+ return dirs
161
+
162
+
108
163
  def findLibraryFile(currfile: str, libfile: str) -> Optional[str]:
109
- """Find a library file using OpenSCAD's search path rules.
110
-
111
- Searches for the library file in the following order:
112
- 1. Directory of the current file (if currfile is provided)
113
- 2. Directories specified in OPENSCADPATH environment variable
114
- 3. Platform-specific default library directories
115
-
164
+ """Find a library file using OpenSCAD's search path rules: the first
165
+ directory in librarySearchDirs(currfile) that holds it.
166
+
116
167
  Args:
117
168
  currfile: Full path to the current OpenSCAD file (can be empty string)
118
169
  libfile: Partial or full path to the library file to find
119
-
170
+
120
171
  Returns:
121
172
  Full path to the found library file, or None if not found
122
173
  """
123
- dirs = []
124
-
125
- # Add directory of current file if provided
126
- if currfile:
127
- dirs.append(os.path.dirname(os.path.abspath(currfile)))
128
-
129
- # Determine path separator and default path based on platform
130
- pathsep = ":"
131
- dflt_path = ""
132
- system = platform.system()
133
-
134
- if system == "Windows": # pragma: no cover
135
- dflt_path = os.path.join(os.path.expanduser("~"), "Documents", "OpenSCAD", "libraries")
136
- pathsep = ";"
137
- elif system == "Darwin": # pragma: no cover
138
- dflt_path = os.path.expanduser("~/Documents/OpenSCAD/libraries")
139
- elif system == "Linux": # pragma: no cover
140
- dflt_path = os.path.expanduser("~/.local/share/OpenSCAD/libraries")
141
-
142
- # Get OPENSCADPATH from environment or use default
143
- env = os.getenv("OPENSCADPATH", dflt_path)
144
- if env:
145
- for path in env.split(pathsep):
146
- expanded_path = os.path.expandvars(path)
147
- if expanded_path:
148
- dirs.append(expanded_path)
149
-
150
- # Search for the file in each directory
151
- for d in dirs:
174
+ for d in librarySearchDirs(currfile):
152
175
  test_file = os.path.join(d, libfile)
153
176
  if os.path.isfile(test_file):
154
177
  return test_file
155
-
156
178
  return None
157
179
 
158
180
 
181
+ def _not_found(what: str, filename: str, currfile: str) -> str:
182
+ """A not-found message that lists every directory searched. Naming only
183
+ the includer read as "only there was searched"."""
184
+ return f"{what} '{filename}' not found. Searched:" + "".join(
185
+ f"\n {d}" for d in librarySearchDirs(currfile))
186
+
187
+
159
188
  # Alias for backward compatibility (test_ast_convenience.py imports _find_library_file)
160
189
  _find_library_file = findLibraryFile
161
190
 
@@ -251,20 +280,24 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
251
280
  source_map = SourceMap()
252
281
  source_map.add_origin(origin, code)
253
282
 
254
- parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
283
+ # Comments are parsed as whitespace and attached afterwards (see comments.py).
284
+ parser = getOpenSCADParser(reduce_tree=False)
255
285
  ast = parse_ast(parser, code, source_map=source_map)
286
+ if ast is not None and include_comments:
287
+ ast = _attach_all_comments(ast, code, origin)
256
288
  return ast
257
289
 
258
290
 
259
291
  # Module-level in-memory cache for per-file AST trees (no includes resolved)
260
- # Key: tuple of (absolute file path (str), include_comments (bool))
292
+ # Key: tuple of (absolute file path (str), include_comments (bool), strict_commas (bool))
261
293
  # Value: tuple of (AST nodes, modification timestamp)
262
- _ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
294
+ _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
263
295
 
264
296
  # Resolved (includes-expanded) cache
265
- # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool))
297
+ # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
298
+ # strict_commas (bool))
266
299
  # Value: tuple of (AST nodes, modification timestamp)
267
- _resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
300
+ _resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, dict[str, float]]] = {}
268
301
 
269
302
 
270
303
  def _get_disk_cache_dir() -> Optional[str]:
@@ -285,12 +318,27 @@ def _get_disk_cache_dir() -> Optional[str]:
285
318
  return None
286
319
 
287
320
 
321
+ @functools.lru_cache(maxsize=None)
322
+ def _ast_format_tag() -> str:
323
+ """Changes whenever the grammar or the AST classes do, so a pickled AST
324
+ from another version is never served: an older pickle lacks any field
325
+ added since (RangeLiteral.implicit_step, say) and reads its default."""
326
+ here = os.path.dirname(os.path.abspath(__file__))
327
+ h = hashlib.sha256()
328
+ for path in (os.path.join(here, "..", "grammar.py"), os.path.join(here, "nodes.py"),
329
+ os.path.join(here, "builder.py"), os.path.join(here, "__init__.py"),
330
+ os.path.join(here, "comments.py")):
331
+ with open(path, "rb") as f:
332
+ h.update(f.read())
333
+ return h.hexdigest()[:16]
334
+
335
+
288
336
  def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
289
337
  """Get the disk cache file path for a given source file."""
290
338
  cache_dir = _get_disk_cache_dir()
291
339
  if not cache_dir:
292
340
  return None # pragma: no cover
293
- key = f"{file_path}:{include_comments}"
341
+ key = f"{file_path}:{include_comments}:{_STRICT_COMMAS.get()}:{_ast_format_tag()}"
294
342
  h = hashlib.sha256(key.encode()).hexdigest()[:16]
295
343
  return os.path.join(cache_dir, f"{h}.pickle")
296
344
 
@@ -429,7 +477,7 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
429
477
  raise FileNotFoundError(f"File {file_path} not found")
430
478
 
431
479
  current_mtime = os.path.getmtime(file_path)
432
- cache_key = (file_path, include_comments)
480
+ cache_key = (file_path, include_comments, _STRICT_COMMAS.get())
433
481
 
434
482
  # Check in-memory cache
435
483
  if cache_key in _ast_cache:
@@ -450,8 +498,10 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
450
498
  source_map = SourceMap()
451
499
  source_map.add_origin(file_path, code)
452
500
 
453
- parser = getOpenSCADParser(reduce_tree=False, include_comments=include_comments)
501
+ parser = getOpenSCADParser(reduce_tree=False)
454
502
  ast = parse_ast(parser, code, source_map=source_map)
503
+ if ast is not None and include_comments:
504
+ ast = _attach_all_comments(ast, code, file_path)
455
505
 
456
506
  # Cache in memory and on disk
457
507
  _ast_cache[cache_key] = (ast, current_mtime)
@@ -460,14 +510,30 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
460
510
  return ast
461
511
 
462
512
 
513
+ def _deps_unchanged(deps: dict[str, float]) -> bool:
514
+ """Whether every file a resolution read still has the mtime it had."""
515
+ try:
516
+ return all(os.path.getmtime(path) == mtime for path, mtime in deps.items())
517
+ except OSError: # one was deleted
518
+ return False
519
+
520
+
463
521
  def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
464
522
  include_comments: bool = False,
465
- visited: set | None = None) -> list[ASTNode] | None:
466
- """Resolve IncludeStatement nodes by parsing and inlining referenced files."""
523
+ open_files: tuple = (),
524
+ deps: dict | None = None) -> list[ASTNode] | None:
525
+ """Resolve IncludeStatement nodes by parsing and inlining referenced files.
526
+
527
+ A file is included every time it is named, as OpenSCAD includes it: an
528
+ `include` written twice runs the file twice. Only a file already open on
529
+ this include chain (`open_files`; the top-level file is not one) is
530
+ refused -- OpenSCAD warns that it can't find it and carries on -- which is
531
+ what stops a cycle. Every file read is recorded in `deps` with its mtime.
532
+ """
467
533
  if ast_nodes is None:
468
534
  return None
469
- if visited is None:
470
- visited = set()
535
+ if deps is None:
536
+ deps = {}
471
537
 
472
538
  result = []
473
539
  for node in ast_nodes:
@@ -475,16 +541,14 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
475
541
  filename = node.filepath.val
476
542
  lib_file = findLibraryFile(current_file, filename)
477
543
  if lib_file is None:
478
- raise FileNotFoundError(
479
- f"Included file '{filename}' not found. "
480
- f"Searched relative to: {current_file if current_file else 'current directory'}"
481
- )
544
+ raise FileNotFoundError(_not_found("Included file", filename, current_file))
482
545
  lib_file = os.path.abspath(lib_file)
483
- if lib_file in visited:
546
+ if lib_file in open_files:
484
547
  continue
485
- visited.add(lib_file)
548
+ deps[lib_file] = os.path.getmtime(lib_file)
486
549
  included_ast = _parse_single_file(lib_file, include_comments)
487
- included_ast = _resolve_includes(included_ast, lib_file, include_comments, visited)
550
+ included_ast = _resolve_includes(included_ast, lib_file, include_comments,
551
+ open_files + (lib_file,), deps)
488
552
  if included_ast:
489
553
  result.extend(included_ast)
490
554
  else:
@@ -550,21 +614,22 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
550
614
  return _parse_single_file(file_path, include_comments)
551
615
 
552
616
  # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
553
- resolved_key = (file_path, include_comments, True)
617
+ resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
554
618
  if resolved_key in _resolved_cache:
555
- cached_ast, cached_mtime = _resolved_cache[resolved_key]
556
- if cached_mtime == current_mtime:
619
+ cached_ast, cached_deps = _resolved_cache[resolved_key]
620
+ if _deps_unchanged(cached_deps):
557
621
  return cached_ast
558
622
 
559
623
  # Parse the file independently (uses per-file cache)
560
624
  ast = _parse_single_file(file_path, include_comments)
561
625
 
562
626
  # Resolve all include statements recursively
563
- visited = {file_path}
564
- ast = _resolve_includes(ast, file_path, include_comments, visited)
627
+ deps = {file_path: current_mtime}
628
+ ast = _resolve_includes(ast, file_path, include_comments, (), deps)
565
629
 
566
- # Cache the resolved result
567
- _resolved_cache[resolved_key] = (ast, current_mtime)
630
+ # Cache the resolved result, valid while every file it read is unchanged:
631
+ # keyed on the top file alone, an edited include went unnoticed.
632
+ _resolved_cache[resolved_key] = (ast, deps)
568
633
 
569
634
  return ast
570
635
 
@@ -574,13 +639,9 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
574
639
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules,
575
640
  and return both the AST and absolute path to the file.
576
641
 
577
- This function searches for the library file in the following order:
578
- 1. Directory of the current file (if currfile is provided)
579
- 2. Directories specified in OPENSCADPATH environment variable
580
- 3. Platform-specific default library directories:
581
- - Windows: ~/Documents/OpenSCAD/libraries
582
- - macOS: ~/Documents/OpenSCAD/libraries
583
- - Linux: ~/.local/share/OpenSCAD/libraries
642
+ This function searches the directories librarySearchDirs(currfile)
643
+ returns, in order: the current file's directory, OPENSCADPATH, the
644
+ platform's libraries folder, then libraries beside this package.
584
645
 
585
646
  Once found, the file is parsed using getASTfromFile(), which includes
586
647
  caching support and include processing.
@@ -624,10 +685,7 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
624
685
  found_file = findLibraryFile(currfile, libfile)
625
686
 
626
687
  if found_file is None:
627
- raise FileNotFoundError(
628
- f"Library file '{libfile}' not found in search paths. "
629
- f"Searched in: current file directory, OPENSCADPATH, and platform default paths."
630
- )
688
+ raise FileNotFoundError(_not_found("Library file", libfile, currfile))
631
689
 
632
690
  # Use getASTfromFile() which includes caching and include processing
633
691
  ast = getASTfromFile(found_file, include_comments=include_comments, process_includes=process_includes)
@@ -984,6 +984,13 @@ class ASTBuilderVisitor(PTNodeVisitor):
984
984
  def visit_primary(self, node, children):
985
985
  return children[0]
986
986
 
987
+ def visit_render_expr(self, node, children):
988
+ # Grammar: (KWD_RENDER, TOK_PAREN, arguments, TOK_ENDPAREN, statement_block)
989
+ arguments = children.get_rule("arguments") or []
990
+ block = children.get_rule("statement_block") or []
991
+ return RenderExpression(arguments=list(arguments), children=list(block),
992
+ position=self._get_node_position(node))
993
+
987
994
  def visit_range_expr(self, node, children):
988
995
  # OpenSCAD syntax: [start:end] or [start:step:end]
989
996
  start = children[0]
@@ -993,7 +1000,8 @@ class ASTBuilderVisitor(PTNodeVisitor):
993
1000
  else:
994
1001
  end = children[1]
995
1002
  step = NumberLiteral(val=1.0, position=self._get_node_position(node))
996
- return RangeLiteral(start=start, end=end, step=step, position=self._get_node_position(node))
1003
+ return RangeLiteral(start=start, end=end, step=step, implicit_step=len(children) <= 2,
1004
+ position=self._get_node_position(node))
997
1005
 
998
1006
  def visit_vector_expr(self, node, children):
999
1007
  elements = children if children else []
@@ -1093,8 +1101,14 @@ class ASTBuilderVisitor(PTNodeVisitor):
1093
1101
  position=self._get_node_position(node)
1094
1102
  )
1095
1103
 
1104
+ def _header_assignments(self, children) -> list:
1105
+ """The `(...)` list's assignments only. Filtering `children` for
1106
+ Assignments also took every assignment in a braced body -- `for (i =
1107
+ [0:1]) { a = i; }` made `a` a loop variable."""
1108
+ return list(children.get_rule('assignments_expr') or [])
1109
+
1096
1110
  def visit_modular_for(self, node, children):
1097
- assignments = [c for c in children if isinstance(c, Assignment)]
1111
+ assignments = self._header_assignments(children)
1098
1112
  body = children.get_rule('child_statement')
1099
1113
  return ModularFor(
1100
1114
  assignments=assignments,
@@ -1103,12 +1117,12 @@ class ASTBuilderVisitor(PTNodeVisitor):
1103
1117
  )
1104
1118
 
1105
1119
  def visit_modular_intersection_for(self, node, children):
1106
- assignments = [c for c in children if isinstance(c, Assignment)]
1120
+ assignments = self._header_assignments(children)
1107
1121
  body = children.get_rule('child_statement')
1108
1122
  return ModularIntersectionFor(assignments=assignments, body=body, position=self._get_node_position(node))
1109
1123
 
1110
1124
  def visit_modular_let(self, node, children):
1111
- assignments = [c for c in children if isinstance(c, Assignment)]
1125
+ assignments = self._header_assignments(children)
1112
1126
  mods = children.get_rule('child_statement')
1113
1127
  return ModularLet(assignments=assignments, children=mods, position=self._get_node_position(node))
1114
1128