scadfmt 0.1.0__py3-none-any.whl

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.
scadfmt/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Opinionated formatter for OpenSCAD code (scadfmt)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ # release-please bumps pyproject.toml only, so the installed metadata is the single source of truth.
6
+ try:
7
+ __version__ = version("scadfmt")
8
+ except PackageNotFoundError:
9
+ __version__ = "unknown"
scadfmt/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Run scadfmt as `python -m scadfmt`."""
2
+
3
+ import sys
4
+
5
+ from scadfmt.cli import main
6
+
7
+ sys.exit(main())
scadfmt/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Command-line interface for scadfmt."""
2
+
3
+ import argparse
4
+ import difflib
5
+ import logging
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from scadfmt import __version__
10
+ from scadfmt.fileio import write_atomically
11
+ from scadfmt.formatter import FormatError, format_source
12
+ from scadfmt.vscode import setup_vscode
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ EXIT_OK = 0
17
+ EXIT_CHANGES = 1
18
+ EXIT_ERROR = 2
19
+
20
+
21
+ def _collect(paths: list[str]) -> list[Path]:
22
+ """Expand directories to the .scad files below them, sorted."""
23
+ files: list[Path] = []
24
+ for raw in paths:
25
+ path = Path(raw)
26
+ if path.is_dir():
27
+ files.extend(sorted(path.rglob("*.scad")))
28
+ else:
29
+ files.append(path)
30
+ return files
31
+
32
+
33
+ def _diff(before: str, after: str, name: str) -> str:
34
+ """Unified diff between two versions of a file."""
35
+ return "".join(
36
+ difflib.unified_diff(
37
+ before.splitlines(keepends=True),
38
+ after.splitlines(keepends=True),
39
+ f"{name} (original)",
40
+ f"{name} (formatted)",
41
+ )
42
+ )
43
+
44
+
45
+ def _write(text: str) -> None:
46
+ """Write UTF-8 text to stdout without newline translation."""
47
+ sys.stdout.buffer.write(text.encode("utf-8"))
48
+ sys.stdout.buffer.flush()
49
+
50
+
51
+ def _format_stdin(check: bool, diff: bool) -> int:
52
+ """Format stdin to stdout, or report on it with --check or --diff."""
53
+ # Bytes, not text: text mode would translate newlines and use the locale codepage on Windows.
54
+ try:
55
+ source = sys.stdin.buffer.read().decode("utf-8")
56
+ formatted = format_source(source)
57
+ except (UnicodeDecodeError, FormatError) as e:
58
+ logger.error("<stdin>:%s", e)
59
+ return EXIT_ERROR
60
+ if diff:
61
+ _write(_diff(source, formatted, "<stdin>"))
62
+ elif not check:
63
+ _write(formatted)
64
+ return EXIT_CHANGES if check and formatted != source else EXIT_OK
65
+
66
+
67
+ def _format_files(files: list[Path], check: bool, diff: bool) -> int:
68
+ """Format files in place, or report on them with --check or --diff."""
69
+ changed = errors = 0
70
+ for path in files:
71
+ try:
72
+ source = path.read_bytes().decode("utf-8")
73
+ formatted = format_source(source)
74
+ except (OSError, UnicodeDecodeError, FormatError) as e:
75
+ logger.error("%s:%s", path, e)
76
+ errors += 1
77
+ continue
78
+ if formatted == source:
79
+ continue
80
+ changed += 1
81
+ if diff:
82
+ _write(_diff(source, formatted, str(path)))
83
+ if check or diff:
84
+ logger.info("would reformat %s", path)
85
+ else:
86
+ try:
87
+ write_atomically(path, formatted)
88
+ except OSError as e:
89
+ logger.error("%s: cannot write: %s", path, e)
90
+ errors += 1
91
+ continue
92
+ logger.info("reformatted %s", path)
93
+ if errors:
94
+ return EXIT_ERROR
95
+ return EXIT_CHANGES if check and changed else EXIT_OK
96
+
97
+
98
+ def _handle_format(args: argparse.Namespace) -> int:
99
+ """Run the format subcommand."""
100
+ if args.paths == ["-"]:
101
+ return _format_stdin(args.check, args.diff)
102
+ if "-" in args.paths:
103
+ logger.error("'-' (stdin) cannot be combined with file paths")
104
+ return EXIT_ERROR
105
+ return _format_files(_collect(args.paths), args.check, args.diff)
106
+
107
+
108
+ def build_parser() -> argparse.ArgumentParser:
109
+ """Build the argument parser.
110
+
111
+ Returns:
112
+ The configured parser.
113
+ """
114
+ parser = argparse.ArgumentParser(prog="scadfmt", description="Opinionated formatter for OpenSCAD code.")
115
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
116
+ subparsers = parser.add_subparsers(dest="command", required=True)
117
+
118
+ format_parser = subparsers.add_parser("format", help="Format .scad files in place")
119
+ format_parser.add_argument("paths", nargs="+", help="Files or directories to format, or '-' for stdin")
120
+ format_parser.add_argument("--check", action="store_true", help="Write nothing, exit 1 if any file would change")
121
+ format_parser.add_argument("--diff", action="store_true", help="Write nothing, print a diff of the changes")
122
+
123
+ vscode_parser = subparsers.add_parser("vscode", help="Make scadfmt the VS Code formatter for .scad files")
124
+ vscode_parser.add_argument(
125
+ "--workspace", default=".", help="Workspace folder whose .vscode/settings.json is updated (default: .)"
126
+ )
127
+ return parser
128
+
129
+
130
+ def main(argv: list[str] | None = None) -> int:
131
+ """Run scadfmt.
132
+
133
+ Args:
134
+ argv: Arguments without the program name. Defaults to sys.argv[1:].
135
+
136
+ Returns:
137
+ Exit code: 0 clean, 1 files would change (--check), 2 error.
138
+ """
139
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
140
+ args = build_parser().parse_args(argv)
141
+ if args.command == "vscode":
142
+ return EXIT_OK if setup_vscode(Path(args.workspace)) else EXIT_ERROR
143
+ return _handle_format(args)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
scadfmt/fileio.py ADDED
@@ -0,0 +1,31 @@
1
+ """Atomic file writes shared by formatting and VS Code setup."""
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+
9
+ def write_atomically(path: Path, text: str) -> None:
10
+ """Replace the file at path with text in one step, so a failed write never leaves a truncated file.
11
+
12
+ A symlink is followed and its target updated. An existing file keeps its permissions.
13
+
14
+ Args:
15
+ path: File to write, created if missing.
16
+ text: New content, written as UTF-8 without newline translation.
17
+
18
+ Raises:
19
+ OSError: If the temporary file cannot be written or moved into place.
20
+ """
21
+ target = path.resolve() if path.is_symlink() else path
22
+ handle, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
23
+ try:
24
+ with os.fdopen(handle, "wb") as temp:
25
+ temp.write(text.encode("utf-8"))
26
+ if target.exists():
27
+ shutil.copymode(target, temp_name)
28
+ os.replace(temp_name, target)
29
+ except OSError:
30
+ Path(temp_name).unlink(missing_ok=True)
31
+ raise
scadfmt/formatter.py ADDED
@@ -0,0 +1,527 @@
1
+ """Format OpenSCAD source by re-spacing and re-indenting its tokens.
2
+
3
+ Line breaks stay where the author put them. Only the whitespace between tokens changes, which `format_source`
4
+ verifies by comparing the tokens of input and output.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+
9
+ from scadfmt.tokenizer import Kind, Token, TokenizeError, significant, tokenize
10
+
11
+ INDENT = " "
12
+ COMMENT_GAP = 2
13
+ MAX_BLANK_LINES_TOP = 2
14
+ MAX_BLANK_LINES_NESTED = 1
15
+ FMT_OFF = "// fmt: off"
16
+ FMT_ON = "// fmt: on"
17
+
18
+ OPENERS = {"(": ")", "[": "]", "{": "}"}
19
+ CLOSERS = {v: k for k, v in OPENERS.items()}
20
+ # Keywords followed by a space before `(`; every other name is a call and stays tight.
21
+ SPACED_BEFORE_PAREN = {"if", "for", "intersection_for", "function"}
22
+ MODIFIERS = {"#", "%", "!", "*"}
23
+ UNARY = {"-", "+", "!", "~"}
24
+ HEADER_KEYWORDS = {"if", "for", "intersection_for", "let", "function", "assert", "echo"}
25
+ # A previous token after which a line continues the statement instead of starting a new one.
26
+ STATEMENT_ENDS = {";", "{", "}"}
27
+
28
+
29
+ class FormatError(ValueError):
30
+ """Raised when source cannot be formatted safely."""
31
+
32
+
33
+ @dataclass
34
+ class _Frame:
35
+ """An open bracket (or the file root) and what the formatter tracks inside it."""
36
+
37
+ char: str
38
+ level: int
39
+ pending_ternaries: int = 0
40
+ # `(` right after `if`, `for`, `let`, `function`...: a `-` after its `)` starts an operand.
41
+ header: bool = False
42
+ # Levels of unbraced `if` lines still waiting for a possible `else`.
43
+ if_levels: list[int] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class _Line:
48
+ """One output line before trailing comments are aligned."""
49
+
50
+ indent: str
51
+ code: str
52
+ comment: str | None = None
53
+ verbatim: bool = False
54
+ blank: bool = False
55
+
56
+
57
+ @dataclass
58
+ # pylint: disable-next=too-many-instance-attributes # Plain state holder for one formatting pass
59
+ class _State:
60
+ """Everything the formatter carries from one token to the next."""
61
+
62
+ stack: list[_Frame] = field(default_factory=lambda: [_Frame("", -1)])
63
+ previous: Token | None = None
64
+ previous_role: str = ""
65
+ in_expression: bool = False
66
+ # Level of the line the last code line belongs to, see `_render_tokens`.
67
+ line_anchor: int = 0
68
+ after_header: bool = False
69
+ newline: str = "\n"
70
+ # Stack depths at which open module/function definitions started, and whether one just ended.
71
+ definition_depths: list[int] = field(default_factory=list)
72
+ definition_ended: bool = False
73
+
74
+
75
+ def format_source(source: str) -> str:
76
+ """Format OpenSCAD source.
77
+
78
+ Args:
79
+ source: OpenSCAD source text.
80
+
81
+ Returns:
82
+ The formatted source, ending in exactly one newline. Line endings follow the first one in source.
83
+
84
+ Raises:
85
+ FormatError: On input that cannot be tokenized, has unbalanced brackets, or would change meaning.
86
+ """
87
+ try:
88
+ tokens = tokenize(source)
89
+ except TokenizeError as e:
90
+ raise FormatError(str(e)) from e
91
+ source_lines = source.replace("\r\n", "\n").replace("\r", "\n").split("\n")
92
+ output = _render(_split_lines(_insert_breaks(tokens)), source_lines, _newline(source))
93
+ if significant(tokenize(output)) != significant(tokens):
94
+ raise FormatError("formatting would change the code, file left untouched (please report this as a bug)")
95
+ return output
96
+
97
+
98
+ def _newline(source: str) -> str:
99
+ """The line ending the file uses, judged by its first line break (LF if it has none)."""
100
+ first = next((i for i, char in enumerate(source) if char in "\r\n"), None)
101
+ if first is None or source[first] == "\n":
102
+ return "\n"
103
+ return "\r\n" if source.startswith("\r\n", first) else "\r"
104
+
105
+
106
+ def _insert_breaks(tokens: list[Token]) -> list[Token]:
107
+ """Put block contents and statements on their own lines.
108
+
109
+ Adds a line break after `{`, around `}` (keeping `} else`) and after `;` outside parentheses, except in empty
110
+ `{}`, before a trailing comment and inside `// fmt: off` regions. Added breaks have empty text.
111
+ """
112
+ out: list[Token] = []
113
+ stack: list[str] = []
114
+ fmt_off = False
115
+
116
+ def ensure_break(after: Token) -> None:
117
+ if out and out[-1].kind != Kind.NEWLINE:
118
+ out.append(Token(Kind.NEWLINE, "", after.line, after.col))
119
+
120
+ for index, token in enumerate(tokens):
121
+ following = tokens[index + 1] if index + 1 < len(tokens) else None
122
+ if token.kind == Kind.LINE_COMMENT and token.text.strip() in (FMT_OFF, FMT_ON):
123
+ fmt_off = token.text.strip() == FMT_OFF
124
+ is_op = token.kind == Kind.OP
125
+ if not fmt_off and is_op and token.text == "}" and not (out and out[-1].text == "{"):
126
+ ensure_break(token)
127
+ out.append(token)
128
+ if is_op and token.text in OPENERS:
129
+ stack.append(token.text)
130
+ elif is_op and token.text in CLOSERS and stack:
131
+ stack.pop()
132
+ if fmt_off or not is_op or following is None:
133
+ continue
134
+ if following.kind in (Kind.NEWLINE, Kind.LINE_COMMENT):
135
+ continue
136
+ if _breaks_after(token.text, following.text, not stack or stack[-1] == "{"):
137
+ ensure_break(token)
138
+ return out
139
+
140
+
141
+ def _breaks_after(text: str, following: str, at_statement_level: bool) -> bool:
142
+ """Whether a line break follows the operator text when following comes next on the same line."""
143
+ if text == "{":
144
+ return following != "}"
145
+ if text == "}":
146
+ return following not in ("else", ";")
147
+ return text == ";" and at_statement_level
148
+
149
+
150
+ def _split_lines(tokens: list[Token]) -> list[tuple[list[Token], int, int]]:
151
+ """Group tokens into physical lines.
152
+
153
+ Returns:
154
+ (tokens, first source line, last source line) per line. A multi-line comment or string keeps the lines it
155
+ spans in one group.
156
+ """
157
+ lines = []
158
+ current: list[Token] = []
159
+ start = 1
160
+ for token in tokens:
161
+ if token.kind == Kind.NEWLINE:
162
+ lines.append((current, start, token.line))
163
+ # An added break (empty text) splits a source line, so the next group starts on the same one.
164
+ current, start = [], token.line + 1 if token.text else token.line
165
+ else:
166
+ current.append(token)
167
+ if current:
168
+ lines.append((current, start, current[-1].line + current[-1].text.count("\n")))
169
+ return lines
170
+
171
+
172
+ def _render(lines: list[tuple[list[Token], int, int]], source_lines: list[str], newline: str = "\n") -> str:
173
+ """Turn grouped tokens into formatted text joined by newline."""
174
+ state = _State(newline=newline)
175
+ out: list[_Line] = []
176
+ blank_run = 0
177
+ fmt_off = False
178
+ next_code_is_import = _next_code_is_import(lines)
179
+ definition_starts = _definition_starts(lines)
180
+ after_import = False
181
+ for index, (tokens, first, last) in enumerate(lines):
182
+ if not tokens:
183
+ if fmt_off:
184
+ out.append(_Line("", "", verbatim=True))
185
+ else:
186
+ blank_run += 1
187
+ continue
188
+ level = _match_else(tokens, _line_level(tokens[0], state), state)
189
+ marker = tokens[0].text.strip() if len(tokens) == 1 and tokens[0].kind == Kind.LINE_COMMENT else None
190
+ if after_import and not fmt_off:
191
+ blank_run, after_import = _import_block_gap(tokens, next_code_is_import[index])
192
+ if not _is_comment_only(tokens):
193
+ after_import = _is_import(tokens)
194
+ if not fmt_off:
195
+ blank_run = _definition_gap(tokens, blank_run, definition_starts[index], state)
196
+ if _is_definition(tokens):
197
+ state.definition_depths.append(len(state.stack))
198
+ out.extend(_blank_lines(blank_run, top_level=len(state.stack) == 1 and level == 0))
199
+ blank_run = 0
200
+ code, comment = _render_tokens(tokens, level, state)
201
+ _close_definitions(state)
202
+ if fmt_off and marker != FMT_ON:
203
+ out.extend(_Line("", text, verbatim=True) for text in source_lines[first - 1 : last])
204
+ continue
205
+ out.append(_Line(INDENT * level, code, comment))
206
+ fmt_off = marker == FMT_OFF or (fmt_off and marker != FMT_ON)
207
+ if len(state.stack) > 1:
208
+ frame = state.stack[-1]
209
+ raise FormatError(f"unclosed {frame.char!r} at end of file")
210
+ # Blank lines only reach out before a following line, so only leading ones need trimming.
211
+ while out and out[0].blank:
212
+ out.pop(0)
213
+ _align_comments(out)
214
+ # Joined with the file's newline directly: a multi-line string keeps its own line endings.
215
+ text = newline.join(line.code if line.verbatim else _join(line) for line in out)
216
+ return text + newline if text else ""
217
+
218
+
219
+ def _is_definition(tokens: list[Token]) -> bool:
220
+ """True for a line starting a `module` or `function` definition (not a function literal)."""
221
+ first = tokens[0]
222
+ return (
223
+ first.kind == Kind.IDENT
224
+ and first.text in ("module", "function")
225
+ and tokens[1:2] != []
226
+ and (tokens[1].kind == Kind.IDENT)
227
+ )
228
+
229
+
230
+ def _definition_starts(lines: list[tuple[list[Token], int, int]]) -> list[bool]:
231
+ """Per line: whether a definition, including the comments directly above it, starts here."""
232
+ starts = [False] * len(lines)
233
+ for index, (tokens, _, _) in enumerate(lines):
234
+ if not tokens or not _is_definition(tokens):
235
+ continue
236
+ top = index
237
+ while top > 0 and _is_attached_comment(lines[top - 1][0]):
238
+ top -= 1
239
+ starts[top] = True
240
+ return starts
241
+
242
+
243
+ def _is_attached_comment(tokens: list[Token]) -> bool:
244
+ """True for a comment-only line that belongs to the definition below it (fmt markers never do)."""
245
+ if not tokens or not _is_comment_only(tokens):
246
+ return False
247
+ return tokens[0].text.strip() not in (FMT_OFF, FMT_ON)
248
+
249
+
250
+ def _definition_gap(tokens: list[Token], blank_run: int, starts_definition: bool, state: _State) -> int:
251
+ """Exactly one blank line before and after a definition, but none next to a brace or the file start.
252
+
253
+ Returns:
254
+ The number of blank lines to put before this line.
255
+ """
256
+ ended, state.definition_ended = state.definition_ended, False
257
+ if not (starts_definition or ended):
258
+ return blank_run
259
+ previous, first = state.previous, tokens[0]
260
+ if previous is None or (previous.kind == Kind.OP and previous.text == "{"):
261
+ return blank_run
262
+ if first.kind == Kind.OP and first.text in CLOSERS:
263
+ return blank_run
264
+ return 1
265
+
266
+
267
+ def _close_definitions(state: _State) -> None:
268
+ """Mark a definition as ended once its closing `;` or `}` brings the bracket depth back."""
269
+ previous = state.previous
270
+ while (
271
+ state.definition_depths
272
+ and len(state.stack) == state.definition_depths[-1]
273
+ and previous is not None
274
+ and previous.text in (";", "}")
275
+ ):
276
+ state.definition_depths.pop()
277
+ state.definition_ended = True
278
+
279
+
280
+ def _blank_lines(count: int, top_level: bool) -> list[_Line]:
281
+ """Up to count blank lines, capped by where they sit."""
282
+ allowed = MAX_BLANK_LINES_TOP if top_level else MAX_BLANK_LINES_NESTED
283
+ return [_Line("", "", blank=True) for _ in range(min(count, allowed))]
284
+
285
+
286
+ def _match_else(tokens: list[Token], level: int, state: _State) -> int:
287
+ """Put an `else` at the level of the unbraced `if` it belongs to, and track those `if`s.
288
+
289
+ Returns:
290
+ The line's level, corrected for an `else`.
291
+ """
292
+ frame, first = state.stack[-1], tokens[0]
293
+ if (first.kind == Kind.OP and first.text in CLOSERS) or _is_comment_only(tokens):
294
+ return level
295
+ is_else = first.kind == Kind.IDENT and first.text == "else"
296
+ if is_else and frame.if_levels:
297
+ level = frame.if_levels.pop()
298
+ elif frame.char in ("", "{") and level == frame.level + 1:
299
+ # A new statement ends every `if` at or below its level.
300
+ frame.if_levels = [pending for pending in frame.if_levels if pending < level]
301
+ starts_if = first.text == "if" or (is_else and len(tokens) > 1 and tokens[1].text == "if")
302
+ if first.kind == Kind.IDENT and starts_if:
303
+ frame.if_levels.append(level)
304
+ return level
305
+
306
+
307
+ def _is_import(tokens: list[Token]) -> bool:
308
+ """True for an `include <...>` or `use <...>` line."""
309
+ return len(tokens) > 1 and tokens[1].kind == Kind.PATH
310
+
311
+
312
+ def _is_comment_only(tokens: list[Token]) -> bool:
313
+ """True for a line holding only comments."""
314
+ return all(token.kind in (Kind.LINE_COMMENT, Kind.BLOCK_COMMENT) for token in tokens)
315
+
316
+
317
+ def _import_block_gap(tokens: list[Token], next_code_is_import: bool) -> tuple[int, bool]:
318
+ """Blank lines before a line that follows an import: none inside the block, exactly one after it.
319
+
320
+ Returns:
321
+ The blank line count and whether the line still belongs to the import block.
322
+ """
323
+ inside = _is_import(tokens) or (_is_comment_only(tokens) and next_code_is_import)
324
+ return (0 if inside else 1), inside
325
+
326
+
327
+ def _next_code_is_import(lines: list[tuple[list[Token], int, int]]) -> list[bool]:
328
+ """Per line: whether the next line with code after it is an import."""
329
+ result = [False] * len(lines)
330
+ following = False
331
+ for index in range(len(lines) - 1, -1, -1):
332
+ result[index] = following
333
+ tokens = lines[index][0]
334
+ if tokens and not _is_comment_only(tokens):
335
+ following = _is_import(tokens)
336
+ return result
337
+
338
+
339
+ def _join(line: _Line) -> str:
340
+ """Build one output line from its parts."""
341
+ if line.blank:
342
+ return ""
343
+ if line.comment is None:
344
+ return line.indent + line.code
345
+ if not line.code:
346
+ return line.indent + line.comment
347
+ return line.indent + line.code + " " * COMMENT_GAP + line.comment
348
+
349
+
350
+ def _align_comments(out: list[_Line]) -> None:
351
+ """Align trailing comments of consecutive lines to one column; a lone one keeps the default gap."""
352
+ run: list[_Line] = []
353
+ for line in [*out, None]:
354
+ if line is not None and not line.verbatim and line.code and line.comment is not None:
355
+ run.append(line)
356
+ continue
357
+ if len(run) > 1:
358
+ width = max(len(item.indent + item.code) for item in run)
359
+ for item in run:
360
+ item.code += " " * (width - len(item.indent + item.code))
361
+ run = []
362
+
363
+
364
+ def _line_level(first: Token, state: _State) -> int:
365
+ """Indent level of a line starting with first, given the state before it."""
366
+ top = state.stack[-1]
367
+ if first.kind == Kind.OP and first.text in CLOSERS:
368
+ return max(top.level, 0)
369
+ base = top.level + 1
370
+ if top.char in ("(", "["):
371
+ return base
372
+ previous = state.previous
373
+ starts_statement = (
374
+ previous is None
375
+ or previous.kind == Kind.PATH
376
+ or (previous.kind == Kind.OP and previous.text in STATEMENT_ENDS)
377
+ or (first.kind == Kind.OP and first.text == "{")
378
+ )
379
+ if starts_statement:
380
+ return base
381
+ # Expressions continue one level in; a chain of module calls nests one level per line.
382
+ return base + 1 if state.in_expression else max(state.line_anchor, top.level) + 1
383
+
384
+
385
+ def _render_tokens(tokens: list[Token], level: int, state: _State) -> tuple[str, str | None]:
386
+ """Render one line's tokens and update state.
387
+
388
+ Returns:
389
+ The code part and the trailing line comment, if any.
390
+ """
391
+ comment = None
392
+ if tokens[-1].kind == Kind.LINE_COMMENT:
393
+ comment = tokens[-1].text.rstrip()
394
+ tokens = tokens[:-1]
395
+ parts: list[str] = []
396
+ before: Token | None = None
397
+ before_role = ""
398
+ # A bracket opened after closing a multi-line `(` or `[` on this line belongs to the line that opened it.
399
+ anchor = level
400
+ for token in tokens:
401
+ role = _role(token, state)
402
+ text = _token_text(token, state.newline)
403
+ if before is not None and _needs_space(before, before_role, token, role):
404
+ parts.append(" ")
405
+ parts.append(text)
406
+ before, before_role = token, role
407
+ closed = _advance(token, role, anchor, state)
408
+ if closed is not None and closed.char in ("(", "["):
409
+ anchor = min(anchor, closed.level)
410
+ if any(token.kind != Kind.BLOCK_COMMENT for token in tokens):
411
+ state.line_anchor = anchor
412
+ return "".join(parts), comment
413
+
414
+
415
+ def _token_text(token: Token, newline: str) -> str:
416
+ """Token text as written to the output."""
417
+ if token.kind == Kind.BLOCK_COMMENT:
418
+ return newline.join(part.rstrip() for part in token.text.splitlines())
419
+ return token.text
420
+
421
+
422
+ def _role(token: Token, state: _State) -> str:
423
+ """Classify an operator by its position: binary, unary, modifier, range or plain."""
424
+ if token.kind != Kind.OP:
425
+ return ""
426
+ text = token.text
427
+ previous, frame = state.previous, state.stack[-1]
428
+ if text in MODIFIERS and _at_statement_start(state):
429
+ return "modifier"
430
+ if text in UNARY and (state.after_header or _expects_operand(previous)):
431
+ return "unary"
432
+ if text == ":":
433
+ return "binary" if frame.pending_ternaries else "range"
434
+ if text in OPENERS or text in CLOSERS or text in (",", ";", "."):
435
+ return ""
436
+ return "binary"
437
+
438
+
439
+ def _at_statement_start(state: _State) -> bool:
440
+ """True where a `#`, `%`, `!` or `*` is a modifier rather than an operator."""
441
+ if state.stack[-1].char not in ("", "{") or state.in_expression:
442
+ return False
443
+ previous = state.previous
444
+ if previous is None or state.previous_role == "modifier":
445
+ return True
446
+ if previous.kind == Kind.IDENT:
447
+ return previous.text == "else"
448
+ return previous.kind == Kind.OP and previous.text in (";", "{", "}", ")")
449
+
450
+
451
+ def _expects_operand(previous: Token | None) -> bool:
452
+ """True when the next token starts an operand, so `-` or `!` is unary."""
453
+ if previous is None:
454
+ return True
455
+ if previous.kind == Kind.IDENT:
456
+ return previous.text in ("else", "each")
457
+ return previous.kind == Kind.OP and previous.text not in (")", "]")
458
+
459
+
460
+ # pylint: disable-next=too-many-return-statements # One rule per line reads better than a nested condition
461
+ def _needs_space(left: Token, left_role: str, right: Token, right_role: str) -> bool:
462
+ """Whether one space separates left and right on the same line."""
463
+ lt, rt = left.text, right.text
464
+ if right.kind == Kind.OP and rt in (")", "]", ",", ";"):
465
+ return False
466
+ if left.kind == Kind.OP and lt in ("(", "[", "."):
467
+ return False
468
+ if right.kind == Kind.OP and rt == ".":
469
+ return False
470
+ if left_role == "unary" and right_role == "unary" and lt in "+-" and rt in "+-":
471
+ return True # `- -x`, never `--x`
472
+ if left_role in ("unary", "modifier") or "range" in (left_role, right_role):
473
+ return False
474
+ if "binary" in (left_role, right_role):
475
+ return True
476
+ if right.kind == Kind.OP and rt == "}":
477
+ return lt != "{"
478
+ if right.kind == Kind.OP and rt in ("(", "["):
479
+ return _space_before_bracket(left, rt)
480
+ return True
481
+
482
+
483
+ def _space_before_bracket(left: Token, bracket: str) -> bool:
484
+ """Whether `(` or `[` after left gets a space: calls and indexing stay tight."""
485
+ if left.kind == Kind.IDENT:
486
+ return left.text in SPACED_BEFORE_PAREN if bracket == "(" else left.text == "each"
487
+ if left.kind == Kind.OP and left.text in (")", "]"):
488
+ return False
489
+ return True
490
+
491
+
492
+ def _advance(token: Token, role: str, level: int, state: _State) -> _Frame | None:
493
+ """Update state after emitting token.
494
+
495
+ Returns:
496
+ The frame token closed, if it is a closing bracket.
497
+ """
498
+ if token.kind in (Kind.LINE_COMMENT, Kind.BLOCK_COMMENT):
499
+ return None
500
+ before = state.previous
501
+ state.previous, state.previous_role, state.after_header = token, role, False
502
+ if token.kind != Kind.OP:
503
+ return None
504
+ text, frame = token.text, state.stack[-1]
505
+ if text in OPENERS:
506
+ header = text == "(" and before is not None and before.kind == Kind.IDENT and before.text in HEADER_KEYWORDS
507
+ state.stack.append(_Frame(text, level, header=header))
508
+ if text == "{":
509
+ state.in_expression = False
510
+ elif text in CLOSERS:
511
+ if frame.char != CLOSERS[text]:
512
+ raise FormatError(f"{token.line}:{token.col}: unexpected {text!r}")
513
+ state.stack.pop()
514
+ state.after_header = frame.header
515
+ if text == "}":
516
+ state.in_expression = False
517
+ return frame
518
+ elif text == "?":
519
+ frame.pending_ternaries += 1
520
+ elif text == ":" and role == "binary":
521
+ frame.pending_ternaries -= 1
522
+ elif text == ";" and frame.char in ("", "{"):
523
+ state.in_expression = False
524
+ frame.pending_ternaries = 0
525
+ elif text == "=" and frame.char in ("", "{"):
526
+ state.in_expression = True
527
+ return None
scadfmt/py.typed ADDED
File without changes
scadfmt/tokenizer.py ADDED
@@ -0,0 +1,177 @@
1
+ """Split OpenSCAD source into tokens.
2
+
3
+ The formatter only ever changes the whitespace between tokens, so the token list is also its safety check:
4
+ the tokens of the formatted output must equal the tokens of the input.
5
+ """
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+
11
+
12
+ class Kind(Enum):
13
+ """Token kinds the formatter distinguishes."""
14
+
15
+ NEWLINE = "newline"
16
+ LINE_COMMENT = "line_comment"
17
+ BLOCK_COMMENT = "block_comment"
18
+ STRING = "string"
19
+ NUMBER = "number"
20
+ IDENT = "ident"
21
+ PATH = "path"
22
+ OP = "op"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Token:
27
+ """One token with the position of its first character (1-based)."""
28
+
29
+ kind: Kind
30
+ text: str
31
+ line: int
32
+ col: int
33
+
34
+
35
+ class TokenizeError(ValueError):
36
+ """Raised on input the tokenizer cannot split safely."""
37
+
38
+
39
+ # Longest operators first, so `<=` wins over `<`.
40
+ OPERATORS = (
41
+ "<<",
42
+ ">>",
43
+ "<=",
44
+ ">=",
45
+ "==",
46
+ "!=",
47
+ "&&",
48
+ "||",
49
+ "+",
50
+ "-",
51
+ "*",
52
+ "/",
53
+ "%",
54
+ "^",
55
+ "!",
56
+ "~",
57
+ "&",
58
+ "|",
59
+ "<",
60
+ ">",
61
+ "=",
62
+ "?",
63
+ ":",
64
+ ",",
65
+ ";",
66
+ ".",
67
+ "(",
68
+ ")",
69
+ "[",
70
+ "]",
71
+ "{",
72
+ "}",
73
+ "#",
74
+ )
75
+
76
+ _PATTERNS = [
77
+ # OpenSCAD also skips U+00A0 (no-break space) and U+FEFF (byte order mark).
78
+ (None, re.compile("[ \t\f\v\u00a0\ufeff]+")),
79
+ (Kind.NEWLINE, re.compile(r"\r\n|\r|\n")),
80
+ # Like OpenSCAD's lexer, a line comment ends at LF only; a trailing CR is stripped on output.
81
+ (Kind.LINE_COMMENT, re.compile(r"//[^\n]*")),
82
+ (Kind.BLOCK_COMMENT, re.compile(r"/\*.*?\*/", re.S)),
83
+ (Kind.STRING, re.compile(r'"(?:\\.|[^"\\])*"', re.S)),
84
+ (Kind.NUMBER, re.compile(r"0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?")),
85
+ # `$`, `$1` and Unicode letters (behind --enable=unicode-identifiers) are valid OpenSCAD identifiers too.
86
+ (Kind.IDENT, re.compile(r"\$\w*|[^\W\d]\w*")),
87
+ (Kind.OP, re.compile("|".join(re.escape(op) for op in OPERATORS))),
88
+ ]
89
+
90
+ # `include <path>` and `use <path>` take a path, not an expression.
91
+ _PATH = re.compile(r"<[^>\r\n]*>")
92
+ _PATH_KEYWORDS = ("include", "use")
93
+ _IDENT_CHAR = re.compile(r"[^\W\d]|\$")
94
+
95
+
96
+ def tokenize(source: str) -> list[Token]:
97
+ """Split source into tokens, dropping spaces and tabs but keeping newlines.
98
+
99
+ Args:
100
+ source: OpenSCAD source text.
101
+
102
+ Returns:
103
+ The tokens in source order.
104
+
105
+ Raises:
106
+ TokenizeError: On a character that starts no known token, or an unterminated string or comment.
107
+ """
108
+ tokens: list[Token] = []
109
+ pos, line, line_start = 0, 1, 0
110
+ while pos < len(source):
111
+ col = pos - line_start + 1
112
+ previous = _last_significant(tokens)
113
+ if previous and previous.kind == Kind.IDENT and previous.text in _PATH_KEYWORDS:
114
+ match = _PATH.match(source, pos)
115
+ if match:
116
+ tokens.append(Token(Kind.PATH, match.group(), line, col))
117
+ pos = match.end()
118
+ continue
119
+ for kind, pattern in _PATTERNS:
120
+ match = pattern.match(source, pos)
121
+ if match:
122
+ break
123
+ else:
124
+ raise TokenizeError(_unknown_message(source, pos, line, col))
125
+ if kind == Kind.OP and source.startswith("/*", pos):
126
+ raise TokenizeError(_unknown_message(source, pos, line, col))
127
+ if kind == Kind.NUMBER and _IDENT_CHAR.match(source, match.end()):
128
+ # OpenSCAD reads `1abc` as one (deprecated) identifier; splitting it would change the code.
129
+ raise TokenizeError(f"{line}:{col}: identifiers starting with a digit are not supported")
130
+ text = match.group()
131
+ if kind is not None:
132
+ tokens.append(Token(kind, "\n" if kind == Kind.NEWLINE else text, line, col))
133
+ newlines = 1 if kind == Kind.NEWLINE else text.count("\n")
134
+ if newlines:
135
+ line += newlines
136
+ line_start = pos + len(text) if kind == Kind.NEWLINE else pos + text.rfind("\n") + 1
137
+ pos = match.end()
138
+ return tokens
139
+
140
+
141
+ def _last_significant(tokens: list[Token]) -> Token | None:
142
+ """Return the last token that is neither a newline nor a comment."""
143
+ for token in reversed(tokens):
144
+ if token.kind not in (Kind.NEWLINE, Kind.LINE_COMMENT, Kind.BLOCK_COMMENT):
145
+ return token
146
+ return None
147
+
148
+
149
+ def _unknown_message(source: str, pos: int, line: int, col: int) -> str:
150
+ """Describe why tokenizing stopped at pos."""
151
+ if source.startswith("/*", pos):
152
+ return f"{line}:{col}: unterminated block comment"
153
+ if source[pos] == '"':
154
+ return f"{line}:{col}: unterminated string"
155
+ return f"{line}:{col}: unexpected character {source[pos]!r}"
156
+
157
+
158
+ def significant(tokens: list[Token]) -> list[tuple[Kind, str]]:
159
+ """Reduce tokens to what must survive formatting unchanged.
160
+
161
+ Newlines drop out, and trailing whitespace inside comments is ignored because the formatter strips it.
162
+
163
+ Args:
164
+ tokens: Tokens from `tokenize`.
165
+
166
+ Returns:
167
+ (kind, text) pairs to compare before and after formatting.
168
+ """
169
+ result = []
170
+ for token in tokens:
171
+ if token.kind == Kind.NEWLINE:
172
+ continue
173
+ text = token.text
174
+ if token.kind in (Kind.LINE_COMMENT, Kind.BLOCK_COMMENT):
175
+ text = "\n".join(part.rstrip() for part in text.splitlines())
176
+ result.append((token.kind, text))
177
+ return result
scadfmt/vscode.py ADDED
@@ -0,0 +1,101 @@
1
+ """Register scadfmt as the VS Code formatter for OpenSCAD files."""
2
+
3
+ import json
4
+ import logging
5
+ import platform
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from scadfmt.fileio import write_atomically
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ EXTENSION = "jkillian.custom-local-formatters"
16
+ LANGUAGE = "scad"
17
+
18
+
19
+ def formatter_command() -> str:
20
+ """Command VS Code runs to format a document: this interpreter, so it works in any install location.
21
+
22
+ Returns:
23
+ A shell command reading source on stdin and writing it formatted to stdout.
24
+ """
25
+ return f'"{sys.executable}" -m scadfmt format -'
26
+
27
+
28
+ def merge_settings(settings: dict) -> dict:
29
+ """Add the scadfmt formatter to VS Code settings, replacing any other formatter for `scad`.
30
+
31
+ Args:
32
+ settings: Existing settings.json content.
33
+
34
+ Returns:
35
+ The updated settings (the same dict).
36
+ """
37
+ formatters = [
38
+ entry
39
+ for entry in settings.get("customLocalFormatters.formatters", [])
40
+ if LANGUAGE not in entry.get("languages", [])
41
+ ]
42
+ formatters.append({"command": formatter_command(), "languages": [LANGUAGE]})
43
+ settings["customLocalFormatters.formatters"] = formatters
44
+ settings.setdefault(f"[{LANGUAGE}]", {})["editor.defaultFormatter"] = EXTENSION
45
+ return settings
46
+
47
+
48
+ def _install_extension() -> bool:
49
+ """Install the Custom Local Formatters extension with the `code` CLI."""
50
+ if not shutil.which("code"):
51
+ logger.error("VS Code CLI 'code' not found. Install VS Code and enable 'Add to PATH'.")
52
+ return False
53
+ try:
54
+ subprocess.run(
55
+ ["code", "--install-extension", EXTENSION, "--force"],
56
+ check=True,
57
+ capture_output=True,
58
+ text=True,
59
+ # code is a .cmd script on Windows, which only the shell resolves.
60
+ shell=platform.system() == "Windows",
61
+ )
62
+ except subprocess.CalledProcessError as e:
63
+ logger.error("Failed to install %s: %s", EXTENSION, e.stderr)
64
+ return False
65
+ logger.info("Installed %s", EXTENSION)
66
+ return True
67
+
68
+
69
+ def setup_vscode(workspace: Path) -> bool:
70
+ """Install the extension and point it at scadfmt in the workspace settings.
71
+
72
+ Args:
73
+ workspace: Workspace folder; its .vscode/settings.json is created or updated.
74
+
75
+ Returns:
76
+ True on success, False otherwise.
77
+ """
78
+ settings_file = workspace / ".vscode" / "settings.json"
79
+ settings = {}
80
+ if settings_file.exists():
81
+ try:
82
+ settings = json.loads(settings_file.read_text(encoding="utf-8"))
83
+ except json.JSONDecodeError as e:
84
+ # Comments or trailing commas: refuse rather than overwrite hand-written settings.
85
+ logger.error(
86
+ "%s is not plain JSON (%s). Add the settings from the scadfmt README by hand.", settings_file, e
87
+ )
88
+ return False
89
+ except (OSError, UnicodeDecodeError) as e:
90
+ logger.error("Cannot read %s: %s", settings_file, e)
91
+ return False
92
+ if not _install_extension():
93
+ return False
94
+ try:
95
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
96
+ write_atomically(settings_file, json.dumps(merge_settings(settings), indent=2, sort_keys=True) + "\n")
97
+ except OSError as e:
98
+ logger.error("Cannot write %s: %s", settings_file, e)
99
+ return False
100
+ logger.info("Updated %s, scadfmt now formats .scad files (Format Document)", settings_file)
101
+ return True
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: scadfmt
3
+ Version: 0.1.0
4
+ Summary: Opinionated formatter for OpenSCAD code
5
+ Author-email: Patrick Pötz <kellervater@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kellerlabs/homeracker
8
+ Project-URL: Repository, https://github.com/kellerlabs/homeracker
9
+ Project-URL: Documentation, https://github.com/kellerlabs/homeracker/tree/main/cmd/scadfmt
10
+ Project-URL: Issues, https://github.com/kellerlabs/homeracker/issues
11
+ Project-URL: Changelog, https://github.com/kellerlabs/homeracker/blob/main/cmd/scadfmt/CHANGELOG.md
12
+ Keywords: openscad,formatter,scad,code-style,pre-commit
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+
24
+ # 🎨 scadfmt
25
+
26
+ ## 📌 What
27
+
28
+ An opinionated formatter for OpenSCAD code. It fixes indentation and spacing, puts blocks and statements on their own lines, and never joins lines or changes what the code does.
29
+
30
+ ## 🤔 Why
31
+
32
+ Community contributions need one code style without style debates in review. Existing formatters rewrite code they do not understand (dropping operators, breaking `include` paths) or join hand-wrapped lines into very long ones. scadfmt only needs to know OpenSCAD's tokens, so new syntax rarely affects it, and it refuses to write output whose tokens differ from the input. See [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md).
33
+
34
+ ## 🔧 How
35
+
36
+ ### 📦 Install
37
+
38
+ Requires Python 3.11 or newer, no other dependencies.
39
+
40
+ ```bash
41
+ pip install scadfmt # from PyPI
42
+ pip install -e cmd/scadfmt # from this repo
43
+ ```
44
+
45
+ ### ▶️ Usage
46
+
47
+ ```bash
48
+ scadfmt format models/ # format files in place, directories recursively
49
+ scadfmt format --check models/ # write nothing, exit 1 if a file would change
50
+ scadfmt format --diff part.scad # write nothing, print a diff
51
+ scadfmt format - < in.scad > out.scad # stdin to stdout
52
+ ```
53
+
54
+ Exit codes: `0` clean, `1` files would change (`--check`), `2` error. On an error (unknown character, unbalanced brackets) the file stays untouched.
55
+
56
+ ### 👀 Before and After
57
+
58
+ ```openscad
59
+ include<BOSL2/std.scad>
60
+ wall=2;// wall strength
61
+ height_units=3; // rack units
62
+ /**
63
+ * Bracket holding a device of the given size.
64
+ * center: centers the body on the origin
65
+ */
66
+ module bracket(width=10,depth=20,center=false){
67
+ size=[width,depth,wall*height_units];
68
+ if(center){translate(-size/2)cube(size);}else{cube(size);}
69
+ for(i=[0:2:width])
70
+ translate([i,0,0])
71
+ rotate([0,0,-90])
72
+ #cylinder(h=wall,r=1);
73
+ }
74
+ ```
75
+
76
+ becomes
77
+
78
+ ```openscad
79
+ include <BOSL2/std.scad>
80
+
81
+ wall = 2; // wall strength
82
+ height_units = 3; // rack units
83
+
84
+ /**
85
+ * Bracket holding a device of the given size.
86
+ * center: centers the body on the origin
87
+ */
88
+ module bracket(width = 10, depth = 20, center = false) {
89
+ size = [width, depth, wall * height_units];
90
+ if (center) {
91
+ translate(-size / 2) cube(size);
92
+ } else {
93
+ cube(size);
94
+ }
95
+ for (i = [0:2:width])
96
+ translate([i, 0, 0])
97
+ rotate([0, 0, -90])
98
+ #cylinder(h = wall, r = 1);
99
+ }
100
+ ```
101
+
102
+ ### 📏 Rules
103
+
104
+ | Rule | Example |
105
+ |---|---|
106
+ | 2 spaces per open `{ ( [`, one level per line that opens them | `cube([`↵` 1,`↵`]);` |
107
+ | A line continuing a module call nests one level further | `translate(v)`↵` cube();` |
108
+ | A line continuing an expression is one level in | `x =`↵` a +`↵` b;` |
109
+ | Spaces around every binary operator and every `=` | `cube(size = w * 2, center = true);` |
110
+ | Tight unary operators, modifiers, calls, indexing | `-x`, `!a`, `#cube()`, `f(a)[0]` |
111
+ | Tight range colons, spaced ternary colons | `[0:2:10]`, `a ? b : c` |
112
+ | `if`, `for`, `intersection_for`, `function` get a space before `(` | `for (i = [0:2])` |
113
+ | Space after commas, none inside brackets | `f(a, [1, 2])` |
114
+ | Block contents on their own lines, `}` on its own line except `} else`, empty `{}` stays | `if (a) {`↵` b();`↵`} else {` |
115
+ | One statement per line (`;` inside `for (...)` excepted) | `a();`↵`b();` |
116
+ | Exactly one blank line before and after each `module` and `function` definition, none next to a brace | `x = 1;`↵↵`module m() {` |
117
+ | Imports form one block without blank lines, followed by exactly one blank line | `include <a.scad>`↵`use <b.scad>`↵↵`x = 1;` |
118
+ | Trailing comments on consecutive lines share one column, a lone one gets 2 spaces | `x = 1; // note` |
119
+ | At most 2 blank lines at top level, 1 inside blocks | |
120
+ | Keeps the file's line endings (LF or CRLF, judged by the first one), no trailing whitespace, one final newline | |
121
+
122
+ Comments (`//` or `/* */`) directly above a line belong to it, so a blank line added before that line goes above its comments. Lines are never joined and line length is never limited.
123
+
124
+ ### 🙈 Opting Out
125
+
126
+ Lines between `// fmt: off` and `// fmt: on` stay as written, for example a hand-aligned matrix:
127
+
128
+ ```openscad
129
+ // fmt: off
130
+ identity = [
131
+ 1, 0, 0,
132
+ 0, 1, 0,
133
+ ];
134
+ // fmt: on
135
+ ```
136
+
137
+ ### 🪝 Pre-commit
138
+
139
+ In another repository, install scadfmt from PyPI through a local hook:
140
+
141
+ ```yaml
142
+ - repo: local
143
+ hooks:
144
+ - id: scadfmt
145
+ name: scadfmt
146
+ entry: scadfmt format
147
+ language: python
148
+ additional_dependencies: [scadfmt==0.1.0]
149
+ files: \.scad$
150
+ ```
151
+
152
+ ### 🖥️ VS Code
153
+
154
+ ```bash
155
+ scadfmt vscode
156
+ ```
157
+
158
+ Installs the [Custom Local Formatters](https://marketplace.visualstudio.com/items?itemName=jkillian.custom-local-formatters) extension and makes scadfmt the default formatter for `.scad` files in `.vscode/settings.json`, so **Format Document** (`Shift+Alt+F`) runs it. It refuses to touch a `settings.json` with comments; add the settings by hand then:
159
+
160
+ ```jsonc
161
+ "customLocalFormatters.formatters": [{ "command": "\"/path/to/python\" -m scadfmt format -", "languages": ["scad"] }],
162
+ "[scad]": { "editor.defaultFormatter": "jkillian.custom-local-formatters" }
163
+ ```
164
+
165
+ For formatting on save, add `"editor.formatOnSave": true` to the `[scad]` block.
166
+
167
+ ### 🧪 Tests
168
+
169
+ See [TESTING.md](../../TESTING.md#scadfmt-tests). `tests/canary/` holds a file using every OpenSCAD construct and its expected output; `check.sh` checks both against the pinned OpenSCAD.
170
+
171
+ ## 📚 References
172
+
173
+ - [#177](https://github.com/kellerlabs/homeracker/issues/177): introduce an OpenSCAD formatter
174
+ - [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md): why scadfmt exists and how it works
175
+ - [OpenSCAD language reference](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/The_OpenSCAD_Language)
@@ -0,0 +1,13 @@
1
+ scadfmt/__init__.py,sha256=GZdF8FtTWfRy-vEOcNUDVq0rNpauamj8QmIJAmWD1m4,320
2
+ scadfmt/__main__.py,sha256=PAqkKibwVb-FpUwreY-Q1aBtFz0RvRb8bDCq8qOtHSc,102
3
+ scadfmt/cli.py,sha256=4s1EzV9UL9W6PCgjtTF_8US6Z6bDI-MQXrUeZPhSY4Q,4948
4
+ scadfmt/fileio.py,sha256=I08qEcmRBXteS0DuL5Mn2nTE3DLYBWSF6iK8lbcU2CM,1069
5
+ scadfmt/formatter.py,sha256=tTSLIMFwJMwTnu9wGNTCx2a6XrWdP5MY9rjJnCvRm_A,20587
6
+ scadfmt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ scadfmt/tokenizer.py,sha256=KxIj2zad0j7xtE6XcxOeAz1uUHb5txFNc0FD7Pk1xL4,5366
8
+ scadfmt/vscode.py,sha256=cMfCHlOaRkrxe1mW6tZaaVH_3wSTocy9NPL_wcDkmWY,3490
9
+ scadfmt-0.1.0.dist-info/METADATA,sha256=EP2BFsguGJS-debMeXW1PG3kJGtIABnNd6W3hNFMpPU,6841
10
+ scadfmt-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ scadfmt-0.1.0.dist-info/entry_points.txt,sha256=MNxTqcXqMiB80h0q1-ddIUzf1GKkGDh0VvE9vcNSB2Y,45
12
+ scadfmt-0.1.0.dist-info/top_level.txt,sha256=y7TxvZfW2J1BC0CZQLOElr70tIDFpQcP1PLGPw_pVYo,8
13
+ scadfmt-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ scadfmt = scadfmt.cli:main
@@ -0,0 +1 @@
1
+ scadfmt