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,320 @@
1
+ """Compile driver: parse, check, and render CR6Comp-compatible results."""
2
+
3
+ import bisect
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from . import profiles
9
+ from . import syntax as S
10
+ from .diagnostics import Diagnostics
11
+ from .profiles import Profile
12
+ from .sources import SourceProvider
13
+
14
+ # Error messages accumulate in a 4096-byte buffer (decoded from the exe, `docs/checker.md` "Error
15
+ # message buffer"); once a message no longer fits, it is dropped and pass 1 reports nothing
16
+ # further. Oracle-confirmed by construction (a 150-line program with a distinct undefined-symbol
17
+ # error on every line): the printed output stops after the 69th message, the last one whose own
18
+ # `line N: <text>` plus a 2-byte CRLF terminator keeps the running total at or under 4096 bytes --
19
+ # the 70th would push it over and is silently missing, with no truncation marker of any kind. This
20
+ # accounts only for the rendered `line N: <message>` text (the `<file> ` prefix included, when
21
+ # present), not the `"<program> -- Compile Failed!"` header or the blank line after it, which are
22
+ # printed unconditionally and are not part of this budget.
23
+ _MESSAGE_BUFFER_LIMIT = 4096
24
+ _IMPLEMENTATION_FAILURE_MESSAGE = "Internal error: unable to compile program."
25
+
26
+
27
+ def _no_line_kind(d) -> int:
28
+ """Secondary sort key for a no-line (`d.line == 0`) diagnostic: `-u`'s own "declared but not
29
+ used"/"declared but never called" (`checker/unused.py`) before any other no-line warning (in
30
+ this project, only the pre-existing "table declared but never called" check) -- see
31
+ `CompileResult.result_lines`'s docstring for the decompiled/oracle evidence.
32
+ """
33
+ return 0 if d.message.endswith("declared but not used.") else 1
34
+
35
+
36
+ def _bounded_by_message_buffer(lines: list[str]) -> list[str]:
37
+ used = 0
38
+ out = []
39
+ for line in lines:
40
+ cost = len(line.encode("latin-1", "replace")) + 2 # + CRLF, as the real buffer counts it
41
+ if used + cost > _MESSAGE_BUFFER_LIMIT:
42
+ break
43
+ used += cost
44
+ out.append(line)
45
+ return out
46
+
47
+
48
+ @dataclass
49
+ class CompileResult:
50
+ program: str # program name as given on the command line
51
+ profile: Profile
52
+ diagnostics: Diagnostics
53
+ mode: str | None = None # "Sequential" / "Pipeline" (CR6, CR1000X)
54
+ tables: list = field(default_factory=list) # tdf.TdfTable list, when available
55
+ table_list: str | None = None # text of the `-l` output, when available
56
+ signature: int | None = None
57
+ file_order: dict = field(default_factory=dict) # file name -> its rank in expansion order
58
+ # (first-seen position among `program.statements`), for `result_lines`' sort -- see there.
59
+ stmt_lines: dict = field(default_factory=dict) # file name -> sorted [(line, expansion index)]
60
+ implementation_exceptions: list[str] = field(default_factory=list)
61
+ program_ast: S.Program | None = None # the parsed program (None when parsing failed)
62
+ pass1_errors: bool | None = None # whether the checker found pass-1 errors (the gate `-t` follows)
63
+ findings: list = field(default_factory=list) # opencrbasic checks (`checks.analyze`), never
64
+ suppressed: list = field(default_factory=list) # in `diagnostics`: `ok` ignores both
65
+
66
+ def record_implementation_failure(self, where: str, exc: Exception) -> None:
67
+ """Make an unexpected implementation error visible as a failed compilation.
68
+
69
+ This is deliberately a project marker, not a claimed CRBasic diagnostic. A swallowed
70
+ checker exception must never let the CLI report a successful compile or write success-only
71
+ artifacts.
72
+ """
73
+ self.implementation_exceptions.append(f"{where}: {type(exc).__name__}: {exc}")
74
+ if not any(d.message == _IMPLEMENTATION_FAILURE_MESSAGE for d in self.diagnostics.errors):
75
+ self.diagnostics.error(0, _IMPLEMENTATION_FAILURE_MESSAGE)
76
+
77
+ def _position(self, d) -> tuple:
78
+ """Where diagnostic `d` falls in the expanded source: the expansion index of the first
79
+ statement of its file at or after its line (the exe prints in one top-to-bottom pass over
80
+ the expanded program, so an included file's messages come where its `Include` is, not
81
+ grouped by file -- oracle: `TDR_tester.CR6` on CR1000X prints `AcclimaTDRLib.crb`'s lines
82
+ 2 and 58 before the main file's line 45). A diagnostic without a file is the main file's
83
+ (`No BeginProg`, the unclosed-block messages)."""
84
+ file = d.file or self.program
85
+ lines = self.stmt_lines.get(file)
86
+ if not lines:
87
+ return (self.file_order.get(file, 0), d.line)
88
+ i = bisect.bisect_left(lines, (d.line, -1))
89
+ index = lines[i][1] if i < len(lines) else lines[-1][1] + 1
90
+ return (index, d.line)
91
+
92
+ @property
93
+ def ok(self) -> bool:
94
+ return not self.diagnostics.errors and not self.implementation_exceptions
95
+
96
+ def _sort_key(self, d) -> tuple:
97
+ if not d.line:
98
+ return (True, _no_line_kind(d), 0)
99
+ if (
100
+ self.profile.include_warning_first
101
+ and (d.file or self.program) == self.program
102
+ and d.severity == "warning"
103
+ and d.message.startswith("Cannot open include file ")
104
+ ):
105
+ # reader pass: before any other message of the main file (`docs/checker.md`)
106
+ return (False, (-1, d.line), 0)
107
+ if (
108
+ d.severity == "warning"
109
+ and d.message == "Parameter will truncate to the nearest integer."
110
+ and not self.diagnostics.errors
111
+ ):
112
+ # parser warning of a compile with no errors: before every later-pass warning
113
+ # (reachability, fnotch), whatever its line (oracle, round 17, `docs/instructions.md`)
114
+ return (False, (-1, d.line), 0)
115
+ return (
116
+ False,
117
+ self._position(d),
118
+ d.message.endswith("never reached at run time.") * 2
119
+ or d.message.endswith("illegally occurs before BeginProg."),
120
+ )
121
+
122
+ def result_lines(self, show_signature: bool = False) -> list[str]:
123
+ """The lines CR6Comp prints to stdout (and the message file) for this result."""
124
+ # CR6Comp is a single top-to-bottom pass over the file, so its own diagnostics -- errors
125
+ # *and* the small set of ungated warnings (`checker/warnings.py`'s `check_early`:
126
+ # reachability/background-calibration/fnotch, oracle-confirmed to print interleaved with
127
+ # errors by line even on an otherwise-failed compile, `docs/instructions.md`) -- are always
128
+ # in ascending line order together. A warning with no line (the pre-existing "table
129
+ # declared but never called" check, `diags.warning(0, ...)`) has no fixed position in the
130
+ # real output either, but every oracle sample found it printed *after* every numbered line,
131
+ # so `d.line == 0` sorts last (stable, so several such warnings keep their own relative
132
+ # order) instead of first, which a plain ascending sort would otherwise do since 0 is the
133
+ # smallest line number. `self.diagnostics.items` is insertion order, which several
134
+ # independent checker passes each contribute to in their own statement-list order
135
+ # (`checker/__init__.py`) -- individually ascending, but not necessarily interleaved
136
+ # correctly against each other or against warnings, so this stable sort (never observed out
137
+ # of order on the same line, so ties keep each check's own relative order) reproduces the
138
+ # single-pass compiler's output order without each checker pass needing to coordinate.
139
+ #
140
+ # That per-line stable sort is only correct *within one file*: `Include`/`IncludeSection`
141
+ # give each included file its own independent line numbering (this module's own docstring,
142
+ # `source.py`), so a raw ascending sort across files interleaves them by coincidence, not by
143
+ # the real single top-to-bottom pass over the *expanded* source (oracle-confirmed,
144
+ # `checker/unused.py`'s corpus run: `SbdPayload_tester.CR6`'s `-u` warnings come out grouped
145
+ # by included library file, in `Include` order, each *group* internally ascending by that
146
+ # file's own line numbers -- not a single global ascending run). `self.file_order` (built
147
+ # below from `program.statements`' own `.file` field, in expansion order) is the primary
148
+ # sort key for exactly this reason; it is `{}` for a single-file program, where every
149
+ # diagnostic's `.file` maps to the same missing-key default and this reduces to the
150
+ # pre-existing ascending-line-only behaviour unchanged.
151
+ #
152
+ # Among no-line (`d.line == 0`) items specifically, file order does not apply (decompiled,
153
+ # `FUN_004eab30`: the `-u` check (`thunk_FUN_004ba180`) runs once, early, well before the
154
+ # later per-table finalisation loop that detects and prints "table declared but never
155
+ # called" -- oracle-confirmed on the corpus, `checker/unused.py`'s docstring: every
156
+ # no-line `-u` warning, regardless of which included file declared it, prints before every
157
+ # "table declared but never called" warning). `_no_line_kind` below reproduces that
158
+ # ordering by message shape rather than by which module raised it, so this stays correct
159
+ # without `checker/warnings.py` (a concurrently-owned module) needing to tag its own
160
+ # diagnostics; a stable sort keeps each kind's own relative (insertion) order within itself.
161
+ # On one line the reachability warning follows that line's errors (oracle: 15 of 15 corpus
162
+ # and fuzz transcripts); other warnings keep their check order.
163
+ ordered = sorted(self.diagnostics.items, key=self._sort_key)
164
+ drive = self.profile.include_drive_prefix
165
+ if not self.ok:
166
+ rendered = _bounded_by_message_buffer([d.cr6comp(self.program, drive) for d in ordered])
167
+ if rendered:
168
+ # a message that echoes its source line keeps the newline (`Illegally dimension:`);
169
+ # the real output is right-trimmed
170
+ rendered[-1] = rendered[-1].rstrip("\n")
171
+ return [f"{self.program} -- Compile Failed!", ""] + rendered
172
+ if self.profile.reports_mode:
173
+ lines = [f"{self.program} -- Compiled in {self.mode or 'Sequential'}Mode."]
174
+ else:
175
+ lines = [f"{self.program} -- Compiled OK."]
176
+ if show_signature and self.signature is not None:
177
+ lines.append(f"ProgSignature = {self.signature}.")
178
+ lines += [d.cr6comp(self.program, drive) for d in ordered if d.severity == "warning"]
179
+ return lines
180
+
181
+
182
+ def dropped_message(result: CompileResult):
183
+ """The first error the 4096-byte message buffer cannot hold, or None. The reference stops
184
+ compiling there, and `-p`/`-i` output ends at its line (artifact matrix, round 74,
185
+ `docs/artifacts.md`)."""
186
+ if result.ok:
187
+ return None
188
+ ordered = sorted(result.diagnostics.items, key=result._sort_key)
189
+ kept = len(
190
+ _bounded_by_message_buffer([d.cr6comp(result.program, result.profile.include_drive_prefix) for d in ordered])
191
+ )
192
+ return ordered[kept] if kept < len(ordered) else None
193
+
194
+
195
+ def compile_file(
196
+ path: Path,
197
+ profile: Profile | None = None,
198
+ display_name: str | None = None,
199
+ warn_unused: bool = False,
200
+ source: SourceProvider | None = None,
201
+ signature: bool = True,
202
+ provenance: bool = True,
203
+ ) -> CompileResult:
204
+ """`provenance=False` (the CLI and the tools) skips the position data only the language server
205
+ reads (`LogicalLine.pieces`, `Program.line_map`, `Stmt.origin`); the compile output is unchanged.
206
+
207
+ Never lets an unexpected exception escape (`docs/checker.md` "Robustness"): the real
208
+ compiler never crashes on a syntactically-loadable file, so an internal bug in this project
209
+ should surface as a rejected program, not a Python traceback that takes down whatever called
210
+ this (`tools/fuzz.py`'s own worker pool did exactly that before it grew its own timeout guard,
211
+ `docs/fuzz-findings.md` "Hang"). No oracle text can ever exist for "opencrbasic itself broke",
212
+ so the fallback message here is a generic marker, never a claim about what CR6Comp.exe itself
213
+ would say -- `checker.check`'s own finer-grained per-pass wrapping is what actually keeps
214
+ *most* internal bugs from reaching here at all.
215
+ """
216
+ profile = profile or profiles.for_file(path)
217
+ name = display_name or path.name
218
+ result = _compile_once(path, profile, name, None, source, provenance)
219
+ if profile.single_pass:
220
+ # CR300/CR350 stop pulling a `Public`/`Dim` initializer's next line once any error has been
221
+ # reported ahead of the declaration (oracle, 2026-10-10, `docs/checker.md` "Round 8"): re-run
222
+ # with the pulls from the first affected declaration on disabled.
223
+ limit = None
224
+ for _ in range(16):
225
+ log = getattr(result.program_ast, "pull_log", None) or []
226
+ site = getattr(result.program_ast, "pull_site", {})
227
+ errs = [
228
+ (*site.get(d.file, ()), d.line)
229
+ for d in result.diagnostics.items
230
+ if d.severity == "error" and d.line and not d.message.endswith("illegally occurs before BeginProg.")
231
+ ]
232
+ bad = next((start for start, end in log if any(e < end for e in errs)), None)
233
+ if bad is None or (limit is not None and bad >= limit):
234
+ break
235
+ limit = bad
236
+ result = _compile_once(path, profile, name, limit, source, provenance)
237
+ if result.program_ast is None:
238
+ return result
239
+ return _finish(result, path, warn_unused, source, signature)
240
+
241
+
242
+ def _compile_once(
243
+ path: Path,
244
+ profile: Profile,
245
+ name: str,
246
+ pull_limit: tuple[int, ...] | None,
247
+ source: SourceProvider | None = None,
248
+ provenance: bool = True,
249
+ ):
250
+ from . import checker, parser
251
+
252
+ try:
253
+ program, diags = parser.parse_file(
254
+ path, profile=profile, pull_limit=pull_limit, source=source, provenance=provenance
255
+ )
256
+ except Exception as exc:
257
+ diags = Diagnostics()
258
+ diags.error(0, "Internal error: unable to parse program.")
259
+ result = CompileResult(name, profile, diags)
260
+ result.implementation_exceptions.append(f"parser.parse_file: {type(exc).__name__}: {exc}")
261
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
262
+ raise
263
+ return result
264
+ file_order: dict[str, int] = {}
265
+ stmt_lines: dict[str, list[tuple[int, int]]] = {}
266
+ for index, stmt in enumerate(program.statements):
267
+ file = getattr(stmt, "file", "") or name
268
+ file_order.setdefault(file, len(file_order))
269
+ stmt_lines.setdefault(file, []).append((stmt.line, index))
270
+ for lines in stmt_lines.values():
271
+ lines.sort()
272
+ result = CompileResult(name, profile, diags, file_order=file_order, stmt_lines=stmt_lines, program_ast=program)
273
+ if len(path.name) + 1 > 60:
274
+ diags.error(0, f"Program name exceeds maximum allowable characters by {len(path.name) - 59}.")
275
+ try:
276
+ checker.check(program, profile, diags, result)
277
+ except Exception as exc:
278
+ result.record_implementation_failure("checker.check", exc)
279
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
280
+ raise
281
+ if program.unclosed_if and not diags.errors:
282
+ # the message is printed alone: with errors the real compilers differ in which messages
283
+ # win (`docs/checker.md` "Unclosed #If"), so only the error-free file is modelled; warnings
284
+ # do not show (oracle, all four loggers, 2026-10-12, "Round 13")
285
+ diags.items[:] = []
286
+ diags.error(0, "#If with no #Endif")
287
+ return result
288
+
289
+
290
+ def _finish(
291
+ result: CompileResult, path: Path, warn_unused: bool, source: SourceProvider | None = None, signature: bool = True
292
+ ) -> CompileResult:
293
+ program, diags = result.program_ast, result.diagnostics
294
+ if warn_unused and program is not None and not diags.errors:
295
+ # `-u` (`docs/cli.md` "-u", `checker/unused.py`): oracle-confirmed to add nothing at all
296
+ # once any error is present anywhere in the file, so this only ever runs on an otherwise-
297
+ # clean pass -- matching the ordinary checker's own post-check tier (`checker/__init__.py`:
298
+ # `tables_hook.attach`/`warnings.check`/`instr_data.check_duplicate_fields` are all gated
299
+ # the same way). A crash here must not take down an otherwise-successful compile any more
300
+ # than a checker pass's own crash does (`checker/__init__.py`'s `_run_pass`).
301
+ try:
302
+ from .checker import unused
303
+
304
+ unused.check(program, diags)
305
+ except Exception as exc:
306
+ result.record_implementation_failure("checker.unused.check", exc)
307
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
308
+ raise
309
+ if not signature:
310
+ return result
311
+ from .signature import program_signature
312
+
313
+ try:
314
+ result.signature = program_signature(path, source)
315
+ except Exception as exc:
316
+ result.record_implementation_failure("signature.program_signature", exc)
317
+ result.signature = None
318
+ if os.environ.get("OPENCRBASIC_STRICT") == "1":
319
+ raise
320
+ return result
@@ -0,0 +1,327 @@
1
+ """Constant-expression evaluation: numbers, strings, predefined symbols, earlier `Const`s,
2
+ operators (CRBasic precedence, built by `parser.py`) and a handful of pure builtin functions.
3
+
4
+ Used for `#If`/`#ElseIf` conditions (which must be constant) and by `mode.py`'s `is_constant`/
5
+ `value_of` callbacks.
6
+
7
+ Operator precedence, loosest to tightest (built into `parser.py`'s expression parser, not this
8
+ module, which only walks whatever tree it is given): ``IMP EQV OR XOR AND`` (one left-associative
9
+ tier -- see docs/frontend.md: the oracle refutes the textbook "AND binds tighter than OR" rule
10
+ for CRBasic), ``NOT`` (unary), relational (``= <> < > <= >=``), ``<< >>``, ``& + -``,
11
+ ``MOD``, ``\\``, ``* /``, unary ``- +``, ``^`` (left-associative; confirmed `2^3^2 = 64`, and
12
+ unary minus binds looser than `^`: `-2^2 = -4`).
13
+
14
+ ``TRUE`` = -1, ``FALSE`` = 0 (from the symbol table); comparisons yield -1/0.
15
+ """
16
+
17
+ import math
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+ from typing import TypeGuard
21
+
22
+ from . import syntax as S
23
+ from .profiles import Profile
24
+
25
+
26
+ class NotConstant(Exception):
27
+ """Raised when an expression (or a name within it) has no compile-time value."""
28
+
29
+
30
+ class DivideByZero(NotConstant):
31
+ """A `/` whose divisor folds to 0; `numerator` is the folded dividend. Every folded value is a
32
+ float, so a caller that must tell `1/0` from `1/0.0` looks at the source text."""
33
+
34
+ numerator: object = None
35
+
36
+
37
+ class CFloat(float):
38
+ """A `Const` value known to be float-typed."""
39
+
40
+
41
+ @dataclass
42
+ class Env:
43
+ """Constants known so far (definition order doesn't matter; last `define` wins) plus the
44
+ logger profile's predefined symbols. `section` is the `Section` pseudo-value bound while
45
+ walking a line that came from an `IncludeSection()` call (see `source.py`); referencing
46
+ `Section` outside that context is an ordinary undefined-symbol error (verified with the
47
+ oracle: `Invalid symbol in expression: Section = "Foo" .`).
48
+ """
49
+
50
+ profile: Profile
51
+ consts: dict[str, S.ConstValue] = field(default_factory=dict)
52
+ section: str | None = None
53
+
54
+ def define(self, name: str, value: S.ConstValue, is_float: bool | None = None) -> None:
55
+ """`is_float` (see `is_float`) travels with the stored value, so the `dict(consts)` copies
56
+ the checker makes keep it: `CFloat` is float-typed, an `int` integer-typed, a plain float
57
+ not established."""
58
+ if is_float and isinstance(value, float):
59
+ value = CFloat(value)
60
+ elif (
61
+ is_float is False
62
+ and isinstance(value, float)
63
+ and value == value
64
+ and abs(value) < 2**63
65
+ and value == int(value)
66
+ ):
67
+ value = int(value)
68
+ self.consts[name.upper()] = value
69
+
70
+ def child(self, section: str | None) -> Env:
71
+ """A view with a different `Section` binding but the same (mutable, shared) consts."""
72
+ return Env(self.profile, self.consts, section)
73
+
74
+
75
+ # Pure builtins usable in constant expressions (see mathematicalexpressions.htm / the CHM function
76
+ # pages for CHR/ABS/INT/SQR/LEN). Each takes already-evaluated argument values.
77
+ def _fn_chr(args: list[S.ConstValue]) -> str:
78
+ # any numeric argument is accepted by the real compilers (CHR(-1), CHR(1e300) probed 2026-10-10).
79
+ # A NUL byte adds nothing: CHR(256), CHR(0)+"A"+CHR(0)+"B" are "", "AB" (round 10, see checker.md)
80
+ v = _num(args[0])
81
+ code = int(v) & 0xFF if v == v and abs(v) != float("inf") else 0
82
+ return chr(code) if code else ""
83
+
84
+
85
+ def _fn_abs(args: list[S.ConstValue]) -> float:
86
+ return abs(_num(args[0]))
87
+
88
+
89
+ def _fn_int(args: list[S.ConstValue]) -> float:
90
+ v = _num(args[0])
91
+ import math
92
+
93
+ return float(math.floor(v))
94
+
95
+
96
+ def _fn_sqr(args: list[S.ConstValue]) -> float:
97
+ root = _num(args[0]) ** 0.5
98
+ if isinstance(root, complex):
99
+ raise NotConstant("SQR() of a negative constant is not folded")
100
+ return root
101
+
102
+
103
+ def _fn_len(args: list[S.ConstValue]) -> int:
104
+ v = args[0]
105
+ if not isinstance(v, str):
106
+ raise NotConstant("LEN() of a non-string constant")
107
+ return len(v)
108
+
109
+
110
+ _BUILTINS = {"CHR": _fn_chr, "CHR$": _fn_chr, "ABS": _fn_abs, "INT": _fn_int, "SQR": _fn_sqr, "LEN": _fn_len}
111
+
112
+
113
+ def _num(v: object) -> float:
114
+ if isinstance(v, bool):
115
+ return -1.0 if v else 0.0
116
+ if isinstance(v, (int, float)):
117
+ try:
118
+ return float(v)
119
+ except OverflowError as exc:
120
+ # Huge integer literals have no recorded oracle value; stop folding rather than
121
+ # crash (round-67 FTPClient review, docs/checker.md#round-67-review-fixes).
122
+ raise NotConstant("number outside float range") from exc
123
+ raise NotConstant("expected a number")
124
+
125
+
126
+ def _bool(v: object) -> bool:
127
+ return _num(v) != 0
128
+
129
+
130
+ def to_long(value: int | float | str) -> int:
131
+ """The C `(long)` conversion applied to a constant parameter (oracle, 2026-10-02): truncated
132
+ toward zero (`0.5` -> 0, `-1.9` -> -1); a string, NaN or a value outside Long range gives
133
+ `-2147483648`."""
134
+ if isinstance(value, str) or value != value:
135
+ return -2147483648
136
+ try:
137
+ n = int(value)
138
+ except OverflowError:
139
+ return -2147483648
140
+ return n if -2147483648 <= n <= 2147483647 else -2147483648
141
+
142
+
143
+ def is_finite_number(value: object) -> TypeGuard[int | float]:
144
+ """Usable by integer-converting rules; infinities stay constants in `value_of`
145
+ (no reference verdict: docs/checker.md#round-67-review-fixes)."""
146
+ return isinstance(value, (int, float)) and (not isinstance(value, float) or math.isfinite(value))
147
+
148
+
149
+ def _truth(v: bool) -> int:
150
+ return -1 if v else 0
151
+
152
+
153
+ def value_of(expr: S.Expr | None, env: Env) -> S.ConstValue:
154
+ """The constant value of `expr` (int/float/str), or raise `NotConstant`. An integer beyond the
155
+ float range (a literal of 309 digits) is not a constant: the reference gives no usable verdict
156
+ for it, and the numeric rules convert to float. Infinities stay values: `String * 1e999` has
157
+ recorded verdicts (rounds 29 and 54; round 69, docs/checker.md#round-67-review-fixes)."""
158
+ value = _value_of(expr, env)
159
+ if isinstance(value, int) and not isinstance(value, bool) and abs(value) > _FLOAT_MAX_INT:
160
+ raise NotConstant("number outside the float range")
161
+ return value
162
+
163
+
164
+ _FLOAT_MAX_INT = int(sys.float_info.max)
165
+
166
+
167
+ def _value_of(expr: S.Expr | None, env: Env) -> S.ConstValue:
168
+ if expr is None:
169
+ raise NotConstant("empty expression")
170
+ if isinstance(expr, S.Number):
171
+ return expr.value
172
+ if isinstance(expr, S.String):
173
+ return expr.value
174
+ if isinstance(expr, S.Name):
175
+ return _lookup(expr.name, env)
176
+ if isinstance(expr, S.Unary):
177
+ v = value_of(expr.operand, env)
178
+ if expr.op == "-":
179
+ return -_num(v)
180
+ if expr.op == "+":
181
+ return _num(v)
182
+ if expr.op == "NOT":
183
+ return _truth(not _bool(v))
184
+ raise NotConstant(f"unsupported unary operator {expr.op!r}")
185
+ if isinstance(expr, S.Binary):
186
+ left, right = value_of(expr.left, env), value_of(expr.right, env)
187
+ try:
188
+ return _binary(expr.op, left, right)
189
+ except (ZeroDivisionError, OverflowError) as exc:
190
+ # not foldable; only `/` is modelled (the real compiler reports it where an instruction
191
+ # argument is folded, `checker/expressions.py` `check_splitstr_whole_array`)
192
+ err = (
193
+ DivideByZero(f"{expr.op!r} cannot be folded: {exc}")
194
+ if expr.op == "/" and isinstance(exc, ZeroDivisionError)
195
+ else NotConstant(f"{expr.op!r}: {exc}")
196
+ )
197
+ if isinstance(err, DivideByZero):
198
+ err.numerator = left
199
+ raise err from exc
200
+ if isinstance(expr, S.Index):
201
+ fn = _BUILTINS.get(expr.name.upper())
202
+ if fn is None:
203
+ raise NotConstant(f"{expr.name} is not a constant-foldable function")
204
+ if len(expr.args) != 1:
205
+ raise NotConstant(f"{expr.name} takes one argument")
206
+ args = [value_of(a, env) for a in expr.args]
207
+ return fn(args)
208
+ raise NotConstant(f"{type(expr).__name__} has no constant value")
209
+
210
+
211
+ def is_float(expr: S.Expr | None, env: Env) -> bool | None:
212
+ """Whether a constant expression is float-typed (a decimal literal, `Const` of one, or any
213
+ `+ - * \\ MOD` with a float operand; `/`, `^` and `Int()` are always float, `Abs` keeps its
214
+ operand's type -- oracle, CR6, 2026-10-10, SDM address probes). `None`: not established."""
215
+ if isinstance(expr, S.Number):
216
+ return isinstance(expr.value, float)
217
+ if isinstance(expr, S.Name):
218
+ upper = expr.name.upper()
219
+ if upper in env.consts:
220
+ v = env.consts[upper]
221
+ return True if isinstance(v, CFloat) else False if isinstance(v, int) else None
222
+ sym = env.profile.symbols.get(upper)
223
+ return None if sym is None else isinstance(sym["value"], float)
224
+ if isinstance(expr, S.Unary) and expr.op in ("-", "+"):
225
+ return is_float(expr.operand, env)
226
+ if isinstance(expr, S.Binary) and expr.op in ("/", "^"):
227
+ return True
228
+ if isinstance(expr, S.Binary) and expr.op in ("+", "-", "*", "\\", "MOD"):
229
+ sides = [is_float(expr.left, env), is_float(expr.right, env)]
230
+ return True if True in sides else None if None in sides else False
231
+ if isinstance(expr, S.Index) and expr.name.upper() == "INT":
232
+ return True
233
+ if isinstance(expr, S.Index) and expr.name.upper() == "ABS" and len(expr.args) == 1:
234
+ return is_float(expr.args[0], env)
235
+ return None
236
+
237
+
238
+ def truthy(v: object) -> bool:
239
+ """CRBasic truthiness of an already-evaluated constant (0 / 0.0 / "" are false)."""
240
+ if isinstance(v, str):
241
+ return v != ""
242
+ return _num(v) != 0
243
+
244
+
245
+ def is_constant(expr: S.Expr | None, env: Env) -> bool:
246
+ try:
247
+ value_of(expr, env)
248
+ return True
249
+ except NotConstant:
250
+ return False
251
+
252
+
253
+ def _lookup(name: str, env: Env) -> S.ConstValue:
254
+ upper = name.upper()
255
+ if upper == "SECTION" and env.section is not None:
256
+ return env.section
257
+ if upper in env.consts:
258
+ return env.consts[upper]
259
+ sym = env.profile.symbols.get(upper)
260
+ if sym is not None:
261
+ return sym["value"]
262
+ raise NotConstant(f"{name} is not a constant")
263
+
264
+
265
+ def _as_str(v: object) -> str:
266
+ if isinstance(v, str):
267
+ return v
268
+ n = _num(v)
269
+ return str(int(n)) if n == int(n) else str(n)
270
+
271
+
272
+ def _binary(op: str, left: S.ConstValue, right: S.ConstValue) -> S.ConstValue:
273
+ if op == "TO": # `Scan(1 To 2,..)` is interval 0, `PortGet(X,5 To 2)` port 0 (oracle, round 9).
274
+ # A string left operand is accepted too: `Const K = "a" To 3` gives K=0; a string right is not
275
+ _num(right)
276
+ return 0
277
+ if op == "&":
278
+ return _as_str(left) + _as_str(right)
279
+ if op == "+":
280
+ if isinstance(left, str) or isinstance(right, str):
281
+ return _as_str(left) + _as_str(right)
282
+ return _num(left) + _num(right)
283
+ if op == "-":
284
+ return _num(left) - _num(right)
285
+ if op == "*":
286
+ return _num(left) * _num(right)
287
+ if op == "/":
288
+ return _num(left) / _num(right)
289
+ if op == "\\":
290
+ return float(int(_num(left)) // int(_num(right)))
291
+ if op == "^":
292
+ power = _num(left) ** _num(right)
293
+ if isinstance(power, complex):
294
+ raise NotConstant("a negative base to a fractional power is not folded")
295
+ return power
296
+ if op == "MOD":
297
+ return float(int(_num(left)) % int(_num(right)))
298
+ if op == "<<":
299
+ return float(int(_num(left)) << int(_num(right)))
300
+ if op == ">>":
301
+ return float(int(_num(left)) >> int(_num(right)))
302
+ if op == "AND":
303
+ return float(int(_num(left)) & int(_num(right)))
304
+ if op == "OR":
305
+ return float(int(_num(left)) | int(_num(right)))
306
+ if op == "XOR":
307
+ return float(int(_num(left)) ^ int(_num(right)))
308
+ if op == "EQV":
309
+ return _truth(_bool(left) == _bool(right))
310
+ if op == "IMP":
311
+ return _truth((not _bool(left)) or _bool(right))
312
+ if op in ("=", "<>", "<", ">", "<=", ">="):
313
+ if isinstance(left, str) or isinstance(right, str):
314
+ return _truth(_compare(op, _as_str(left), _as_str(right)))
315
+ return _truth(_compare(op, _num(left), _num(right)))
316
+ raise NotConstant(f"unsupported operator {op!r}")
317
+
318
+
319
+ def _compare[T: (str, float)](op: str, left: T, right: T) -> bool:
320
+ return {
321
+ "=": left == right,
322
+ "<>": left != right,
323
+ "<": left < right,
324
+ ">": left > right,
325
+ "<=": left <= right,
326
+ ">=": left >= right,
327
+ }[op]
@@ -0,0 +1,4 @@
1
+ {
2
+ "threshold_ms": 8,
3
+ "busy_slack": 23
4
+ }