openscad-parser 2.6.0__tar.gz → 3.0.1__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.0
3
+ Version: 3.0.1
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.0"
7
+ version = "3.0.1"
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 ---
@@ -1,10 +1,13 @@
1
+ import functools
1
2
  import hashlib
3
+ import json
2
4
  import os
3
5
  import pickle
4
6
  import platform
5
7
  from typing import Optional
6
8
  from arpeggio import NoMatch
7
- from openscad_parser import getOpenSCADParser
9
+ from openscad_parser import getOpenSCADParser, strict_commas
10
+ from openscad_parser.grammar import _STRICT_COMMAS
8
11
  from .source_map import SourceMap, process_includes as process_includes_func
9
12
 
10
13
  # Import all AST nodes from nodes
@@ -53,6 +56,7 @@ from .nodes import (
53
56
  LessThanOrEqualOp,
54
57
  FunctionLiteral,
55
58
  PrimaryCall,
59
+ RenderExpression,
56
60
  PrimaryIndex,
57
61
  PrimaryMember,
58
62
  VectorElement,
@@ -86,7 +90,7 @@ from .nodes import (
86
90
  from .builder import ASTBuilderVisitor, Position
87
91
 
88
92
  # Import scope classes
89
- from .scope import Scope, build_scopes
93
+ from .scope import Scope, ScopeTable, build_scopes, build_scopes_into
90
94
 
91
95
  # Import pretty-printer
92
96
  from .pretty_print import to_openscad
@@ -104,57 +108,82 @@ from .serialization import (
104
108
 
105
109
  # --- AST convenience functions ---
106
110
 
111
+ def _windows_documents_dir() -> str:
112
+ """Where Windows says My Documents is -- the same SHGetFolderPathW(
113
+ CSIDL_PERSONAL, SHGFP_TYPE_CURRENT) call OpenSCAD makes -- rather than
114
+ assuming ~/Documents: OneDrive's Known Folder Move, on by default, puts
115
+ it at ~/OneDrive/Documents instead."""
116
+ try:
117
+ import ctypes
118
+ buf = ctypes.create_unicode_buffer(260)
119
+ if ctypes.windll.shell32.SHGetFolderPathW(None, 5, None, 0, buf) == 0: # CSIDL_PERSONAL, SHGFP_TYPE_CURRENT
120
+ return buf.value
121
+ except (AttributeError, OSError): # not actually on Windows
122
+ pass
123
+ return os.path.join(os.path.expanduser("~"), "Documents")
124
+
125
+
126
+ # Libraries shipped beside this package -- OpenSCAD's resourcePath("libraries").
127
+ _BUNDLED_LIBRARY_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "libraries")
128
+
129
+
130
+ def librarySearchDirs(currfile: str) -> list[str]:
131
+ """The directories `include`/`use` search, in order, as OpenSCAD's
132
+ parser_init() builds them: the including file's own directory, then
133
+ every OPENSCADPATH entry, then the user's libraries folder, then
134
+ libraries shipped beside this package.
135
+
136
+ OPENSCADPATH adds to the libraries folder rather than replacing it --
137
+ it used to replace it, so setting it for one library hid every other
138
+ one, a BOSL2 in the default folder included (openscad_cpp_parser #10).
139
+ """
140
+ dirs = []
141
+ if currfile:
142
+ dirs.append(os.path.dirname(os.path.abspath(currfile)))
143
+
144
+ system = platform.system()
145
+ pathsep = ";" if system == "Windows" else ":"
146
+ for path in os.getenv("OPENSCADPATH", "").split(pathsep):
147
+ expanded_path = os.path.expandvars(path)
148
+ if expanded_path:
149
+ dirs.append(expanded_path)
150
+
151
+ if system == "Windows":
152
+ dirs.append(os.path.join(_windows_documents_dir(), "OpenSCAD", "libraries"))
153
+ elif system == "Darwin":
154
+ dirs.append(os.path.expanduser("~/Documents/OpenSCAD/libraries"))
155
+ elif system == "Linux":
156
+ dirs.append(os.path.expanduser("~/.local/share/OpenSCAD/libraries"))
157
+ # ponytail: only the folder beside the package, no ../share/openscad walk
158
+ dirs.append(_BUNDLED_LIBRARY_DIR)
159
+ return dirs
160
+
161
+
107
162
  def findLibraryFile(currfile: str, libfile: str) -> Optional[str]:
108
- """Find a library file using OpenSCAD's search path rules.
109
-
110
- Searches for the library file in the following order:
111
- 1. Directory of the current file (if currfile is provided)
112
- 2. Directories specified in OPENSCADPATH environment variable
113
- 3. Platform-specific default library directories
114
-
163
+ """Find a library file using OpenSCAD's search path rules: the first
164
+ directory in librarySearchDirs(currfile) that holds it.
165
+
115
166
  Args:
116
167
  currfile: Full path to the current OpenSCAD file (can be empty string)
117
168
  libfile: Partial or full path to the library file to find
118
-
169
+
119
170
  Returns:
120
171
  Full path to the found library file, or None if not found
121
172
  """
122
- dirs = []
123
-
124
- # Add directory of current file if provided
125
- if currfile:
126
- dirs.append(os.path.dirname(os.path.abspath(currfile)))
127
-
128
- # Determine path separator and default path based on platform
129
- pathsep = ":"
130
- dflt_path = ""
131
- system = platform.system()
132
-
133
- if system == "Windows": # pragma: no cover
134
- dflt_path = os.path.join(os.path.expanduser("~"), "Documents", "OpenSCAD", "libraries")
135
- pathsep = ";"
136
- elif system == "Darwin": # pragma: no cover
137
- dflt_path = os.path.expanduser("~/Documents/OpenSCAD/libraries")
138
- elif system == "Linux": # pragma: no cover
139
- dflt_path = os.path.expanduser("~/.local/share/OpenSCAD/libraries")
140
-
141
- # Get OPENSCADPATH from environment or use default
142
- env = os.getenv("OPENSCADPATH", dflt_path)
143
- if env:
144
- for path in env.split(pathsep):
145
- expanded_path = os.path.expandvars(path)
146
- if expanded_path:
147
- dirs.append(expanded_path)
148
-
149
- # Search for the file in each directory
150
- for d in dirs:
173
+ for d in librarySearchDirs(currfile):
151
174
  test_file = os.path.join(d, libfile)
152
175
  if os.path.isfile(test_file):
153
176
  return test_file
154
-
155
177
  return None
156
178
 
157
179
 
180
+ def _not_found(what: str, filename: str, currfile: str) -> str:
181
+ """A not-found message that lists every directory searched. Naming only
182
+ the includer read as "only there was searched"."""
183
+ return f"{what} '{filename}' not found. Searched:" + "".join(
184
+ f"\n {d}" for d in librarySearchDirs(currfile))
185
+
186
+
158
187
  # Alias for backward compatibility (test_ast_convenience.py imports _find_library_file)
159
188
  _find_library_file = findLibraryFile
160
189
 
@@ -256,14 +285,15 @@ def getASTfromString(code: str, include_comments: bool = False, origin: str = "<
256
285
 
257
286
 
258
287
  # Module-level in-memory cache for per-file AST trees (no includes resolved)
259
- # Key: tuple of (absolute file path (str), include_comments (bool))
288
+ # Key: tuple of (absolute file path (str), include_comments (bool), strict_commas (bool))
260
289
  # Value: tuple of (AST nodes, modification timestamp)
261
- _ast_cache: dict[tuple[str, bool], tuple[list[ASTNode] | None, float]] = {}
290
+ _ast_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
262
291
 
263
292
  # Resolved (includes-expanded) cache
264
- # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool))
293
+ # Key: tuple of (absolute file path (str), include_comments (bool), process_includes (bool),
294
+ # strict_commas (bool))
265
295
  # Value: tuple of (AST nodes, modification timestamp)
266
- _resolved_cache: dict[tuple[str, bool, bool], tuple[list[ASTNode] | None, float]] = {}
296
+ _resolved_cache: dict[tuple[str, bool, bool, bool], tuple[list[ASTNode] | None, float]] = {}
267
297
 
268
298
 
269
299
  def _get_disk_cache_dir() -> Optional[str]:
@@ -284,12 +314,26 @@ def _get_disk_cache_dir() -> Optional[str]:
284
314
  return None
285
315
 
286
316
 
317
+ @functools.lru_cache(maxsize=None)
318
+ def _ast_format_tag() -> str:
319
+ """Changes whenever the grammar or the AST classes do, so a pickled AST
320
+ from another version is never served: an older pickle lacks any field
321
+ added since (RangeLiteral.implicit_step, say) and reads its default."""
322
+ here = os.path.dirname(os.path.abspath(__file__))
323
+ h = hashlib.sha256()
324
+ for path in (os.path.join(here, "..", "grammar.py"), os.path.join(here, "nodes.py"),
325
+ os.path.join(here, "builder.py"), os.path.join(here, "__init__.py")):
326
+ with open(path, "rb") as f:
327
+ h.update(f.read())
328
+ return h.hexdigest()[:16]
329
+
330
+
287
331
  def _disk_cache_path(file_path: str, include_comments: bool) -> Optional[str]:
288
332
  """Get the disk cache file path for a given source file."""
289
333
  cache_dir = _get_disk_cache_dir()
290
334
  if not cache_dir:
291
335
  return None # pragma: no cover
292
- key = f"{file_path}:{include_comments}"
336
+ key = f"{file_path}:{include_comments}:{_STRICT_COMMAS.get()}:{_ast_format_tag()}"
293
337
  h = hashlib.sha256(key.encode()).hexdigest()[:16]
294
338
  return os.path.join(cache_dir, f"{h}.pickle")
295
339
 
@@ -310,17 +354,83 @@ def _load_from_disk_cache(file_path: str, include_comments: bool, current_mtime:
310
354
 
311
355
 
312
356
  def _save_to_disk_cache(file_path: str, include_comments: bool, mtime: float, ast: list[ASTNode] | None):
313
- """Save a file's AST to disk cache."""
357
+ """Save a file's AST to disk cache and update the manifest."""
314
358
  cache_path = _disk_cache_path(file_path, include_comments)
315
359
  if not cache_path:
316
360
  return # pragma: no cover
317
361
  try:
318
362
  with open(cache_path, 'wb') as f:
319
363
  pickle.dump((mtime, ast), f, protocol=pickle.HIGHEST_PROTOCOL)
364
+ except OSError: # pragma: no cover
365
+ return
366
+ cache_fname = os.path.basename(cache_path)
367
+ _manifest_update(cache_fname, file_path)
368
+ _evict_stale_cache()
369
+
370
+
371
+ def _manifest_path() -> Optional[str]:
372
+ """Get the path to the cache manifest file."""
373
+ cache_dir = _get_disk_cache_dir()
374
+ if not cache_dir:
375
+ return None # pragma: no cover
376
+ return os.path.join(cache_dir, "manifest.json")
377
+
378
+
379
+ def _manifest_load() -> dict[str, str]:
380
+ """Load the manifest: {cache_filename: source_file_path}."""
381
+ path = _manifest_path()
382
+ if not path or not os.path.exists(path):
383
+ return {}
384
+ try:
385
+ with open(path, 'r') as f:
386
+ return json.load(f)
387
+ except (OSError, json.JSONDecodeError):
388
+ return {}
389
+
390
+
391
+ def _manifest_save(manifest: dict[str, str]):
392
+ """Save the manifest to disk."""
393
+ path = _manifest_path()
394
+ if not path:
395
+ return # pragma: no cover
396
+ try:
397
+ with open(path, 'w') as f:
398
+ json.dump(manifest, f)
320
399
  except OSError: # pragma: no cover
321
400
  pass
322
401
 
323
402
 
403
+ def _manifest_update(cache_fname: str, source_path: str):
404
+ """Add or update an entry in the manifest."""
405
+ manifest = _manifest_load()
406
+ manifest[cache_fname] = source_path
407
+ _manifest_save(manifest)
408
+
409
+
410
+ def _evict_stale_cache():
411
+ """Remove cache entries whose source files no longer exist."""
412
+ cache_dir = _get_disk_cache_dir()
413
+ if not cache_dir:
414
+ return # pragma: no cover
415
+ manifest = _manifest_load()
416
+ if not manifest:
417
+ return
418
+ stale_keys = [
419
+ fname for fname, source_path in manifest.items()
420
+ if not os.path.exists(source_path)
421
+ ]
422
+ if not stale_keys:
423
+ return
424
+ for fname in stale_keys:
425
+ cache_file = os.path.join(cache_dir, fname)
426
+ try:
427
+ os.remove(cache_file)
428
+ except OSError:
429
+ pass
430
+ del manifest[fname]
431
+ _manifest_save(manifest)
432
+
433
+
324
434
  def clear_ast_cache():
325
435
  """Clear the in-memory AST cache.
326
436
 
@@ -346,7 +456,7 @@ def clear_disk_cache():
346
456
  cache_dir = _get_disk_cache_dir()
347
457
  if cache_dir and os.path.isdir(cache_dir):
348
458
  for fname in os.listdir(cache_dir):
349
- if fname.endswith('.pickle'):
459
+ if fname.endswith('.pickle') or fname == 'manifest.json':
350
460
  try:
351
461
  os.remove(os.path.join(cache_dir, fname))
352
462
  except OSError: # pragma: no cover
@@ -362,7 +472,7 @@ def _parse_single_file(file_path: str, include_comments: bool = False) -> list[A
362
472
  raise FileNotFoundError(f"File {file_path} not found")
363
473
 
364
474
  current_mtime = os.path.getmtime(file_path)
365
- cache_key = (file_path, include_comments)
475
+ cache_key = (file_path, include_comments, _STRICT_COMMAS.get())
366
476
 
367
477
  # Check in-memory cache
368
478
  if cache_key in _ast_cache:
@@ -408,10 +518,7 @@ def _resolve_includes(ast_nodes: list[ASTNode] | None, current_file: str,
408
518
  filename = node.filepath.val
409
519
  lib_file = findLibraryFile(current_file, filename)
410
520
  if lib_file is None:
411
- raise FileNotFoundError(
412
- f"Included file '{filename}' not found. "
413
- f"Searched relative to: {current_file if current_file else 'current directory'}"
414
- )
521
+ raise FileNotFoundError(_not_found("Included file", filename, current_file))
415
522
  lib_file = os.path.abspath(lib_file)
416
523
  if lib_file in visited:
417
524
  continue
@@ -483,7 +590,7 @@ def getASTfromFile(file: str, include_comments: bool = False, process_includes:
483
590
  return _parse_single_file(file_path, include_comments)
484
591
 
485
592
  # Check resolved cache (in-memory only since resolved ASTs depend on multiple files)
486
- resolved_key = (file_path, include_comments, True)
593
+ resolved_key = (file_path, include_comments, True, _STRICT_COMMAS.get())
487
594
  if resolved_key in _resolved_cache:
488
595
  cached_ast, cached_mtime = _resolved_cache[resolved_key]
489
596
  if cached_mtime == current_mtime:
@@ -507,13 +614,9 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
507
614
  Find and parse an OpenSCAD library file using OpenSCAD's search path rules,
508
615
  and return both the AST and absolute path to the file.
509
616
 
510
- This function searches for the library file in the following order:
511
- 1. Directory of the current file (if currfile is provided)
512
- 2. Directories specified in OPENSCADPATH environment variable
513
- 3. Platform-specific default library directories:
514
- - Windows: ~/Documents/OpenSCAD/libraries
515
- - macOS: ~/Documents/OpenSCAD/libraries
516
- - Linux: ~/.local/share/OpenSCAD/libraries
617
+ This function searches the directories librarySearchDirs(currfile)
618
+ returns, in order: the current file's directory, OPENSCADPATH, the
619
+ platform's libraries folder, then libraries beside this package.
517
620
 
518
621
  Once found, the file is parsed using getASTfromFile(), which includes
519
622
  caching support and include processing.
@@ -557,10 +660,7 @@ def getASTfromLibraryFile(currfile: str, libfile: str, include_comments: bool =
557
660
  found_file = findLibraryFile(currfile, libfile)
558
661
 
559
662
  if found_file is None:
560
- raise FileNotFoundError(
561
- f"Library file '{libfile}' not found in search paths. "
562
- f"Searched in: current file directory, OPENSCADPATH, and platform default paths."
563
- )
663
+ raise FileNotFoundError(_not_found("Library file", libfile, currfile))
564
664
 
565
665
  # Use getASTfromFile() which includes caching and include processing
566
666
  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 []