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.
- opencrbasic/__init__.py +3 -0
- opencrbasic/__main__.py +5 -0
- opencrbasic/analysis.py +184 -0
- opencrbasic/bindings.py +1034 -0
- opencrbasic/catalog.py +173 -0
- opencrbasic/checkconfig.py +144 -0
- opencrbasic/checker/__init__.py +977 -0
- opencrbasic/checker/cr3xx.py +3118 -0
- opencrbasic/checker/ess.py +81 -0
- opencrbasic/checker/expressions.py +3581 -0
- opencrbasic/checker/instr_data.py +1802 -0
- opencrbasic/checker/instr_measure.py +1547 -0
- opencrbasic/checker/instr_rules.py +3540 -0
- opencrbasic/checker/instructions.py +17 -0
- opencrbasic/checker/intervals.py +678 -0
- opencrbasic/checker/longconv.py +133 -0
- opencrbasic/checker/scan_time.py +580 -0
- opencrbasic/checker/storage.py +456 -0
- opencrbasic/checker/structs.py +240 -0
- opencrbasic/checker/structure.py +6213 -0
- opencrbasic/checker/symbols.py +609 -0
- opencrbasic/checker/terminals.py +249 -0
- opencrbasic/checker/unused.py +186 -0
- opencrbasic/checker/var_types.py +564 -0
- opencrbasic/checker/warnings.py +661 -0
- opencrbasic/checks.py +491 -0
- opencrbasic/cli.py +391 -0
- opencrbasic/commands.py +128 -0
- opencrbasic/compiler.py +320 -0
- opencrbasic/consteval.py +327 -0
- opencrbasic/data/background_calibration.json +4 -0
- opencrbasic/data/before_beginprog.json +99 -0
- opencrbasic/data/cr1000x/background_calibration.json +4 -0
- opencrbasic/data/cr1000x/before_beginprog.json +138 -0
- opencrbasic/data/cr1000x/ess_variables.json +1087 -0
- opencrbasic/data/cr1000x/fnotch.json +29 -0
- opencrbasic/data/cr1000x/functions.json +1337 -0
- opencrbasic/data/cr1000x/instructions.json +4836 -0
- opencrbasic/data/cr1000x/measure.json +121 -0
- opencrbasic/data/cr1000x/mode.json +111 -0
- opencrbasic/data/cr1000x/pipeline_tasks.json +88 -0
- opencrbasic/data/cr1000x/settings_fields.json +321 -0
- opencrbasic/data/cr1000x/status_members.json +318 -0
- opencrbasic/data/cr1000x/symbols.json +872 -0
- opencrbasic/data/cr1000x/terminals.json +225 -0
- opencrbasic/data/cr300/before_beginprog.json +42 -0
- opencrbasic/data/cr300/ess_variables.json +1087 -0
- opencrbasic/data/cr300/fnotch.json +22 -0
- opencrbasic/data/cr300/functions.json +1261 -0
- opencrbasic/data/cr300/instructions.json +2837 -0
- opencrbasic/data/cr300/measure.json +33 -0
- opencrbasic/data/cr300/mode.json +121 -0
- opencrbasic/data/cr300/settings_fields.json +259 -0
- opencrbasic/data/cr300/status_members.json +303 -0
- opencrbasic/data/cr300/symbols.json +584 -0
- opencrbasic/data/cr300/terminals.json +193 -0
- opencrbasic/data/cr350/before_beginprog.json +44 -0
- opencrbasic/data/cr350/ess_variables.json +1087 -0
- opencrbasic/data/cr350/fnotch.json +22 -0
- opencrbasic/data/cr350/functions.json +1262 -0
- opencrbasic/data/cr350/instructions.json +2848 -0
- opencrbasic/data/cr350/measure.json +35 -0
- opencrbasic/data/cr350/mode.json +121 -0
- opencrbasic/data/cr350/settings_fields.json +292 -0
- opencrbasic/data/cr350/status_members.json +303 -0
- opencrbasic/data/cr350/symbols.json +572 -0
- opencrbasic/data/cr350/terminals.json +190 -0
- opencrbasic/data/ess_variables.json +1087 -0
- opencrbasic/data/fnotch.json +34 -0
- opencrbasic/data/functions.json +1337 -0
- opencrbasic/data/instructions.json +4965 -0
- opencrbasic/data/measure.json +123 -0
- opencrbasic/data/mode.json +121 -0
- opencrbasic/data/notices_cr6.json +890 -0
- opencrbasic/data/pipeline_tasks.json +84 -0
- opencrbasic/data/settings_fields.json +322 -0
- opencrbasic/data/status_members.json +319 -0
- opencrbasic/data/status_table.json +684 -0
- opencrbasic/data/symbols.json +902 -0
- opencrbasic/data/terminals.json +195 -0
- opencrbasic/data/type_codes.json +249 -0
- opencrbasic/diagnostics.py +196 -0
- opencrbasic/editing.py +827 -0
- opencrbasic/formatting.py +310 -0
- opencrbasic/helpdocs.py +677 -0
- opencrbasic/hover.py +667 -0
- opencrbasic/inlay.py +169 -0
- opencrbasic/lexer.py +183 -0
- opencrbasic/lsp/__init__.py +5 -0
- opencrbasic/lsp/__main__.py +7 -0
- opencrbasic/lsp/config.py +202 -0
- opencrbasic/lsp/features.py +602 -0
- opencrbasic/lsp/server.py +741 -0
- opencrbasic/lsp/session.py +1189 -0
- opencrbasic/lsp/worker.py +128 -0
- opencrbasic/mode.py +143 -0
- opencrbasic/navigation.py +401 -0
- opencrbasic/parser.py +3264 -0
- opencrbasic/positions.py +274 -0
- opencrbasic/preprocess.py +269 -0
- opencrbasic/profiles.py +266 -0
- opencrbasic/provenance.py +17 -0
- opencrbasic/quickfix.py +331 -0
- opencrbasic/rename.py +662 -0
- opencrbasic/signature.py +33 -0
- opencrbasic/source.py +600 -0
- opencrbasic/sources.py +125 -0
- opencrbasic/syntax.py +337 -0
- opencrbasic/tables.py +2173 -0
- opencrbasic/tables_hook.py +14 -0
- opencrbasic/tdf.py +142 -0
- opencrbasic/timing.py +565 -0
- opencrbasic-1.0.0.dist-info/METADATA +223 -0
- opencrbasic-1.0.0.dist-info/RECORD +117 -0
- opencrbasic-1.0.0.dist-info/WHEEL +4 -0
- opencrbasic-1.0.0.dist-info/entry_points.txt +3 -0
- opencrbasic-1.0.0.dist-info/licenses/LICENSE +21 -0
opencrbasic/compiler.py
ADDED
|
@@ -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
|
opencrbasic/consteval.py
ADDED
|
@@ -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]
|