fprime-cpp-codegen 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.
@@ -0,0 +1,323 @@
1
+ """The immutable line model and the line algebra built on top of it.
2
+
3
+ A generated C++ file is a ``list[Line]``. A :class:`Line` carries its indentation
4
+ separately from its text, so a continuation line can be aligned to an arbitrary
5
+ column of the line before it and a blank line renders empty rather than as trailing
6
+ whitespace.
7
+
8
+ Every function here is pure, and none of them know anything about C++.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterable, Sequence
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from typing import TypeVar
17
+
18
+ __all__ = [
19
+ "INDENT_INCREMENT",
20
+ "IndentMode",
21
+ "Line",
22
+ "add_blank_postfix",
23
+ "add_blank_prefix",
24
+ "add_postfix_line",
25
+ "add_prefix",
26
+ "add_prefix_and_suffix",
27
+ "add_prefix_indent",
28
+ "add_prefix_line",
29
+ "add_separators",
30
+ "add_suffix",
31
+ "blank",
32
+ "blank_separated",
33
+ "flatten",
34
+ "flatten_with_prefix_line",
35
+ "indent_lines",
36
+ "intersperse",
37
+ "intersperse_blank_lines",
38
+ "join",
39
+ "join_lists",
40
+ "line",
41
+ "lines",
42
+ "lines_opt",
43
+ "render",
44
+ "strip_margin",
45
+ "wrap_in_scope",
46
+ ]
47
+
48
+ _T = TypeVar("_T")
49
+
50
+ #: The number of spaces one level of indentation adds.
51
+ INDENT_INCREMENT = 2
52
+
53
+
54
+ class IndentMode(Enum):
55
+ """How :func:`join_lists` treats the tail of the second list of lines.
56
+
57
+ ``INDENT`` re-indents the tail to line up under the join column, aligning a
58
+ doxygen post-comment beneath its parameter. ``NO_INDENT`` leaves the tail where
59
+ it is, for gluing a suffix such as ``;`` onto a multi-line signature.
60
+ """
61
+
62
+ INDENT = "indent"
63
+ NO_INDENT = "no-indent"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Line:
68
+ """A single line of output: some text plus the column it starts at.
69
+
70
+ ``indent`` is a raw space count and may go negative, which renders as no indent.
71
+ Access tags rely on that, being emitted at the class-body indent then shifted out
72
+ by two.
73
+ """
74
+
75
+ string: str = ""
76
+ indent: int = 0
77
+
78
+ def __str__(self) -> str:
79
+ """Render the line. An empty line renders empty, never as whitespace."""
80
+ if not self.string:
81
+ return ""
82
+ return " " * self.indent + self.string
83
+
84
+ def indent_in(self, n: int = INDENT_INCREMENT) -> Line:
85
+ """Return this line indented in by ``n`` spaces."""
86
+ return Line(self.string, self.indent + n)
87
+
88
+ def indent_out(self, n: int = INDENT_INCREMENT) -> Line:
89
+ """Return this line indented out by ``n`` spaces."""
90
+ return Line(self.string, self.indent - n)
91
+
92
+ def indent_to(self, n: int) -> Line:
93
+ """Return this line at the absolute indent ``n``."""
94
+ return Line(self.string, n)
95
+
96
+ @property
97
+ def size(self) -> int:
98
+ """The rendered width of the line, indentation included, newline excluded.
99
+
100
+ The column :func:`join_lists` aligns against. Computed from the raw indent,
101
+ so a negative indent shrinks it.
102
+ """
103
+ return self.indent + len(self.string)
104
+
105
+
106
+ def line(s: str) -> Line:
107
+ """Construct a single unindented line."""
108
+ return Line(s)
109
+
110
+
111
+ def blank() -> Line:
112
+ """Construct a blank line."""
113
+ return Line()
114
+
115
+
116
+ def strip_margin(s: str, margin: str = "|") -> str:
117
+ """Strip a leading margin from every line of ``s``.
118
+
119
+ For each line, leading whitespace and control characters are skipped; if the next
120
+ character is ``margin``, it and everything before it are dropped. A line with no
121
+ margin marker is left untouched, including its leading whitespace -- so
122
+ ``"x = a | b;"`` survives intact.
123
+ """
124
+ out: list[str] = []
125
+ for part in s.split("\n"):
126
+ i = 0
127
+ n = len(part)
128
+ while i < n and part[i] <= " ":
129
+ i += 1
130
+ if i < n and part[i] == margin:
131
+ out.append(part[i + 1 :])
132
+ else:
133
+ out.append(part)
134
+ return "\n".join(out)
135
+
136
+
137
+ def _split_lines(s: str) -> list[str]:
138
+ """Split ``s`` on newlines, discarding trailing empty fields.
139
+
140
+ A string ending in a newline yields no spurious blank line, but the empty string
141
+ still yields one empty field. Interior blank lines are preserved.
142
+ """
143
+ if s == "":
144
+ return [""]
145
+ parts = s.split("\n")
146
+ while parts and parts[-1] == "":
147
+ parts.pop()
148
+ return parts
149
+
150
+
151
+ def lines(s: str) -> list[Line]:
152
+ """Convert a (possibly margin-stripped, possibly multi-line) string to lines.
153
+
154
+ ``lines("\\n|#ifndef X\\n|#define X")`` yields a leading blank line followed by the
155
+ two directives.
156
+ """
157
+ return [Line(part) for part in _split_lines(strip_margin(s))]
158
+
159
+
160
+ def lines_opt(f: Callable[[_T], list[Line]], value: _T | None) -> list[Line]:
161
+ """Apply ``f`` to ``value`` if it is not ``None``, else return no lines."""
162
+ return [] if value is None else f(value)
163
+
164
+
165
+ def indent_lines(ll: Iterable[Line], n: int = INDENT_INCREMENT) -> list[Line]:
166
+ """Indent every line in ``ll`` in by ``n`` spaces."""
167
+ return [l.indent_in(n) for l in ll]
168
+
169
+
170
+ def join(sep: str, l1: Line, l2: Line) -> Line:
171
+ """Concatenate two lines' text with ``sep``, keeping the first line's indent."""
172
+ return Line(l1.string + sep + l2.string, l1.indent)
173
+
174
+
175
+ def join_lists(
176
+ mode: IndentMode,
177
+ lines1: Sequence[Line],
178
+ sep: str,
179
+ lines2: Sequence[Line],
180
+ ) -> list[Line]:
181
+ """Glue two blocks of lines together at their seam.
182
+
183
+ The last line of ``lines1`` and the first of ``lines2`` merge into one line joined
184
+ by ``sep``. Under :attr:`IndentMode.INDENT` the remaining lines of ``lines2`` are
185
+ indented by the width of that seam, so they hang under the join column. Either
186
+ block being empty short-circuits to the other.
187
+ """
188
+ if not lines2:
189
+ return list(lines1)
190
+ if not lines1:
191
+ return list(lines2)
192
+ head1, last1 = list(lines1[:-1]), lines1[-1]
193
+ first2, rest2 = lines2[0], list(lines2[1:])
194
+ joined = join(sep, last1, first2)
195
+ if mode is IndentMode.INDENT:
196
+ rest2 = indent_lines(rest2, last1.size + len(sep))
197
+ return head1 + [joined] + rest2
198
+
199
+
200
+ def add_prefix(prefix: str, ll: Sequence[Line]) -> list[Line]:
201
+ """Prepend ``prefix`` to the first line of ``ll``, without re-indenting."""
202
+ return join_lists(IndentMode.NO_INDENT, [Line(prefix)], "", ll)
203
+
204
+
205
+ def add_prefix_indent(prefix: str, ll: Sequence[Line]) -> list[Line]:
206
+ """Prepend ``prefix`` to the first line of ``ll``, hanging the rest under it."""
207
+ return join_lists(IndentMode.INDENT, [Line(prefix)], "", ll)
208
+
209
+
210
+ def add_suffix(ll: Sequence[Line], suffix: str) -> list[Line]:
211
+ """Append ``suffix`` to the last line of ``ll``."""
212
+ return join_lists(IndentMode.NO_INDENT, ll, "", [Line(suffix)])
213
+
214
+
215
+ def add_prefix_and_suffix(prefix: str, ll: Sequence[Line], suffix: str) -> list[Line]:
216
+ """Append ``suffix`` to the last line and prepend ``prefix`` to the first."""
217
+ return add_prefix(prefix, add_suffix(ll, suffix))
218
+
219
+
220
+ def add_prefix_line(prefix: Line, ll: Sequence[Line]) -> list[Line]:
221
+ """Prepend ``prefix`` as its own line, but only if ``ll`` is non-empty."""
222
+ return [prefix, *ll] if ll else []
223
+
224
+
225
+ def add_postfix_line(postfix: Line, ll: Sequence[Line]) -> list[Line]:
226
+ """Append ``postfix`` as its own line, but only if ``ll`` is non-empty."""
227
+ return [*ll, postfix] if ll else []
228
+
229
+
230
+ def add_blank_prefix(ll: Sequence[Line]) -> list[Line]:
231
+ """Prepend a blank line, but only if ``ll`` is non-empty."""
232
+ return add_prefix_line(blank(), ll)
233
+
234
+
235
+ def add_blank_postfix(ll: Sequence[Line]) -> list[Line]:
236
+ """Append a blank line, but only if ``ll`` is non-empty."""
237
+ return add_postfix_line(blank(), ll)
238
+
239
+
240
+ def flatten(sep: str, ll: Sequence[Line]) -> Line:
241
+ """Collapse ``ll`` into a single line, joining the text with ``sep``."""
242
+ if not ll:
243
+ return blank()
244
+ result = ll[-1]
245
+ for l in reversed(ll[:-1]):
246
+ result = join(sep, l, result)
247
+ return result
248
+
249
+
250
+ def flatten_with_prefix_line(prefix: Line, lll: Iterable[Sequence[Line]]) -> list[Line]:
251
+ """Flatten a list of blocks, prefixing each non-empty block with ``prefix``."""
252
+ out: list[Line] = []
253
+ for ll in lll:
254
+ out.extend(add_prefix_line(prefix, ll))
255
+ return out
256
+
257
+
258
+ def blank_separated(f: Callable[[_T], list[Line]], items: Sequence[_T]) -> list[Line]:
259
+ """Map ``f`` over ``items`` and separate the results with single blank lines.
260
+
261
+ Empty results still contribute a separator; :func:`intersperse_blank_lines` drops
262
+ them.
263
+ """
264
+ out: list[Line] = []
265
+ for i, item in enumerate(items):
266
+ if i:
267
+ out.append(blank())
268
+ out.extend(f(item))
269
+ return out
270
+
271
+
272
+ def intersperse(items: Sequence[_T], element: _T) -> list[_T]:
273
+ """Insert ``element`` between every pair of adjacent items."""
274
+ if len(items) <= 1:
275
+ return list(items)
276
+ out: list[_T] = [items[0]]
277
+ for item in items[1:]:
278
+ out.append(element)
279
+ out.append(item)
280
+ return out
281
+
282
+
283
+ def intersperse_blank_lines(lll: Iterable[Sequence[Line]]) -> list[Line]:
284
+ """Flatten a list of blocks with one blank line between them, dropping empty
285
+ blocks so they do not produce doubled blanks."""
286
+ blocks = [list(ll) for ll in lll if ll]
287
+ out: list[Line] = []
288
+ for i, block in enumerate(blocks):
289
+ if i:
290
+ out.append(blank())
291
+ out.extend(block)
292
+ return out
293
+
294
+
295
+ def add_separators(sep: str, ll: Sequence[Line]) -> list[Line]:
296
+ """Append ``sep`` to every line except the last. Useful for comma lists."""
297
+ last = len(ll) - 1
298
+ return [Line(l.string + sep, l.indent) if i < last else l for i, l in enumerate(ll)]
299
+
300
+
301
+ def wrap_in_scope(
302
+ opening: str,
303
+ body: Sequence[Line],
304
+ closing: str,
305
+ *,
306
+ keep_empty: bool = False,
307
+ ) -> list[Line]:
308
+ """Indent ``body`` one level between an ``opening`` and ``closing`` line.
309
+
310
+ An empty body yields nothing unless ``keep_empty`` is set, so a conditional block
311
+ with no content disappears instead of leaving empty braces.
312
+ """
313
+ if not body and not keep_empty:
314
+ return []
315
+ return [*lines(opening), *indent_lines(body), *lines(closing)]
316
+
317
+
318
+ def render(ll: Iterable[Line]) -> str:
319
+ """Render lines to file text, newline-separated and newline-terminated."""
320
+ items = list(ll)
321
+ if not items:
322
+ return ""
323
+ return "\n".join(str(l) for l in items) + "\n"
@@ -0,0 +1,121 @@
1
+ """Turning a document into files on disk.
2
+
3
+ A file whose contents already match is left alone, keeping its mtime stable so a
4
+ no-op regeneration does not cascade a rebuild downstream. Pass
5
+ ``skip_unchanged=False`` to always write.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from .doc import Class, ClassMember, CppDoc, Member, Namespace
15
+ from .formatting import Formatter
16
+ from .writer import render_cpp, render_hpp
17
+
18
+ __all__ = ["WriteResult", "collect_cpp_files", "doc_files", "write_doc"]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WriteResult:
23
+ """What :func:`write_doc` did."""
24
+
25
+ written: list[Path] = field(default_factory=list)
26
+ """Files created or updated."""
27
+
28
+ unchanged: list[Path] = field(default_factory=list)
29
+ """Files that already had the right contents and were left alone."""
30
+
31
+ @property
32
+ def all(self) -> list[Path]:
33
+ """Every file the document owns, written or not, in generation order."""
34
+ return sorted([*self.written, *self.unchanged])
35
+
36
+
37
+ def collect_cpp_files(doc: CppDoc) -> list[str]:
38
+ """Find every supplemental source file ``doc`` assigns definitions to.
39
+
40
+ Returns base names without extensions, in the order they first appear, and never
41
+ the document's own default file. Rendering uses this to discover its own
42
+ outputs; a file left unnamed would lose every definition assigned to it.
43
+ """
44
+ default_base = doc.cpp_file_name.rsplit(".", 1)[0]
45
+ found: list[str] = []
46
+
47
+ def visit(members: Sequence[Member | ClassMember]) -> None:
48
+ for m in members:
49
+ base = getattr(m, "cpp_file", None)
50
+ if base is not None and base != default_base and base not in found:
51
+ found.append(base)
52
+ if isinstance(m, (Class, Namespace)):
53
+ visit(m.members)
54
+
55
+ visit(doc.members)
56
+ return found
57
+
58
+
59
+ def doc_files(
60
+ doc: CppDoc,
61
+ cpp_files: Sequence[str] | None = None,
62
+ *,
63
+ formatter: Formatter | None = None,
64
+ ) -> dict[str, str]:
65
+ """Render a document to a mapping of file name to text.
66
+
67
+ Always produces the header and the document's default source file.
68
+ ``cpp_files`` names additional source files by base name, without extension;
69
+ each gets only the definitions assigned to it. Left as ``None``, the
70
+ supplemental files are discovered from the document itself.
71
+
72
+ ``formatter`` post-processes each file, receiving its text and its name; see
73
+ :mod:`fprime_cpp_codegen.formatting`.
74
+ """
75
+ if cpp_files is None:
76
+ cpp_files = collect_cpp_files(doc)
77
+ out = {
78
+ doc.hpp_file.name: render_hpp(doc),
79
+ doc.cpp_file_name: render_cpp(doc),
80
+ }
81
+ for base in cpp_files:
82
+ out[f"{base}.cpp"] = render_cpp(doc, base)
83
+ if formatter is not None:
84
+ out = {name: formatter(text, name) for name, text in out.items()}
85
+ return out
86
+
87
+
88
+ def write_doc(
89
+ doc: CppDoc,
90
+ directory: str | Path = ".",
91
+ cpp_files: Sequence[str] | None = None,
92
+ *,
93
+ formatter: Formatter | None = None,
94
+ skip_unchanged: bool = True,
95
+ encoding: str = "utf-8",
96
+ ) -> WriteResult:
97
+ """Write a document's header and source files into ``directory``.
98
+
99
+ The directory is created if it does not exist. See :func:`doc_files` for how
100
+ ``cpp_files`` selects supplemental source files and what ``formatter`` does.
101
+
102
+ Formatting happens before the unchanged check, so a file already holding the
103
+ formatted text is still left alone.
104
+ """
105
+ root = Path(directory)
106
+ root.mkdir(parents=True, exist_ok=True)
107
+ written: list[Path] = []
108
+ unchanged: list[Path] = []
109
+ for name, text in doc_files(doc, cpp_files, formatter=formatter).items():
110
+ path = root / name
111
+ if (
112
+ skip_unchanged
113
+ and path.is_file()
114
+ and path.read_text(encoding=encoding) == text
115
+ ):
116
+ unchanged.append(path)
117
+ continue
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ path.write_text(text, encoding=encoding)
120
+ written.append(path)
121
+ return WriteResult(written, unchanged)
File without changes