opencrbasic 1.0.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.
Files changed (117) hide show
  1. opencrbasic/__init__.py +3 -0
  2. opencrbasic/__main__.py +5 -0
  3. opencrbasic/analysis.py +184 -0
  4. opencrbasic/bindings.py +1034 -0
  5. opencrbasic/catalog.py +173 -0
  6. opencrbasic/checkconfig.py +144 -0
  7. opencrbasic/checker/__init__.py +977 -0
  8. opencrbasic/checker/cr3xx.py +3118 -0
  9. opencrbasic/checker/ess.py +81 -0
  10. opencrbasic/checker/expressions.py +3581 -0
  11. opencrbasic/checker/instr_data.py +1802 -0
  12. opencrbasic/checker/instr_measure.py +1547 -0
  13. opencrbasic/checker/instr_rules.py +3540 -0
  14. opencrbasic/checker/instructions.py +17 -0
  15. opencrbasic/checker/intervals.py +678 -0
  16. opencrbasic/checker/longconv.py +133 -0
  17. opencrbasic/checker/scan_time.py +580 -0
  18. opencrbasic/checker/storage.py +456 -0
  19. opencrbasic/checker/structs.py +240 -0
  20. opencrbasic/checker/structure.py +6213 -0
  21. opencrbasic/checker/symbols.py +609 -0
  22. opencrbasic/checker/terminals.py +249 -0
  23. opencrbasic/checker/unused.py +186 -0
  24. opencrbasic/checker/var_types.py +564 -0
  25. opencrbasic/checker/warnings.py +661 -0
  26. opencrbasic/checks.py +491 -0
  27. opencrbasic/cli.py +391 -0
  28. opencrbasic/commands.py +128 -0
  29. opencrbasic/compiler.py +320 -0
  30. opencrbasic/consteval.py +327 -0
  31. opencrbasic/data/background_calibration.json +4 -0
  32. opencrbasic/data/before_beginprog.json +99 -0
  33. opencrbasic/data/cr1000x/background_calibration.json +4 -0
  34. opencrbasic/data/cr1000x/before_beginprog.json +138 -0
  35. opencrbasic/data/cr1000x/ess_variables.json +1087 -0
  36. opencrbasic/data/cr1000x/fnotch.json +29 -0
  37. opencrbasic/data/cr1000x/functions.json +1337 -0
  38. opencrbasic/data/cr1000x/instructions.json +4836 -0
  39. opencrbasic/data/cr1000x/measure.json +121 -0
  40. opencrbasic/data/cr1000x/mode.json +111 -0
  41. opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
  42. opencrbasic/data/cr1000x/settings_fields.json +321 -0
  43. opencrbasic/data/cr1000x/status_members.json +318 -0
  44. opencrbasic/data/cr1000x/symbols.json +872 -0
  45. opencrbasic/data/cr1000x/terminals.json +225 -0
  46. opencrbasic/data/cr300/before_beginprog.json +42 -0
  47. opencrbasic/data/cr300/ess_variables.json +1087 -0
  48. opencrbasic/data/cr300/fnotch.json +22 -0
  49. opencrbasic/data/cr300/functions.json +1261 -0
  50. opencrbasic/data/cr300/instructions.json +2837 -0
  51. opencrbasic/data/cr300/measure.json +33 -0
  52. opencrbasic/data/cr300/mode.json +121 -0
  53. opencrbasic/data/cr300/settings_fields.json +259 -0
  54. opencrbasic/data/cr300/status_members.json +303 -0
  55. opencrbasic/data/cr300/symbols.json +584 -0
  56. opencrbasic/data/cr300/terminals.json +193 -0
  57. opencrbasic/data/cr350/before_beginprog.json +44 -0
  58. opencrbasic/data/cr350/ess_variables.json +1087 -0
  59. opencrbasic/data/cr350/fnotch.json +22 -0
  60. opencrbasic/data/cr350/functions.json +1262 -0
  61. opencrbasic/data/cr350/instructions.json +2848 -0
  62. opencrbasic/data/cr350/measure.json +35 -0
  63. opencrbasic/data/cr350/mode.json +121 -0
  64. opencrbasic/data/cr350/settings_fields.json +292 -0
  65. opencrbasic/data/cr350/status_members.json +303 -0
  66. opencrbasic/data/cr350/symbols.json +572 -0
  67. opencrbasic/data/cr350/terminals.json +190 -0
  68. opencrbasic/data/ess_variables.json +1087 -0
  69. opencrbasic/data/fnotch.json +34 -0
  70. opencrbasic/data/functions.json +1337 -0
  71. opencrbasic/data/instructions.json +4965 -0
  72. opencrbasic/data/measure.json +123 -0
  73. opencrbasic/data/mode.json +121 -0
  74. opencrbasic/data/notices_cr6.json +890 -0
  75. opencrbasic/data/pipeline_tasks.json +84 -0
  76. opencrbasic/data/settings_fields.json +322 -0
  77. opencrbasic/data/status_members.json +319 -0
  78. opencrbasic/data/status_table.json +684 -0
  79. opencrbasic/data/symbols.json +902 -0
  80. opencrbasic/data/terminals.json +195 -0
  81. opencrbasic/data/type_codes.json +249 -0
  82. opencrbasic/diagnostics.py +196 -0
  83. opencrbasic/editing.py +827 -0
  84. opencrbasic/formatting.py +310 -0
  85. opencrbasic/helpdocs.py +677 -0
  86. opencrbasic/hover.py +667 -0
  87. opencrbasic/inlay.py +169 -0
  88. opencrbasic/lexer.py +183 -0
  89. opencrbasic/lsp/__init__.py +5 -0
  90. opencrbasic/lsp/__main__.py +7 -0
  91. opencrbasic/lsp/config.py +202 -0
  92. opencrbasic/lsp/features.py +602 -0
  93. opencrbasic/lsp/server.py +741 -0
  94. opencrbasic/lsp/session.py +1189 -0
  95. opencrbasic/lsp/worker.py +128 -0
  96. opencrbasic/mode.py +143 -0
  97. opencrbasic/navigation.py +401 -0
  98. opencrbasic/parser.py +3264 -0
  99. opencrbasic/positions.py +274 -0
  100. opencrbasic/preprocess.py +269 -0
  101. opencrbasic/profiles.py +266 -0
  102. opencrbasic/provenance.py +17 -0
  103. opencrbasic/quickfix.py +331 -0
  104. opencrbasic/rename.py +662 -0
  105. opencrbasic/signature.py +33 -0
  106. opencrbasic/source.py +600 -0
  107. opencrbasic/sources.py +125 -0
  108. opencrbasic/syntax.py +337 -0
  109. opencrbasic/tables.py +2173 -0
  110. opencrbasic/tables_hook.py +14 -0
  111. opencrbasic/tdf.py +142 -0
  112. opencrbasic/timing.py +565 -0
  113. opencrbasic-1.0.0.dist-info/METADATA +223 -0
  114. opencrbasic-1.0.0.dist-info/RECORD +117 -0
  115. opencrbasic-1.0.0.dist-info/WHEEL +4 -0
  116. opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
  117. opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,33 @@
1
+ """ProgSignature (`CR6Comp -s`): the PakBus signature of the program text.
2
+
3
+ CR6Comp seeds the signature with 0xAAAA, runs it over the main file's bytes, then continues it
4
+ over each included file's bytes in the order the includes are expanded (an `IncludeSection` of
5
+ the same file twice contributes the file twice). Verified against the exe: PT1000_tester.CR6
6
+ with its five IncludeSection expansions gives 53316 (docs/cli.md).
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+
12
+ def csi_signature(data: bytes, seed: int = 0xAAAA) -> int:
13
+ sig = seed
14
+ for byte in data:
15
+ prev = sig
16
+ sig = (sig << 1) & 0x1FF
17
+ if sig >= 0x100:
18
+ sig += 1
19
+ sig = (((sig + (prev >> 8) + byte) & 0xFF) | (prev << 8)) & 0xFFFF
20
+ return sig
21
+
22
+
23
+ def program_signature(path: Path, source=None) -> int:
24
+ from . import source as source_mod
25
+ from .diagnostics import Diagnostics
26
+
27
+ path = Path(path)
28
+ loader = source_mod._Loader(path.parent, Diagnostics(), source=source)
29
+ loader.load_file(path, section=None, in_include=False)
30
+ sig = 0xAAAA
31
+ for data in loader.files:
32
+ sig = csi_signature(data, sig)
33
+ return sig
opencrbasic/source.py ADDED
@@ -0,0 +1,600 @@
1
+ """Load a CRBasic program: physical lines -> logical lines, with comments stripped, line
2
+ continuation joined, and `Include`/`IncludeSection` expanded.
3
+
4
+ Quirks verified against the real compiler (`tools/oracle.py`); see `docs/frontend.md`:
5
+
6
+ - A logical line built from `_` continuation reports CR6Comp's error line as the *last* physical
7
+ line it spans, not the first (`LogicalLine.line` is set accordingly; there is no evidence CR6Comp
8
+ tracks a finer position than "the whole logical line").
9
+ - Code pulled in by `Include`/`IncludeSection` is reported at *its own* line number within the
10
+ included file, never the line of the `Include`/`IncludeSection` call, and the main program's own
11
+ line numbers are unaffected by an inclusion (each file keeps its own line counter).
12
+ - `IncludeSection("Name", "Device:File")` splices in the *entire* file's text (like `Include`);
13
+ `Name` only matters because the file's own `#If Section = "..."` directives compare against it
14
+ (`LogicalLine.section`). Referencing `Section` anywhere else is an ordinary undefined-symbol
15
+ error, confirmed with the oracle (`Invalid symbol in expression: Section = "Foo" .`).
16
+ - A missing `Include`/`IncludeSection` target is only a *warning* (`Cannot open include file %s.`,
17
+ the double space is the real message text) and the inclusion contributes nothing; compilation
18
+ continues.
19
+ - An `Include` or `IncludeSection` inside an already-included file (either kind, any depth, a file
20
+ including itself too) is a hard error in CR6Comp on all four loggers: `Include file within an
21
+ Include file is illegal.`, reported at that statement's line in the included file. The nested
22
+ file is not loaded (`_nested_include`; oracle, 2026-10-12, `docs/frontend.md`).
23
+ """
24
+
25
+ import dataclasses
26
+ import re
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import NamedTuple
30
+
31
+ from .diagnostics import Diagnostics
32
+ from .sources import DiskSource, SourceProvider
33
+
34
+ _INCLUDE_RE = re.compile(r'^\s*Include\s*\(?\s*"([^"]*)"\s*\)?\s*$', re.IGNORECASE)
35
+ # A third (or later) argument is tolerated and ignored, matching the real compiler
36
+ # (oracle-confirmed, `docs/fuzz-findings.md` "False reject": `IncludeSection("Rail",
37
+ # "CPU:BenchLib.crb",(1+2))` compiles -- `(1+2)` is accepted and has no effect). `(?:,.*)?` is
38
+ # greedy and the trailing `\)\s*$` still anchors the match to the statement's own closing paren,
39
+ # so a parenthesised extra argument (nested parens included) does not break the match.
40
+ _INCLUDE_SECTION_RE = re.compile(r'^\s*IncludeSection\s*\(\s*"([^"]*)"\s*,\s*"([^"]*)"\s*(?:,.*)?\)\s*$', re.IGNORECASE)
41
+ # An `Include`/`IncludeSection` statement as the real compiler recognises it inside an included
42
+ # file, well formed or not (oracle, 2026-10-12, `docs/frontend.md`): the keyword followed by
43
+ # blank, `(`, `=` or the end of the line. `IncludeVar`, `Includes`, `Include"x"` are other tokens;
44
+ # a lone `IncludeSection` crashes CR6Comp (no verdict) and is left alone.
45
+ _NESTED_INCLUDE_RE = re.compile(r"^\s*(?:Include(?:Section)?(?=[\s(=])|Include\s*$)", re.IGNORECASE)
46
+ _DRIVE_RE = re.compile(r"^(CPU|USR|CRD|USB):", re.IGNORECASE)
47
+
48
+
49
+ class Piece(NamedTuple):
50
+ """Provenance: `text[start:end]` of a `LogicalLine` is the decoded characters of physical line
51
+ `line` (1-based, the true line in its file) from character `col` (0-based) on. Characters of
52
+ `text` that no piece covers were inserted by the loader (the `" = "`/`" "` joins of a pull)."""
53
+
54
+ start: int
55
+ end: int
56
+ file: str # the file's display name, as `LogicalLine.file`
57
+ line: int
58
+ col: int
59
+ occurrence: int # which load of that file (`Occurrence.id`)
60
+
61
+
62
+ class Occurrence(NamedTuple):
63
+ """One load of a file: a file included twice has two. `parent` is the occurrence whose
64
+ `Include`/`IncludeSection` statement loaded it (None: the entry) and `site` the physical line
65
+ span (first, last) of that statement in the parent."""
66
+
67
+ id: int
68
+ file: str
69
+ path: Path | None
70
+ parent: int | None
71
+ site: tuple[int, int] | None
72
+
73
+
74
+ class LineSpan(NamedTuple):
75
+ """The physical lines (first, last) of a logical line reported at some line of its file."""
76
+
77
+ first: int
78
+ last: int
79
+ occurrence: int
80
+
81
+
82
+ def _clip(pieces: list[Piece], n: int) -> list[Piece]:
83
+ out = []
84
+ for p in pieces:
85
+ if p.start >= n:
86
+ break
87
+ out.append(p if p.end <= n else p._replace(end=n))
88
+ return out
89
+
90
+
91
+ @dataclass
92
+ class LogicalLine:
93
+ text: str # joined, comment-stripped source text
94
+ line: int # physical line to report (see module docstring)
95
+ file: str # originating file's name, for our own richer diagnostics
96
+ section: str | None = None # the Name argument of the enclosing IncludeSection(), if any
97
+ had_trailing_comment: bool = False # the *last* physical line contributing to `text` had a
98
+ # `'` comment stripped off it -- so `text` stops short of that physical line's real end, rather
99
+ # than reaching it. Lets the generic "Invalid, or out of place expression" fallback's own
100
+ # CR300/CR350 newline quirk (`checker/structure.py`'s `_out_of_place`) tell a statement that
101
+ # truly runs to the physical end of line from one a trailing comment cut short (oracle-
102
+ # confirmed, `docs/loggers.md` "Generic fallback echo on CR300/CR350").
103
+ kind: str = "code" # code | overflow | include_begin | include_end | include_warning | include_error
104
+ pulls: int = 0 # CR6/CR1000X initializer pull: physical lines folded into `text`
105
+ unpulled: list[LogicalLine] | None = None # the lines as written, for when `text` is not read
106
+ bad_init: tuple[str, str] | None = None # (message, variable) reported only when read
107
+ # Provenance (positions only; nothing in the compiler reads these): see `positions.py`.
108
+ pieces: tuple[Piece, ...] = () # where each character of `text` came from, in order
109
+ phys: tuple[int, int] | None = None # physical lines (first, last) the statement spans
110
+ occurrence: int = 0 # `Occurrence.id` of the load this line belongs to
111
+ stops_includes: bool = False # an `IncludeSection` extension error (`parser`, round 66)
112
+
113
+
114
+ def _split_physical_lines(raw: str) -> list[str]:
115
+ return raw.replace("\r\n", "\n").split("\n")
116
+
117
+
118
+ def _strip_comment(line: str) -> str:
119
+ """Remove a trailing `'` comment, respecting `"..."` strings (with `""` escapes)."""
120
+ in_string = False
121
+ i = 0
122
+ n = len(line)
123
+ while i < n:
124
+ ch = line[i]
125
+ if in_string:
126
+ if ch == '"':
127
+ if i + 1 < n and line[i + 1] == '"':
128
+ i += 2
129
+ continue
130
+ in_string = False
131
+ i += 1
132
+ continue
133
+ if ch == '"':
134
+ in_string = True
135
+ i += 1
136
+ continue
137
+ if ch == "'":
138
+ return line[:i]
139
+ i += 1
140
+ return line
141
+
142
+
143
+ def _is_continuation(line: str) -> tuple[bool, str]:
144
+ """`(True, prefix)` when `line` ends with whitespace + `_`; `prefix` has it removed."""
145
+ t = line.rstrip(" \t")
146
+ if t.endswith("_") and (len(t) == 1 or t[-2] in " \t"):
147
+ return True, t[:-1].rstrip(" \t")
148
+ return False, line
149
+
150
+
151
+ def _resolve_include_path(spec: str, directory: Path | None, source: SourceProvider | None = None) -> Path | None:
152
+ """The file `spec` names, searched from the main program's directory. `/` and `\\` both
153
+ separate directories; `.` and empty parts are skipped and `..` cancels the part before it, even a missing one (oracle, 2026-10-12,
154
+ all four loggers: `CPU:sub/Lib.crb`, `CPU:sub\\Lib.crb`, `./`, `..`, `a/b\\Lib.crb` and
155
+ `sub//Lib.crb` open the file, `docs/checker.md` "Include paths with a directory"). A spec
156
+ starting with a separator, or naming a drive-like part, is not found. Names match
157
+ case-insensitively."""
158
+ name = _DRIVE_RE.sub("", spec.strip())
159
+ if directory is None or not name or name[0] in "/\\":
160
+ return None
161
+ source = source or DiskSource()
162
+ *folders, leaf = name.replace("\\", "/").split("/")
163
+ parts: list[str] = []
164
+ for part in folders:
165
+ if part in ("", "."):
166
+ continue
167
+ if ":" in part:
168
+ return None
169
+ if part == "..": # lexical: `nosub/../Lib.crb` opens `Lib.crb` (oracle, 2026-10-12)
170
+ if parts:
171
+ parts.pop()
172
+ else:
173
+ directory = directory.absolute().parent
174
+ else:
175
+ parts.append(part)
176
+ for part in parts:
177
+ directory = _child_directory(directory, part, source)
178
+ if directory is None:
179
+ return None
180
+ if leaf in ("", ".", "..") or ":" in leaf:
181
+ return None
182
+ names = source.list_dir(directory)
183
+ if names is None:
184
+ return None
185
+ for entry in names:
186
+ if entry.lower() == leaf.lower():
187
+ return directory / entry
188
+ return None
189
+
190
+
191
+ def _child_directory(directory: Path, part: str, source: SourceProvider) -> Path | None:
192
+ """`directory/part`, matched case-insensitively when the provider can list subdirectories."""
193
+ exact = directory / part
194
+ if source.list_dir(exact) is not None:
195
+ return exact
196
+ subdirs = getattr(source, "list_subdirs", None)
197
+ for entry in (subdirs(directory) if subdirs else None) or ():
198
+ if entry.lower() == part.lower():
199
+ return directory / entry
200
+ return None
201
+
202
+
203
+ _DECL_START_RE = re.compile(r"^\s*(?:Public|Dim)\b", re.IGNORECASE)
204
+
205
+
206
+ def _mask_strings(text: str) -> str:
207
+ return re.sub(r'"[^"]*"', lambda m: "s" * len(m.group()), text)
208
+
209
+
210
+ def _split_top_commas(text: str) -> list[str]:
211
+ parts, depth, start = [], 0, 0
212
+ for k, ch in enumerate(_mask_strings(text)):
213
+ if ch in "({":
214
+ depth += 1
215
+ elif ch in ")}":
216
+ depth -= 1
217
+ elif ch == "," and depth == 0:
218
+ parts.append(text[start:k])
219
+ start = k + 1
220
+ parts.append(text[start:])
221
+ return parts
222
+
223
+
224
+ def _pull_decl_initializer(text: str, physical: list[str], i: int, trace: list | None = None):
225
+ """CR6/CR1000X `Public`/`Dim` initializer line pulling (oracle, 2026-10-10, `docs/checker.md`
226
+ "Round 7 CR6/CR1000X help-corpus families"): a declaration ending in `=` pulls the next line
227
+ into itself, and a brace list ending in `,` or `{` keeps pulling; every pulled line is
228
+ consumed without advancing the reported line counter. Returns `(text, i, pulls, bad)`; `bad`
229
+ is `(message, variable)` when the pulled line opens with `{` after a trailing `=` on an array.
230
+ `trace`, when given, receives the provenance edits in order: `("cut", n)` keeps the first `n`
231
+ characters of `text`; `("add", gap, index, col, length)` appends `gap` inserted characters and
232
+ then `length` characters of physical line index `index` from column `col`."""
233
+ pulls = 0
234
+ n = len(physical)
235
+
236
+ def next_text() -> tuple[str, str] | None:
237
+ if i + pulls + 1 >= n:
238
+ return None
239
+ raw = physical[i + pulls + 1]
240
+ stripped = _strip_comment(raw)
241
+ return None if stripped.strip() == "" else (raw, stripped)
242
+
243
+ masked = _mask_strings(text).rstrip()
244
+ if masked.endswith("=") and not masked.endswith(("<=", ">=")):
245
+ nxt = next_text()
246
+ if nxt is None:
247
+ return text, i, 0, None
248
+ raw, stripped = nxt
249
+ head = text.rstrip()[:-1].rstrip()
250
+ if trace is not None:
251
+ trace.append(("cut", len(head)))
252
+ body = stripped.lstrip(" \t")
253
+ names = _split_top_commas(head)
254
+ first = names[-1]
255
+ if len(names) == 1:
256
+ first = re.sub(r"^\s*(?:Public|Dim)\b", "", first, count=1, flags=re.I)
257
+ m = re.match(r"\s*(\w+)\s*(\(?)", first)
258
+ unbal = body.count("{") > body.count("}") and _mask_strings(body).rstrip().endswith((",", "{"))
259
+ if body.startswith("{") and m and (m.group(2) or unbal):
260
+ seg = _split_top_commas(body)
261
+ echo = seg[0] + ("\n" if len(seg) == 1 and len(stripped) == len(raw) else "")
262
+ kind = "Invalid, or out of place expression" if seg[0].strip() == "{" else "Incomplete expression"
263
+ return head, i + 1, 1, (f"{kind}: {echo}.", m.group(1))
264
+ if not body.startswith("{"):
265
+ body = _split_top_commas(body)[0] # the rest of the pulled line is dropped, unread
266
+ if trace is not None:
267
+ trace.append(("add", 3, i + 1, len(stripped) - len(stripped.lstrip(" \t")), len(body)))
268
+ return head + " = " + body, i + 1, 1, None
269
+ while True:
270
+ m = _mask_strings(text).rstrip()
271
+ after = m[m.rfind("=") + 1 :] if "=" in m else ""
272
+ if not (after.count("{") > after.count("}") and m.endswith((",", "{"))):
273
+ break
274
+ nxt = next_text()
275
+ if nxt is None:
276
+ break
277
+ if trace is not None:
278
+ trace.append(("cut", len(text.rstrip())))
279
+ trace.append(("add", 1, i + pulls + 1, len(nxt[1]) - len(nxt[1].lstrip(" \t")), len(nxt[1].lstrip(" \t"))))
280
+ text = text.rstrip() + " " + nxt[1].lstrip(" \t")
281
+ pulls += 1
282
+ return text, i + pulls, pulls, None
283
+
284
+
285
+ def bad_include_extension(spec: str, own_ext: str | None, cr3xx_exts: tuple[str, ...] | None) -> bool:
286
+ """Shared by literal includes in the loader and folded include names in the parser."""
287
+ if cr3xx_exts is not None:
288
+ ext = spec.rpartition(".")[2].upper() if "." in spec else ""
289
+ return (len(ext) == 3 and ext != "CRB") or (len(ext) == 5 and ext.startswith("CR3") and ext not in cr3xx_exts)
290
+ return own_ext is not None and not spec.rpartition(".")[2].upper().startswith(("CRB", own_ext))
291
+
292
+
293
+ class _Loader:
294
+ """Turns one file's raw text into `LogicalLine`s, expanding includes as it goes."""
295
+
296
+ def __init__(
297
+ self,
298
+ directory: Path | None,
299
+ diags: Diagnostics,
300
+ own_ext: str | None = None,
301
+ pull_init: bool = False,
302
+ cr3xx_exts: tuple[str, ...] | None = None,
303
+ source: SourceProvider | None = None,
304
+ provenance: bool = True,
305
+ ):
306
+ self.provenance = provenance # False: skip `LogicalLine.pieces` (only the language server reads them)
307
+ self.source = source or DiskSource()
308
+ self.includes: dict[str, Path] = {} # include display name -> the path it resolved to
309
+ self.directory = directory
310
+ self.pull_init = pull_init
311
+ self.pull_limit: tuple[int, ...] | None = None # pulls only at positions below this (CR300/CR350)
312
+ self.pull_log: list[tuple[tuple[int, ...], tuple[int, ...]]] = [] # (position, end position) of pulls
313
+ self.site: dict[str, tuple[int, ...]] = {} # included file -> position of its Include line
314
+ self.diags = diags
315
+ self.own_ext = own_ext # CR6/CR1000X only: the logger's own program extension
316
+ self.cr3xx_exts = cr3xx_exts # CR300/CR350 only: the extensions an include may load
317
+ self.files: list[bytes] = [] # every file read, in order (ProgSignature input)
318
+ self.occurrences: list[Occurrence] = [] # every load of a file, in order (provenance)
319
+ self._cur: int | None = None # the occurrence being loaded (provenance)
320
+ self._path: Path | None = None # path of the file `load_file` is about to load
321
+ self._site_phys: tuple[int, int] | None = None # physical span of the include being expanded
322
+
323
+ def load_file(
324
+ self, path: Path, *, section: str | None, in_include: bool, display: str | None = None
325
+ ) -> list[LogicalLine]:
326
+ """`display` is an included file's name as written in the `Include`, drive included
327
+ (`CPU:Lib.crb`); `diagnostics.Diagnostic.cr6comp` renders it per logger."""
328
+ data = self.source.read(path)
329
+ if data is None:
330
+ return []
331
+ self.files.append(data)
332
+ saved = self._cur
333
+ self._path = path
334
+ try:
335
+ return self.load_text(data.decode("latin-1"), display or path.name, section=section, in_include=in_include)
336
+ finally:
337
+ self._cur = saved
338
+
339
+ def load_text(self, raw: str, filename: str, *, section: str | None, in_include: bool) -> list[LogicalLine]:
340
+ physical = _split_physical_lines(raw)
341
+ prov = self.provenance
342
+ occ = len(self.occurrences)
343
+ self.occurrences.append(
344
+ Occurrence(occ, filename, self._path, self._cur, self._site_phys if self._cur is not None else None)
345
+ )
346
+ self._cur, self._path = occ, None
347
+ out: list[LogicalLine] = []
348
+ i = 0
349
+ n = len(physical)
350
+ # the empty piece after a final newline is no line: a continuation into it joins nothing
351
+ real_n = n - 1 if n > 1 and physical[-1] == "" else n
352
+ shift = 0
353
+ while i < n:
354
+ start_line = i + 1
355
+ raw_line = physical[i]
356
+ buffer_text = raw_line
357
+ # The buffer includes an LF when present; see docs/frontend.md#source-line-buffer.
358
+ if len(buffer_text) + (i < n - 1) > 510:
359
+ out.append(
360
+ LogicalLine(
361
+ "",
362
+ start_line - shift,
363
+ filename,
364
+ section,
365
+ kind="overflow",
366
+ phys=(start_line, start_line),
367
+ occurrence=occ,
368
+ )
369
+ )
370
+ break
371
+ text = _strip_comment(raw_line)
372
+ had_comment = len(text) != len(raw_line)
373
+ pieces = [Piece(0, len(text), filename, start_line, 0, occ)] if text and prov else []
374
+ last_line = start_line
375
+ while True:
376
+ cont, prefix = _is_continuation(text)
377
+ if cont and i + 1 >= real_n:
378
+ text = "" # a continuation into the end of the file: the statement is not read
379
+ pieces = []
380
+ break
381
+ if not cont or i + 1 >= n:
382
+ text = prefix if cont else text
383
+ pieces = _clip(pieces, len(text)) if prov else pieces
384
+ break
385
+ buffer_text = _strip_comment(buffer_text).rstrip(" \t")[:-1]
386
+ i += 1
387
+ last_line = i + 1
388
+ raw_line = physical[i]
389
+ buffer_text += raw_line
390
+ if len(buffer_text) + (i < n - 1) > 510:
391
+ out.append(
392
+ LogicalLine(
393
+ "",
394
+ last_line - shift,
395
+ filename,
396
+ section,
397
+ kind="overflow",
398
+ phys=(start_line, last_line),
399
+ occurrence=occ,
400
+ )
401
+ )
402
+ return out
403
+ stripped = _strip_comment(raw_line)
404
+ had_comment = len(stripped) != len(raw_line)
405
+ # the text before the `_` with its blanks, then the next line verbatim, no
406
+ # separator (oracle, 2026-10-10, `docs/checker.md` "Round 7"; after a text part
407
+ # too: `foo bar _` / ` baz` echoes `foo bar baz`)
408
+ lone = (
409
+ not prefix.strip() and _is_continuation(stripped)[0] and not _is_continuation(stripped)[1].strip()
410
+ )
411
+ joined = len(text.rstrip(" \t")) - 1
412
+ text = stripped if lone else text.rstrip(" \t")[:-1] + stripped
413
+ pieces = [] if lone or not prov else _clip(pieces, joined)
414
+ if stripped and prov:
415
+ pieces.append(Piece(0 if lone else joined, len(text), filename, last_line, 0, occ))
416
+ if text.strip() == "":
417
+ i += 1
418
+ continue
419
+ pulls, bad = 0, None
420
+ orig = text
421
+ orig_pieces = pieces
422
+ pos = (*self.site.get(filename, ()), start_line - shift)
423
+ if self.pull_init and _DECL_START_RE.match(text) and (self.pull_limit is None or pos < self.pull_limit):
424
+ trace: list = []
425
+ text, i, pulls, bad = _pull_decl_initializer(text, physical, i, trace)
426
+ size = len(orig)
427
+ for edit in trace if prov else ():
428
+ if edit[0] == "cut":
429
+ pieces, size = _clip(pieces, edit[1]), edit[1]
430
+ else:
431
+ _, gap, index, col, length = edit
432
+ pieces = pieces + [Piece(size + gap, size + gap + length, filename, index + 1, col, occ)]
433
+ size += gap + length
434
+ if pulls:
435
+ self.pull_log.append((pos, (*self.site.get(filename, ()), last_line - shift)))
436
+ if pulls:
437
+ had_comment = len(_strip_comment(physical[i])) != len(physical[i])
438
+ self._site_phys = (start_line, i + 1)
439
+ new = self._expand_or_keep(text, last_line - shift, filename, section, in_include, had_comment)
440
+ for made in new:
441
+ if made.phys is None:
442
+ made.phys, made.occurrence = (start_line, i + 1), occ
443
+ if len(new) == 1 and new[0].kind == "code":
444
+ new[0].pieces = tuple(pieces)
445
+ if pulls and len(new) == 1 and new[0].kind == "code":
446
+ # the parser decides whether this text is read (not in an inactive #If, not after
447
+ # EndProg; oracle, 2026-10-10) and otherwise restores the lines as written
448
+ base = last_line - shift - pulls
449
+ new[0].pulls, new[0].bad_init = pulls, bad
450
+ new[0].unpulled = [
451
+ LogicalLine(
452
+ orig,
453
+ base,
454
+ filename,
455
+ section,
456
+ pieces=tuple(orig_pieces),
457
+ phys=(start_line, last_line),
458
+ occurrence=occ,
459
+ )
460
+ ] + [
461
+ LogicalLine(
462
+ _strip_comment(physical[last_line - 1 + k]),
463
+ base + k,
464
+ filename,
465
+ section,
466
+ pieces=(self._whole(physical[last_line - 1 + k], filename, last_line + k, occ) if prov else ()),
467
+ phys=(last_line + k, last_line + k),
468
+ occurrence=occ,
469
+ )
470
+ for k in range(1, pulls + 1)
471
+ ]
472
+ out.extend(new)
473
+ shift += pulls
474
+ i += 1
475
+ return out
476
+
477
+ @staticmethod
478
+ def _whole(raw: str, filename: str, line: int, occ: int) -> tuple[Piece, ...]:
479
+ n = len(_strip_comment(raw))
480
+ return (Piece(0, n, filename, line, 0, occ),) if n else ()
481
+
482
+ def _expand_or_keep(
483
+ self,
484
+ text: str,
485
+ line: int,
486
+ filename: str,
487
+ section: str | None,
488
+ in_include: bool,
489
+ had_comment: bool = False,
490
+ ) -> list[LogicalLine]:
491
+ if in_include and _NESTED_INCLUDE_RE.match(text):
492
+ return [self._nested_include(line, filename, section)]
493
+ m = _INCLUDE_SECTION_RE.match(text)
494
+ if m:
495
+ name, spec = m.group(1), m.group(2)
496
+ if self._bad_extension(spec):
497
+ return [dataclasses.replace(self._extension_error(spec, line, filename, section), stops_includes=True)]
498
+ target = None if self._unloadable(spec) else _resolve_include_path(spec, self.directory, self.source)
499
+ if target is None:
500
+ return [self._missing(spec, line, filename, section)]
501
+ self.site.setdefault(spec.strip(), (*self.site.get(filename, ()), line))
502
+ self.includes.setdefault(spec.strip(), target)
503
+ included = self.load_file(target, section=name, in_include=True, display=spec.strip())
504
+ return self._include_lines(included, line, filename, section)
505
+ m = _INCLUDE_RE.match(text)
506
+ if m:
507
+ spec = m.group(1)
508
+ if self._bad_extension(spec):
509
+ return [self._extension_error(spec, line, filename, section)]
510
+ target = None if self._unloadable(spec) else _resolve_include_path(spec, self.directory, self.source)
511
+ if target is None:
512
+ return [self._missing(spec, line, filename, section)]
513
+ self.site.setdefault(spec.strip(), (*self.site.get(filename, ()), line))
514
+ self.includes.setdefault(spec.strip(), target)
515
+ included = self.load_file(target, section=None, in_include=True, display=spec.strip())
516
+ return self._include_lines(included, line, filename, section)
517
+ return [LogicalLine(text, line, filename, section, had_trailing_comment=had_comment)]
518
+
519
+ def _bad_extension(self, spec: str) -> bool:
520
+ """CR6/CR1000X: an include name whose text after the last `.` (none: empty) starts with
521
+ neither `crb` nor the logger's own extension is an error whether or not the file exists, and
522
+ an existing file contributes nothing (oracle, 2026-10-08/10)."""
523
+ return bad_include_extension(spec, self.own_ext, self.cr3xx_exts)
524
+
525
+ def _unloadable(self, spec: str) -> bool:
526
+ """CR300/CR350: an include whose extension passes `_bad_extension` still opens only with
527
+ `crb` or the logger's own extension; anything else is the missing-file warning even if the
528
+ file exists (oracle, 2026-10-10, `docs/checker.md` "Round 9")."""
529
+ ext = spec.rpartition(".")[2].upper() if "." in spec else ""
530
+ return self.cr3xx_exts is not None and ext != "CRB" and ext not in self.cr3xx_exts
531
+
532
+ @staticmethod
533
+ def _nested_include(line: int, filename: str, section: str | None) -> LogicalLine:
534
+ """An include inside an included file is an error whatever it names (a marker like the
535
+ other include errors: not reported after `EndProg` or in an inactive `#If`)."""
536
+ return LogicalLine(
537
+ "Include file within an Include file is illegal.", line, filename, section, kind="include_error"
538
+ )
539
+
540
+ @staticmethod
541
+ def _extension_error(spec: str, line: int, filename: str, section: str | None) -> LogicalLine:
542
+ return LogicalLine(
543
+ f"Include file {spec} must have valid program file extension.",
544
+ line,
545
+ filename,
546
+ section,
547
+ kind="include_error",
548
+ )
549
+
550
+ @staticmethod
551
+ def _missing(spec: str, line: int, filename: str, section: str | None) -> LogicalLine:
552
+ """A missing include is a marker the parser reports only if it reads that far: text after
553
+ `EndProg` or in an inactive `#If` branch is never looked at (oracle, 2026-10-10)."""
554
+ return LogicalLine(f"Cannot open include file {spec}.", line, filename, section, kind="include_warning")
555
+
556
+ @staticmethod
557
+ def _include_lines(included: list[LogicalLine], line: int, filename: str, section: str | None) -> list[LogicalLine]:
558
+ return [
559
+ LogicalLine("", line, filename, section, kind="include_begin"),
560
+ *included,
561
+ LogicalLine("", line, filename, section, kind="include_end"),
562
+ ]
563
+
564
+
565
+ def load_program(
566
+ path: Path,
567
+ own_ext: str | None = None,
568
+ pull_init: bool = False,
569
+ pull_limit: tuple[int, ...] | None = None,
570
+ cr3xx_exts: tuple[str, ...] | None = None,
571
+ source: SourceProvider | None = None,
572
+ provenance: bool = True,
573
+ ) -> tuple[list[LogicalLine], Diagnostics]:
574
+ """Load `path` and every file it `Include`s/`IncludeSection`s, as one logical-line stream."""
575
+ path = Path(path)
576
+ diags = Diagnostics()
577
+ loader = _Loader(path.parent, diags, own_ext, pull_init, cr3xx_exts, source, provenance)
578
+ loader.pull_limit = pull_limit
579
+ lines = loader.load_file(path, section=None, in_include=False)
580
+ diags.pull_log = loader.pull_log
581
+ diags.pull_site = loader.site
582
+ diags.include_paths = loader.includes
583
+ diags.occurrences = loader.occurrences
584
+ return lines, diags
585
+
586
+
587
+ def load_text(
588
+ text: str,
589
+ name: str = "t.cr6",
590
+ directory: Path | None = None,
591
+ own_ext: str | None = None,
592
+ pull_init: bool = False,
593
+ cr3xx_exts: tuple[str, ...] | None = None,
594
+ ) -> tuple[list[LogicalLine], Diagnostics]:
595
+ """Like `load_program`, for source text that may not be on disk (`directory` resolves includes)."""
596
+ diags = Diagnostics()
597
+ loader = _Loader(directory, diags, own_ext, pull_init, cr3xx_exts)
598
+ lines = loader.load_text(text, name, section=None, in_include=False)
599
+ diags.occurrences = loader.occurrences
600
+ return lines, diags