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
opencrbasic/sources.py ADDED
@@ -0,0 +1,125 @@
1
+ """Where program text comes from: the disk, or the disk plus unsaved editor buffers.
2
+
3
+ The loader (`source.py`) asks a `SourceProvider` for file bytes and directory listings instead of
4
+ touching the filesystem itself, so the language-server facade (`analysis.py`) can analyse a
5
+ program whose files have unsaved edits through the same compile driver the CLI uses. Keys are
6
+ absolute, non-lowercased, non-symlink-resolved paths (`Path.absolute()`). `DiskSource` is exactly
7
+ the loader's historical behaviour. See `docs/lsp-api.md`.
8
+ """
9
+
10
+ import hashlib
11
+ import stat
12
+ from collections.abc import Mapping
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+
17
+ class SourceProvider(Protocol):
18
+ def read(self, path: Path) -> bytes | None:
19
+ """The file's bytes; None when it is missing or unreadable."""
20
+
21
+ def list_dir(self, directory: Path) -> list[str] | None:
22
+ """Names of the files (not subdirectories) in `directory`; None when it is not a directory."""
23
+
24
+
25
+ class DiskSource:
26
+ def read(self, path: Path) -> bytes | None:
27
+ try:
28
+ return Path(path).read_bytes()
29
+ except OSError:
30
+ return None
31
+
32
+ def list_dir(self, directory: Path) -> list[str] | None:
33
+ directory = Path(directory)
34
+ if not directory.is_dir():
35
+ return None
36
+ return [entry.name for entry in directory.iterdir() if entry.is_file()]
37
+
38
+ def list_subdirs(self, directory: Path) -> list[str] | None:
39
+ directory = Path(directory)
40
+ if not directory.is_dir():
41
+ return None
42
+ return [entry.name for entry in directory.iterdir() if entry.is_dir()]
43
+
44
+
45
+ class OverlaySource:
46
+ """`base` with `overlays` (path -> bytes) on top. An overlay file also appears in its
47
+ directory's listing, even when `base` has no such file or no such directory."""
48
+
49
+ def __init__(self, base: SourceProvider, overlays: Mapping[Path, bytes] | None = None):
50
+ self.base = base
51
+ self.overlays: dict[Path, bytes] = {Path(p).absolute(): bytes(b) for p, b in (overlays or {}).items()}
52
+
53
+ def read(self, path: Path) -> bytes | None:
54
+ data = self.overlays.get(Path(path).absolute())
55
+ return data if data is not None else self.base.read(path)
56
+
57
+ def list_dir(self, directory: Path) -> list[str] | None:
58
+ directory = Path(directory).absolute()
59
+ names = self.base.list_dir(directory)
60
+ extra = sorted(p.name for p in self.overlays if p.parent == directory)
61
+ if names is None:
62
+ return extra or None
63
+ have = set(names)
64
+ return names + [n for n in extra if n not in have]
65
+
66
+ def list_subdirs(self, directory: Path) -> list[str] | None:
67
+ subdirs = getattr(self.base, "list_subdirs", None)
68
+ return subdirs(directory) if subdirs else None
69
+
70
+
71
+ class LimitedSource:
72
+ """`inner` without the files larger than `limit` bytes: such a file reads as missing (None)
73
+ and is recorded in `oversized` (path -> size). The size of an overlay is its length; a disk
74
+ file is checked with `stat` before it is read, so an oversized file is never read."""
75
+
76
+ def __init__(self, inner: OverlaySource, limit: int):
77
+ self.inner = inner
78
+ self.limit = limit
79
+ self.oversized: dict[Path, int] = {}
80
+
81
+ def read(self, path: Path) -> bytes | None:
82
+ data = self.inner.overlays.get(Path(path).absolute())
83
+ if data is None:
84
+ try:
85
+ info = Path(path).stat()
86
+ except OSError:
87
+ info = None
88
+ if info is not None and stat.S_ISREG(info.st_mode) and info.st_size > self.limit:
89
+ self.oversized[Path(path)] = info.st_size
90
+ return None
91
+ data = self.inner.read(path)
92
+ if data is not None and len(data) > self.limit: # an overlay, or a file that grew
93
+ self.oversized[Path(path)] = len(data)
94
+ return None
95
+ return data
96
+
97
+ def list_dir(self, directory: Path) -> list[str] | None:
98
+ return self.inner.list_dir(directory)
99
+
100
+ def list_subdirs(self, directory: Path) -> list[str] | None:
101
+ return self.inner.list_subdirs(directory)
102
+
103
+
104
+ class RecordingSource:
105
+ """Wraps `inner` and records what was asked: `reads` maps path -> sha256 hex of the bytes (None
106
+ for a miss), `listings` maps directory -> tuple of sorted names (None for a non-directory)."""
107
+
108
+ def __init__(self, inner: SourceProvider):
109
+ self.inner = inner
110
+ self.reads: dict[Path, str | None] = {}
111
+ self.listings: dict[Path, tuple[str, ...] | None] = {}
112
+
113
+ def read(self, path: Path) -> bytes | None:
114
+ data = self.inner.read(path)
115
+ self.reads[Path(path)] = None if data is None else hashlib.sha256(data).hexdigest()
116
+ return data
117
+
118
+ def list_dir(self, directory: Path) -> list[str] | None:
119
+ names = self.inner.list_dir(directory)
120
+ self.listings[Path(directory)] = None if names is None else tuple(sorted(names))
121
+ return names
122
+
123
+ def list_subdirs(self, directory: Path) -> list[str] | None:
124
+ subdirs = getattr(self.inner, "list_subdirs", None)
125
+ return subdirs(directory) if subdirs else None
opencrbasic/syntax.py ADDED
@@ -0,0 +1,337 @@
1
+ """Syntax tree for CRBasic.
2
+
3
+ A program is a flat list of statements, one per logical source line (or per `:`-separated
4
+ part). Block structure (If/EndIf, Scan/NextScan, DataTable/EndTable, ...) is kept flat, as the
5
+ original compiler does, and is checked by `opencrbasic.structure`. Every node carries the
6
+ 1-based source line it came from and, for diagnostics, the file it came from.
7
+ """
8
+
9
+ import dataclasses
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ from .provenance import LoadProvenance
14
+
15
+ if TYPE_CHECKING:
16
+ from .lexer import Token
17
+ from .source import LogicalLine
18
+
19
+ type ConstValue = int | float | str # a folded constant (see `consteval.value_of`)
20
+
21
+
22
+ @dataclass
23
+ class Node:
24
+ line: int
25
+ file: str = ""
26
+
27
+
28
+ # ---------------------------------------------------------------- expressions
29
+
30
+
31
+ @dataclass
32
+ class Expr(Node):
33
+ pass
34
+
35
+
36
+ @dataclass
37
+ class Number(Expr):
38
+ value: float | int = 0
39
+ text: str = "" # source spelling, e.g. "&H1F", "1.5E3"
40
+
41
+
42
+ @dataclass
43
+ class String(Expr):
44
+ value: str = ""
45
+
46
+
47
+ @dataclass
48
+ class Name(Expr):
49
+ """A bare identifier: variable, constant, predefined symbol, table name, ..."""
50
+
51
+ name: str = ""
52
+
53
+
54
+ @dataclass
55
+ class Index(Expr):
56
+ """`name(i, j, k)`: array element, or a call when `name` is a builtin function / Function."""
57
+
58
+ name: str = ""
59
+ args: list[Expr | None] = field(default_factory=list) # None marks an empty argument slot
60
+
61
+
62
+ @dataclass
63
+ class Member(Expr):
64
+ """`Table.Field`, `Status.Battery(1)`, `Struct.member`."""
65
+
66
+ base: Expr | None = None
67
+ member: str = ""
68
+ args: list[Expr | None] | None = None
69
+
70
+
71
+ @dataclass
72
+ class Unary(Expr):
73
+ op: str = "" # "-", "+", "NOT"
74
+ operand: Expr | None = None
75
+
76
+
77
+ @dataclass
78
+ class ArrayLiteral(Expr):
79
+ """`{a, b, c}`: a `Const` array value, or an array initialiser in a `Public`/`Dim`."""
80
+
81
+ items: list[Expr | None] = field(default_factory=list)
82
+
83
+
84
+ @dataclass
85
+ class Juxtaposed(ArrayLiteral):
86
+ """Operands side by side in a declaration dimension (`Dim G(1 8)`), which the compiler reports
87
+ as `Incomplete expression`; `items` are the operands, so name walkers see all of them."""
88
+
89
+
90
+ @dataclass
91
+ class Binary(Expr):
92
+ op: str = "" # upper-case operator text: + - * / \ ^ & MOD AND OR XOR IMP EQV = <> < > <= >= << >>
93
+ left: Expr | None = None
94
+ right: Expr | None = None
95
+
96
+
97
+ # ---------------------------------------------------------------- statements
98
+
99
+
100
+ class _StatementMetadata:
101
+ """Parser-attached details; annotations preserve the absence of unset attributes."""
102
+
103
+ origin: tuple[LogicalLine, int]
104
+ dropped_tail: list[Token]
105
+ grouped: bool
106
+ hoisted_last: bool
107
+ arg_spans: list[tuple[int, int] | None]
108
+ alias_junk: bool
109
+ alias_item_junk: list[bool]
110
+ extra_targets: list[Expr]
111
+
112
+
113
+ @dataclass
114
+ class Stmt(Node, _StatementMetadata):
115
+ text: str = "" # the logical line as written (for diagnostics that echo it)
116
+ at_eol: bool = True # `text` (this statement's own `:`-separated segment) reaches the
117
+ # true end of its physical source line -- no `'` comment and no further `:`-segment cut it
118
+ # short. Set by `parser.py`'s `_parse_line`; used only by `checker/structure.py`'s
119
+ # `_out_of_place` to decide whether CR300/CR350 print the fallback's closing `.` on its own
120
+ # output line (`docs/loggers.md` "Generic fallback echo on CR300/CR350").
121
+
122
+
123
+ class _DeclarationMetadata:
124
+ size_missing: int | None
125
+ size_junk: tuple[int, str] | None
126
+
127
+
128
+ @dataclass
129
+ class VarDecl(_DeclarationMetadata):
130
+ name: str
131
+ dims: list[Expr] = field(default_factory=list) # up to 3
132
+ type: str | None = (
133
+ None # upper-case: FLOAT LONG BOOLEAN STRING DOUBLE UINT1 UINT2 UINT4 BOOL8 NSEC or a StructureType name
134
+ )
135
+ string_size: Expr | None = None # `As String * n`
136
+ init: list[Expr | None] | None = None # `= value` or `= {a, b, c}`
137
+ units: str | None = None
138
+ dim0_span: tuple[int, int] | None = None # (start, end) columns of the first
139
+ # dimension expression's own source text within the enclosing `Declaration.text` -- lets
140
+ # `checker/structure.py` echo it verbatim for `Variable %s dimension exceeds max of %d.`
141
+ # (oracle-confirmed to always name the *first* dimension, `docs/checker.md`), without the
142
+ # parser needing to reconstruct an expression's source spelling from its AST.
143
+ paren_dim: bool = False # a `(` inside the dimension list makes the variable Float whatever its `As` type
144
+ junk_dims: list[Expr] = field(default_factory=list)
145
+ empty_type_init: bool = False # `As Float() = ...`: `Invalid, or out of place expression: =.` (round 54) # the dimensions as written when one is a `Juxtaposed`; `dims` then holds 1 in its place
146
+
147
+
148
+ @dataclass
149
+ class Declaration(Stmt):
150
+ """Public / Dim / Static (keyword upper-case in `kind`)."""
151
+
152
+ kind: str = ""
153
+ vars: list[VarDecl] = field(default_factory=list)
154
+
155
+
156
+ @dataclass
157
+ class ConstDecl(Stmt):
158
+ name: str = ""
159
+ value: Expr | None = None
160
+ type: str | None = None
161
+ value_span: tuple[int, int] | None = None # (start, end) columns of `value`'s own source
162
+ # text within `Stmt.text` -- lets `checker/expressions.py` echo the initializer verbatim for
163
+ # `check_consts`'s undefined-symbol message, the same way `Instruction.arg_spans` already
164
+ # lets `_arg_text` echo an argument (`docs/checker.md` "Round 4"'s whole-expression echo).
165
+
166
+
167
+ @dataclass
168
+ class Assignment(Stmt):
169
+ target: Expr | None = None # Name, Index or Member
170
+ value: Expr | None = None
171
+ compound: bool = False # `X += 1`, stored as `X = X + 1`
172
+
173
+
174
+ @dataclass
175
+ class Instruction(Stmt):
176
+ """Any statement of the form `Keyword [(] args [)]`, including block keywords.
177
+
178
+ `keyword` is the instruction-table spelling in upper case (`SCAN`, `NEXTSCAN`, `DATATABLE`,
179
+ `SAMPLE`, `ENDIF`, `CALLTABLE`, ...). `args` are the comma-separated arguments, None for an
180
+ empty slot. Statements that need extra structure use the dedicated classes below.
181
+ """
182
+
183
+ keyword: str = ""
184
+ args: list[Expr | None] = field(default_factory=list)
185
+ arg_spans: list[tuple[int, int] | None] = field(default_factory=list) # (start, end) columns
186
+ # of each argument's own raw source text within this statement's `text` (`None` for an empty
187
+ # slot); lets a checker echo an argument's *verbatim* spelling (e.g. `1+2`, not a re-derived
188
+ # `3` or a spacing-normalised `1 + 2`) without reconstructing it from the parsed `Expr` tree.
189
+
190
+
191
+ @dataclass
192
+ class If(Stmt):
193
+ """`If cond Then` (block form, `inline` None) or `If cond Then stmt [Else stmt]`."""
194
+
195
+ cond: Expr | None = None
196
+ inline: list[Stmt] | None = None
197
+ inline_else: list[Stmt] | None = None
198
+
199
+
200
+ @dataclass
201
+ class ElseIf(Stmt):
202
+ cond: Expr | None = None
203
+
204
+
205
+ @dataclass
206
+ class For(Stmt):
207
+ var: Expr | None = None
208
+ start: Expr | None = None
209
+ end: Expr | None = None
210
+ step: Expr | None = None
211
+
212
+
213
+ @dataclass
214
+ class DoLoop(Stmt):
215
+ """`Do [While|Until cond]` or `Loop [While|Until cond]`; `keyword` is DO or LOOP."""
216
+
217
+ keyword: str = ""
218
+ cond_kind: str | None = None # WHILE / UNTIL
219
+ cond: Expr | None = None
220
+
221
+
222
+ type CaseItem = tuple[Literal["value"], Expr] | tuple[Literal["range"], Expr, Expr] | tuple[Literal["is"], str, Expr]
223
+
224
+
225
+ @dataclass
226
+ class Case(Stmt):
227
+ """`Case a, b To c, Is > d, Else`; each item is ("value", e) / ("range", lo, hi) / ("is", op, e)."""
228
+
229
+ items: list[CaseItem] = field(default_factory=list)
230
+ is_else: bool = False
231
+
232
+
233
+ @dataclass
234
+ class Param:
235
+ name: str
236
+ by_ref: bool | None = None # None when not written
237
+ type: str | None = None
238
+ string_size: Expr | None = None
239
+ dims: int = 0 # `x()` style array parameter
240
+ default: Expr | None = None # `Optional x = 1`
241
+ # Parsed expressions inside `x(...)`, in source order; alias declaration validation and
242
+ # editor bindings use these. They take no part in equality or repr.
243
+ dim_exprs: list[Expr] = field(default_factory=list, compare=False, repr=False)
244
+
245
+
246
+ @dataclass
247
+ class SubDef(Stmt):
248
+ """`Sub name(params)` or `Function name(params) [As type]`; `keyword` SUB or FUNCTION."""
249
+
250
+ keyword: str = ""
251
+ name: str = ""
252
+ params: list[Param] = field(default_factory=list)
253
+ return_type: str | None = None
254
+ # pass-1 messages of a malformed header (`parser._parse_subdef`), in source order
255
+ header_errors: list[str] = field(default_factory=list, compare=False, repr=False)
256
+
257
+
258
+ @dataclass
259
+ class Call(Stmt):
260
+ """`Call name(args)` or a bare `name(args)` / `name args` invocation of a Sub."""
261
+
262
+ name: str = ""
263
+ args: list[Expr | None] = field(default_factory=list)
264
+ explicit: bool = False # written with `Call`
265
+ tail_ignored: bool = False # `S()` followed by ignored trailing text
266
+ tail_expr: Expr | None = None # a `Function` call statement continued as one expression
267
+ juxtaposed: list[Expr] | None = None # operands of a juxtaposed tail: `Fn(1) 1`
268
+ juxt_opaque: bool = False # tail starts with `,` or `(`: `Fn(1),Zork`
269
+
270
+
271
+ @dataclass
272
+ class Label(Stmt):
273
+ name: str = ""
274
+
275
+
276
+ @dataclass
277
+ class Unparsed(Stmt):
278
+ """A line the parser could not handle; carries the reason for diagnostics."""
279
+
280
+ reason: str = ""
281
+ expr: Expr | None = None # the expression of a statement the parser took apart but cannot compile
282
+
283
+
284
+ def ends_routine(stmt: Stmt) -> bool:
285
+ """`EndSub`/`EndFunction`, or the two-word `End Sub`/`End Function` (parsed as `END` with the
286
+ word as its argument); the real compiler closes the routine with either spelling (oracle,
287
+ 2026-10-12, all four loggers, `docs/checker.md` "Two-word routine closers")."""
288
+ if not isinstance(stmt, Instruction):
289
+ return False
290
+ kw = stmt.keyword.upper()
291
+ if kw in ("ENDSUB", "ENDFUNCTION"):
292
+ return True
293
+ return (
294
+ kw == "END"
295
+ and bool(stmt.args)
296
+ and isinstance(stmt.args[0], Name)
297
+ and stmt.args[0].name.upper() in ("SUB", "FUNCTION")
298
+ )
299
+
300
+
301
+ def hash_statement(st: Stmt) -> Unparsed | None:
302
+ """The `#...` statement that ends the compile (`Unknown instruction`): `st` itself or one in
303
+ an inline `If` body."""
304
+ if isinstance(st, Unparsed):
305
+ return st if st.text.lstrip().startswith("#") else None
306
+ for sub in (getattr(st, "inline", None) or []) + (getattr(st, "inline_else", None) or []):
307
+ if (h := hash_statement(sub)) is not None:
308
+ return h
309
+ return None
310
+
311
+
312
+ def cut_after_hash(st: Stmt) -> Stmt:
313
+ """`st` without the inline statements that follow its `#...` statement (and without the `Else`
314
+ body when the `#...` is in the `Then` body): the compile stops there."""
315
+ then, other = getattr(st, "inline", None) or [], getattr(st, "inline_else", None) or []
316
+ for body, name in ((then, "inline"), (other, "inline_else")):
317
+ k = next((i for i, sub in enumerate(body) if hash_statement(sub)), None)
318
+ if k is not None and isinstance(st, If):
319
+ cut = body[:k] + [cut_after_hash(body[k])]
320
+ if name == "inline":
321
+ return dataclasses.replace(st, inline=cut, inline_else=[])
322
+ return dataclasses.replace(st, inline_else=cut)
323
+ return st
324
+
325
+
326
+ @dataclass
327
+ class Program(LoadProvenance):
328
+ name: str
329
+ statements: list[Stmt] = field(default_factory=list)
330
+ last_line: int = 0 # `EndProg`'s line, else the main file's last physical line
331
+ hash_cut: int | None = None # parser diagnostics kept: those before the first `#...` statement ends
332
+ unclosed_if: bool = False # a `#If` still open at the end of the file (before any `EndProg`)
333
+ include_error_at: list[int] = field(default_factory=list) # statement count when each reported
334
+ # include error (extension, nested include) was read: where the CR300/CR350 cascade starts
335
+ consts: dict[str, ConstValue] = field(default_factory=dict) # name (upper) -> resolved value,
336
+ # for every `Const` the parser could constant-fold in an active branch; `checker.py` reuses
337
+ # this (via `consteval.Env`) instead of re-walking the program to rebuild it.