opencrbasic 1.0.0__tar.gz → 1.1.1__tar.gz

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 (119) hide show
  1. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/PKG-INFO +20 -5
  2. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/README.md +19 -4
  3. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/pyproject.toml +4 -2
  4. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/__init__.py +1 -1
  5. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/analysis.py +2 -1
  6. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/__init__.py +3 -4
  7. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/cr3xx.py +3 -4
  8. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/expressions.py +3 -5
  9. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/instr_data.py +5 -7
  10. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/instr_rules.py +1 -1
  11. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/structure.py +8 -16
  12. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/terminals.py +3 -3
  13. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/warnings.py +4 -9
  14. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checks.py +4 -4
  15. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/cli.py +22 -5
  16. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/compiler.py +8 -4
  17. opencrbasic-1.1.1/src/opencrbasic/macros.py +161 -0
  18. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/parser.py +2 -0
  19. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/source.py +26 -1
  20. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/tests/corpus/README.md +21 -21
  21. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/.gitignore +0 -0
  22. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/LICENSE +0 -0
  23. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/__main__.py +0 -0
  24. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/bindings.py +0 -0
  25. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/catalog.py +0 -0
  26. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checkconfig.py +0 -0
  27. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/ess.py +0 -0
  28. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/instr_measure.py +0 -0
  29. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/instructions.py +0 -0
  30. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/intervals.py +0 -0
  31. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/longconv.py +0 -0
  32. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/scan_time.py +0 -0
  33. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/storage.py +0 -0
  34. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/structs.py +0 -0
  35. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/symbols.py +0 -0
  36. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/unused.py +0 -0
  37. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/checker/var_types.py +0 -0
  38. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/commands.py +0 -0
  39. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/consteval.py +0 -0
  40. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/background_calibration.json +0 -0
  41. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/before_beginprog.json +0 -0
  42. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/background_calibration.json +0 -0
  43. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/before_beginprog.json +0 -0
  44. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/ess_variables.json +0 -0
  45. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/fnotch.json +0 -0
  46. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/functions.json +0 -0
  47. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/instructions.json +0 -0
  48. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/measure.json +0 -0
  49. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/mode.json +0 -0
  50. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/pipeline_tasks.json +0 -0
  51. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/settings_fields.json +0 -0
  52. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/status_members.json +0 -0
  53. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/symbols.json +0 -0
  54. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr1000x/terminals.json +0 -0
  55. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/before_beginprog.json +0 -0
  56. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/ess_variables.json +0 -0
  57. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/fnotch.json +0 -0
  58. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/functions.json +0 -0
  59. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/instructions.json +0 -0
  60. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/measure.json +0 -0
  61. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/mode.json +0 -0
  62. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/settings_fields.json +0 -0
  63. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/status_members.json +0 -0
  64. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/symbols.json +0 -0
  65. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr300/terminals.json +0 -0
  66. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/before_beginprog.json +0 -0
  67. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/ess_variables.json +0 -0
  68. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/fnotch.json +0 -0
  69. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/functions.json +0 -0
  70. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/instructions.json +0 -0
  71. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/measure.json +0 -0
  72. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/mode.json +0 -0
  73. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/settings_fields.json +0 -0
  74. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/status_members.json +0 -0
  75. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/symbols.json +0 -0
  76. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/cr350/terminals.json +0 -0
  77. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/ess_variables.json +0 -0
  78. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/fnotch.json +0 -0
  79. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/functions.json +0 -0
  80. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/instructions.json +0 -0
  81. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/measure.json +0 -0
  82. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/mode.json +0 -0
  83. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/notices_cr6.json +0 -0
  84. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/pipeline_tasks.json +0 -0
  85. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/settings_fields.json +0 -0
  86. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/status_members.json +0 -0
  87. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/status_table.json +0 -0
  88. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/symbols.json +0 -0
  89. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/terminals.json +0 -0
  90. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/data/type_codes.json +0 -0
  91. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/diagnostics.py +0 -0
  92. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/editing.py +0 -0
  93. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/formatting.py +0 -0
  94. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/helpdocs.py +0 -0
  95. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/hover.py +0 -0
  96. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/inlay.py +0 -0
  97. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lexer.py +0 -0
  98. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/__init__.py +0 -0
  99. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/__main__.py +0 -0
  100. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/config.py +0 -0
  101. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/features.py +0 -0
  102. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/server.py +0 -0
  103. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/session.py +0 -0
  104. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/lsp/worker.py +0 -0
  105. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/mode.py +0 -0
  106. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/navigation.py +0 -0
  107. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/positions.py +0 -0
  108. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/preprocess.py +0 -0
  109. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/profiles.py +0 -0
  110. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/provenance.py +0 -0
  111. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/quickfix.py +0 -0
  112. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/rename.py +0 -0
  113. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/signature.py +0 -0
  114. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/sources.py +0 -0
  115. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/syntax.py +0 -0
  116. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/tables.py +0 -0
  117. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/tables_hook.py +0 -0
  118. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/tdf.py +0 -0
  119. {opencrbasic-1.0.0 → opencrbasic-1.1.1}/src/opencrbasic/timing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencrbasic
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Open-source re-implementation of the Campbell Scientific CRBasic pre-compilers (CR6, CR1000X, CR300, CR350)
5
5
  Project-URL: Repository, https://github.com/UtrechtUniversity/open-crbasic
6
6
  Author: Utrecht University
@@ -38,8 +38,8 @@ Status, measured against the real compilers: `PROGRESS.md`. Design and the v1.0
38
38
  ## Scope
39
39
 
40
40
  - **Gated profiles: CR6 and CR1000X.** On these, v1.0 gates the reference verdict in the relevant
41
- area (the FirnMelt/GEUS instruction set and language constructs), the execution mode, and the
42
- first error for realistic mistakes on relevant rules, measured on the FirnMelt programs, the 507
41
+ area (the station/GEUS instruction set and language constructs), the execution mode, and the
42
+ first error for realistic mistakes on relevant rules, measured on the station programs, the 507
43
43
  CRBasic help examples, the 66 GEUS programs, the saved fuzz replay and fresh `--relevant` fuzz
44
44
  seeds. The help examples and GEUS programs are not redistributed here; see
45
45
  [tests/corpus/README.md](tests/corpus/README.md) for running against local copies. One relevant-area false accept is known: a `Sub` name that is also another routine's
@@ -118,6 +118,22 @@ crbc [options] program [messagefile]
118
118
  --report FILE write a JSON report (reference verdict and diagnostics, check findings)
119
119
  --config FILE use this opencrbasic.toml (default: nearest ancestor of the program)
120
120
  --check CODE=SEVERITY set a check severity (error|warning|info|off); repeatable
121
+ --expand FILE expand #Define/#Macro macros into FILE, then compile FILE instead
122
+ ```
123
+
124
+ `#Define` and `#Macro` are an opencrbasic extension, not CRBasic: the reference compilers reject
125
+ them. `--expand` writes plain CRBasic (definitions commented out, uses expanded inline) that
126
+ `CR6Comp.exe` accepts and that is the file to send to the logger; write it next to the source so
127
+ its `Include`s resolve. The language server expands them the same way, so the editor reports
128
+ the expansion's diagnostics on the line of each use. Syntax and rules:
129
+ [`macros.py`](src/opencrbasic/macros.py).
130
+
131
+ ```crbasic
132
+ #Define LOGD(msg) DebugMsg = msg ' release build: #Define LOGD(msg)
133
+ #Macro LOGV(name, value)
134
+ DebugMsg = name & "=" & value
135
+ CallTable Debug
136
+ #EndMacro
121
137
  ```
122
138
 
123
139
  Check configuration and the report schema: `docs/checker.md` "Check configuration and report file".
@@ -143,8 +159,7 @@ result.tables # list of tdf.TdfTable (Status, DataTables, DataTableInfo, ConstT
143
159
  `parser.parse_file()` gives the syntax tree (`syntax.py`) for other tools.
144
160
 
145
161
  [openloggernet](https://github.com/UtrechtUniversity/open-loggernet) uses opencrbasic when
146
- `CR6COMP=opencrbasic` is set, and the FirnMelt station repository compiles every program with it in
147
- CI.
162
+ `CR6COMP=opencrbasic` is set.
148
163
 
149
164
  ## Editor support
150
165
 
@@ -21,8 +21,8 @@ Status, measured against the real compilers: `PROGRESS.md`. Design and the v1.0
21
21
  ## Scope
22
22
 
23
23
  - **Gated profiles: CR6 and CR1000X.** On these, v1.0 gates the reference verdict in the relevant
24
- area (the FirnMelt/GEUS instruction set and language constructs), the execution mode, and the
25
- first error for realistic mistakes on relevant rules, measured on the FirnMelt programs, the 507
24
+ area (the station/GEUS instruction set and language constructs), the execution mode, and the
25
+ first error for realistic mistakes on relevant rules, measured on the station programs, the 507
26
26
  CRBasic help examples, the 66 GEUS programs, the saved fuzz replay and fresh `--relevant` fuzz
27
27
  seeds. The help examples and GEUS programs are not redistributed here; see
28
28
  [tests/corpus/README.md](tests/corpus/README.md) for running against local copies. One relevant-area false accept is known: a `Sub` name that is also another routine's
@@ -101,6 +101,22 @@ crbc [options] program [messagefile]
101
101
  --report FILE write a JSON report (reference verdict and diagnostics, check findings)
102
102
  --config FILE use this opencrbasic.toml (default: nearest ancestor of the program)
103
103
  --check CODE=SEVERITY set a check severity (error|warning|info|off); repeatable
104
+ --expand FILE expand #Define/#Macro macros into FILE, then compile FILE instead
105
+ ```
106
+
107
+ `#Define` and `#Macro` are an opencrbasic extension, not CRBasic: the reference compilers reject
108
+ them. `--expand` writes plain CRBasic (definitions commented out, uses expanded inline) that
109
+ `CR6Comp.exe` accepts and that is the file to send to the logger; write it next to the source so
110
+ its `Include`s resolve. The language server expands them the same way, so the editor reports
111
+ the expansion's diagnostics on the line of each use. Syntax and rules:
112
+ [`macros.py`](src/opencrbasic/macros.py).
113
+
114
+ ```crbasic
115
+ #Define LOGD(msg) DebugMsg = msg ' release build: #Define LOGD(msg)
116
+ #Macro LOGV(name, value)
117
+ DebugMsg = name & "=" & value
118
+ CallTable Debug
119
+ #EndMacro
104
120
  ```
105
121
 
106
122
  Check configuration and the report schema: `docs/checker.md` "Check configuration and report file".
@@ -126,8 +142,7 @@ result.tables # list of tdf.TdfTable (Status, DataTables, DataTableInfo, ConstT
126
142
  `parser.parse_file()` gives the syntax tree (`syntax.py`) for other tools.
127
143
 
128
144
  [openloggernet](https://github.com/UtrechtUniversity/open-loggernet) uses opencrbasic when
129
- `CR6COMP=opencrbasic` is set, and the FirnMelt station repository compiles every program with it in
130
- CI.
145
+ `CR6COMP=opencrbasic` is set.
131
146
 
132
147
  ## Editor support
133
148
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opencrbasic"
3
- version = "1.0.0"
3
+ version = "1.1.1"
4
4
  description = "Open-source re-implementation of the Campbell Scientific CRBasic pre-compilers (CR6, CR1000X, CR300, CR350)"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -31,7 +31,8 @@ crbc-lsp = "opencrbasic.lsp.server:main"
31
31
  # `uv sync` installs `dev` by default: the test runner and the reverse-engineering tools
32
32
  # (`tools/extract_tables.py`, `tools/message_catalog.py` read the exe with pefile), and pygls
33
33
  # so the suite can test the language server; pytest-xdist for parallel test runs (`-n`); pyrefly
34
- # checks the repository (`[tool.pyrefly]`); pre-commit runs ruff and pyrefly before each commit.
34
+ # checks the repository (`[tool.pyrefly]`); pre-commit runs ruff and pyrefly before each commit;
35
+ # openloggernet drives a logger for `tools/logger_oracle.py`.
35
36
  dev = [
36
37
  "pytest>=8",
37
38
  "pefile>=2024.8.26",
@@ -39,6 +40,7 @@ dev = [
39
40
  "pytest-xdist>=3.6",
40
41
  "pyrefly==1.3.1",
41
42
  "pre-commit>=4",
43
+ "openloggernet>=0.3.0",
42
44
  ]
43
45
 
44
46
  [build-system]
@@ -1,3 +1,3 @@
1
1
  """Open-source CRBasic (CR6) pre-compiler."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.1"
@@ -14,7 +14,7 @@ from .compiler import compile_file
14
14
  from .sources import DiskSource, LimitedSource, OverlaySource, RecordingSource, SourceProvider
15
15
 
16
16
  API_VERSION = 2 # 2: findings, notices, config_error, AnalysisRequest.checks (M6)
17
- DOCS = "https://github.com/UtrechtUniversity/firnmelt-datalogger/blob/main/opencrbasic/"
17
+ DOCS = "https://github.com/UtrechtUniversity/open-crbasic/blob/main/"
18
18
 
19
19
 
20
20
  def _no_overlays() -> Mapping[Path, bytes]:
@@ -120,6 +120,7 @@ def analyze(request: AnalysisRequest) -> AnalysisResult:
120
120
  warn_unused=request.warn_unused,
121
121
  source=recorder,
122
122
  signature=request.signature,
123
+ macros=True,
123
124
  )
124
125
  includes = getattr(result.program_ast, "include_paths", None) or {}
125
126
  lowered = {name.lower(): path for name, path in includes.items()}
@@ -953,10 +953,9 @@ def _check(program: S.Program, profile: Profile, diags: Diagnostics, result) ->
953
953
 
954
954
  if not diags.errors:
955
955
  # `When Main Sequence Scan %d is running, background calibration will be disabled.`:
956
- # round 6, moved here from the unconditional `warnings.check_early` call above -- oracle-
957
- # confirmed *not* ungated the way `_check_reachability`/`_check_fnotch` are, unlike this
958
- # module's own docstring previously claimed for all of `check_early` (`checker/warnings.py`'s
959
- # `check_background_calibration` docstring has the oracle evidence). Independent of
956
+ # oracle-confirmed *not* ungated the way `_check_reachability`/`_check_fnotch` are
957
+ # (`checker/warnings.py`'s `check_background_calibration` docstring has the oracle evidence).
958
+ # Independent of
960
959
  # `tables_hook`'s own availability, so called ahead of that guard rather than inside it.
961
960
  _run_pass(warnings.check_background_calibration, program, profile, diags, env)
962
961
  _run_pass(warnings.check_slow_scan_skips, program, profile, diags, env)
@@ -389,9 +389,8 @@ def check(program: S.Program, profile, diags: Diagnostics) -> None:
389
389
  # `acceptdatarecords_1.CR6`, `SerialOpen (COMC1,...)`'s `COMC1 is not defined.` taints every
390
390
  # following two-plus-argument instruction to end of file, `Scan`'s own `Sec` and
391
391
  # `AcceptDataRecords`'s own `32769`, despite that finding being **ungated** and thus, in this
392
- # project's own pass model, skipping tier B -- where the *previous*, narrower version of this
393
- # function lived -- entirely), this is no longer called from inside the `if not diags.errors:`
394
- # tier-B block the way `check_expr_cascade` was: `checker/__init__.py` now calls it once,
392
+ # project's own pass model, skipping tier B entirely), this is not called from inside the
393
+ # `if not diags.errors:` tier-B block: `checker/__init__.py` calls it once,
395
394
  # unconditionally, after every other diagnostic-producing pass has run, so it can see -- and rewrite
396
395
  # -- the complete, final diagnostic list regardless of which tier first tainted the file. It is
397
396
  # still a no-op when nothing in the file ever taints (no `_is_cascade_trigger` match and no
@@ -555,7 +554,7 @@ def check_too_many_cascade(program: S.Program, profile, diags: Diagnostics) -> N
555
554
  """CR300/CR350 only; a no-op on every other logger and when nothing in the file ever taints
556
555
  (see this module's comment above for the oracle evidence and the exact shape implemented).
557
556
  Called from `checker/__init__.py` once, unconditionally, after every other pass -- see that
558
- comment for why this can no longer be tier-B-only the way `check_expr_cascade` was.
557
+ comment for why this cannot be tier-B-only.
559
558
  """
560
559
  if profile.name not in ("cr300", "cr350"):
561
560
  return
@@ -25,9 +25,8 @@ _TYPE_CODES_PATH = Path(__file__).resolve().parent.parent / "data" / "type_codes
25
25
  # separate function (`4`/`16`/`17`/`19` `FUN_004b5db0`, a different failure message). Restricting
26
26
  # to this explicit include-list (rather than excluding the handful of kinds known *not* to
27
27
  # resolve) is deliberate: the default for an unrecognised kind is "don't check", so a gap in this
28
- # list only under-reports, never falsely rejects (AGENTS.md's priority order); the `ConstTable`
29
- # regression this session found during development (`crbasic/Main.CR6`'s
30
- # `ConstTable(GlobalConfigTable)`, kind `expr_marker`) is the concrete case that shape of bug
28
+ # list only under-reports, never falsely rejects; the station corpus's `Main.CR6`
29
+ # `ConstTable(GlobalConfigTable)` (kind `expr_marker`) is the concrete case that shape of bug
31
30
  # looks like.
32
31
  _RESOLVED_KINDS = frozenset(
33
32
  {
@@ -74,8 +73,7 @@ _CONST_FAMILY_KINDS = frozenset({"const_expr", "const_expr_only", "datatype_kw"}
74
73
  # separate `FooBar is not defined.` / `BazQux is not defined.` lines on CR300/CR350, in the source
75
74
  # (left-to-right) order `_undefined_names` already walks in. Modelled below as a plain per-logger
76
75
  # branch on `profile.name` (both loggers in each pair share identical behaviour, oracle-confirmed
77
- # side by side) rather than a new `Profile` field, to keep this session's change inside
78
- # `checker/expressions.py`/`checker/symbols.py` (`PLAN.md`'s ownership split).
76
+ # side by side) rather than a new `Profile` field.
79
77
  _WHOLE_EXPR_ECHO_LOGGERS = frozenset({"cr6", "cr1000x"})
80
78
 
81
79
  # `NAN`/`INF` are accepted as predefined float constants on all four loggers (oracle-confirmed,
@@ -45,13 +45,11 @@ from . import symbols
45
45
  # `docs/instructions.md`'s "not independently oracle-confirmed" list for exactly which those are.
46
46
  # One more call site exists (the unlabelled string `"Et"`, near the `SampleFieldCal` call site)
47
47
  # that could not be attributed to a specific instruction with confidence and is deliberately left
48
- # out -- AGENTS.md: never guess into a false reject. The other originally-unlabelled string this
49
- # round found nearby, `"FFT"`, *was* independently confirmed (a fresh scratch probe, `tfft.cr6`,
50
- # and two CHM corpus files whose `DataTable` bodies contain only `FFT(...)` calls, `fft_1.CR6`/
51
- # `covspa_1.CR6` -- both were a **false-reject regression** this round's own full difftest re-run
52
- # caught, from `check_table_structure`'s sibling `Table %s declared with no output fields.` rule
53
- # below not yet recognising `FFT` as a field producer, before `FFT` was added here too) and is
54
- # included as `FFT`.
48
+ # out: never guess into a false reject. The other unlabelled string nearby, `"FFT"`, is
49
+ # independently confirmed (a scratch probe, `tfft.cr6`, and two CHM corpus files whose
50
+ # `DataTable` bodies contain only `FFT(...)` calls, `fft_1.CR6`/`covspa_1.CR6`, which compile
51
+ # only when `FFT` also counts as a field producer for `Table %s declared with no output
52
+ # fields.`) and is included as `FFT`.
55
53
  TABLE_ONLY_INSTRUCTIONS: dict[str, str] = {
56
54
  "CARDOUT": "CardOut", # checked with the oracle
57
55
  "MQTTPUBLISHTABLE": "MQTTPublishTable",
@@ -1223,7 +1223,7 @@ def check_data_interval(program: S.Program, profile, diags: Diagnostics, env: co
1223
1223
  per-logger difference, not shared with CR300/CR350 (`_SCAN_VALUE_CHECK_LOGGERS`; found the
1224
1224
  same way as `check_scan_params`'s own CR300/CR350 gate, same session).
1225
1225
  - `DataTable interval %d uSec not integrally divisible by scan interval %d uSec.` (function
1226
- `FUN_004f6540`) -- project knowledge (`knowledge/crbasic/lessons.md` "`DataInterval` versus
1226
+ `FUN_004f6540`) -- project knowledge (open-crbasic-skills `lessons.md` "`DataInterval` versus
1227
1227
  `Scan`": "a table interval must be at least the scan interval and an exact integer multiple
1228
1228
  of it", human-verified against `CR6Comp.exe`), reported at each `CallTable <name>` site whose
1229
1229
  table has a `DataInterval` and which sits directly inside a `Scan...NextScan` block (main
@@ -2186,7 +2186,7 @@ def check_pass2(
2186
2186
  "Before `BeginProg`" only means the top level of the program, never a statement lexically
2187
2187
  inside a `Sub`/`Function` body (regardless of that body's own textual position, almost always
2188
2188
  before `BeginProg` itself, `docs/checker.md` "Pass model": found as a **regression** against
2189
- this repository's own `crbasic/Main.CR6` and several siblings, all of which call a built-in
2189
+ the station corpus's `Main.CR6` and several siblings, all of which call a built-in
2190
2190
  function bare, with no `Call` keyword, e.g. `SerialOpen(...)` inside a `Function`/`Sub` defined
2191
2191
  above `BeginProg` -- perfectly ordinary CRBasic, since the body only executes when the
2192
2192
  `Sub`/`Function` is later called from inside `Scan`/`NextScan`, oracle-reconfirmed by re-running
@@ -5120,8 +5120,7 @@ def _check_array_dims(stmt: S.Declaration, diags: Diagnostics, env: consteval.En
5120
5120
  separate variables of the same declaration.
5121
5121
 
5122
5122
  A dimension that does not fold to a constant at all (`NotConstant`) is left alone: no oracle
5123
- evidence this session found supports guessing at that shape, and AGENTS.md's priority (never
5124
- reject an accepted program) rules out it.
5123
+ evidence supports guessing at that shape, and a guess could reject an accepted program.
5125
5124
  """
5126
5125
  for index, v in enumerate(stmt.vars):
5127
5126
  before = len(diags.items)
@@ -5779,24 +5778,17 @@ def _check_alias_dims(
5779
5778
  check in this project) into a single **row-major flat offset** (`_alias_flat_offset`:
5780
5779
  `flat = i_n + sum_{k<n} (i_k - 1) * prod(d_{k+1..n})`, the ordinary multi-dimensional-array-
5781
5780
  as-flat-buffer formula, last index varying fastest) that must land in `[1,
5782
- prod(all declared dims)]`. Round 6's own original account of this check ("only the *first*
5783
- index is ever checked, however far out of range a later one is") is **superseded**: it was
5784
- built from two probes (`Public grid(3,3)`: `Alias grid(2,4) = Ts` compiles, `Alias grid(4,2)
5785
- = Ts` fails) that happen to *also* agree with the flat-offset formula purely by coincidence
5786
- (`(2-1)*3+4=7` and `(4-1)*3+2=11`, both landing on the correct side of `[1,9]` for unrelated
5787
- reasons) -- a fresh, wider oracle sweep this round (`docs/checker.md` "Round 7") found the
5788
- "first index alone" account is wrong in general: `Alias grid(4,-2) = Ts` (first index `4`,
5789
- out of `[1,3]` by the old account) **compiles clean** (`(4-1)*3+(-2)=7`, in `[1,9]`), while
5790
- `Alias grid(2,-1) = Ts` (a valid first index, an out-of-range *second* index by the old
5791
- account, which the old rule never checked at all) also compiles clean
5792
- (`(2-1)*3+(-1)=2`, in `[1,9]`) -- both correctly predicted by the flat formula, neither by
5793
- "only check position 0". A **scalar** (no declared dimensions at all) behaves exactly like a
5781
+ prod(all declared dims)]`. Oracle evidence (`docs/checker.md` "Round 7"), for
5782
+ `Public grid(3,3)`: `Alias grid(2,4) = Ts` compiles (`(2-1)*3+4=7`), `Alias grid(4,2) = Ts`
5783
+ fails (`11`), and `Alias grid(4,-2) = Ts` (`7`) and `Alias grid(2,-1) = Ts` (`2`) both
5784
+ compile clean: the first index is not checked on its own. A **scalar** (no declared
5785
+ dimensions at all) behaves exactly like a
5794
5786
  size-1 array (`Public y` / `Alias y(1) = Ts` compiles, `Alias y(2) = Ts` fails: flat
5795
5787
  formula with `dims=(1,)` reduces to `flat=i1`, range `[1,1]`), which is why `declared_all_
5796
5788
  dims` maps every declared variable, not just array ones, the same convention `dim0_sizes`
5797
5789
  already used. Skipped (no check at all, under-reporting only) when any declared dimension or
5798
5790
  any index argument does not fold to a constant (`declared_all_dims`/the tuple-folding loop
5799
- below, both AGENTS.md-conservative the same way check 3 already was) or when the index
5791
+ below, both conservative the same way check 3 is) or when the index
5800
5792
  tuple's own length does not match the variable's own declared dimension count (no oracle
5801
5793
  evidence gathered for that shape either way).
5802
5794
  3. **`Duplicate Alias`** (round 7; no trailing period, no echoed name): the source side's own
@@ -1,8 +1,8 @@
1
1
  """Per-logger analog-channel and port terminal model.
2
2
 
3
- Generalises the analog-channel range check (originally CR6-only, hard-coded `U1..U12`) across all
4
- four loggers, driven by oracle-probed per-logger data (`data/<logger>/terminals.json`; evidence,
5
- probes and the message-text differences: `docs/loggers.md`, `docs/instructions.md`).
3
+ Models the analog-channel range check for all four loggers, driven by oracle-probed per-logger
4
+ data (`data/<logger>/terminals.json`; evidence, probes and the message-text differences:
5
+ `docs/loggers.md`, `docs/instructions.md`).
6
6
 
7
7
  Each logger's `terminals.json` names its own channel "kinds" -- CR6 has separate `u_terminal`
8
8
  (single-ended-shaped: any `U1..U12`, no oddness constraint; `ACPower`, `CS616`'s `CS616Chan`,
@@ -405,15 +405,10 @@ def check_background_calibration(program: S.Program, profile: Profile, diags: Di
405
405
  `Invalid symbol in expression` error present (`Delay never reached at run time.` alongside it,
406
406
  same probe file). A warning-only file (no errors, e.g. a reachability warning with no error
407
407
  anywhere) still gets *both* warnings together, confirming this is specifically errors, not "any
408
- diagnostic", that gates it. Moved to `checker/__init__.py`'s own final `if not diags.errors:`
409
- tier (alongside `warnings.check`/`tables_hook.attach`, the same "only on an otherwise-clean
410
- compile" gate already used there) instead of `check_early`'s unconditional call site -- this is
411
- the only change; the rule and message above are unchanged. Found as a side effect of developing
412
- `instr_rules.check_scan_min_interval` (this round): dozens of pre-existing `false_accept`/
413
- `message_diff` fuzz reproducers (`tests/corpus/fuzz/cr6/message_diff/ba333c2ee146.CR6` and
414
- siblings) already showed this exact extra, spurious warning line alongside an unrelated real
415
- error -- a genuine, previously-undocumented false-accept-shaped bug independent of this round's
416
- own `Scan`-interval work, not a regression it introduced.
408
+ diagnostic", that gates it. It is therefore called from `checker/__init__.py`'s final
409
+ `if not diags.errors:` tier (alongside `warnings.check`/`tables_hook.attach`), not from
410
+ `check_early`'s unconditional call site. Fuzz reproducers such as
411
+ `tests/corpus/fuzz/cr6/message_diff/ba333c2ee146.CR6` cover the gate.
417
412
  """
418
413
  data = profile.background_calibration
419
414
  if not data:
@@ -147,7 +147,7 @@ def _ambiguous_names(program: S.Program) -> set[str]:
147
147
  def _numeric_concatenation(program: S.Program, profile) -> list[Finding]:
148
148
  """OCB002: a String `+` (or `&`) assigned to a numeric variable (`L = 2000 + Mid(S,5,2)`). The
149
149
  reference accepts it, but `+` with a String operand concatenates, and the variable receives
150
- the converted text: a GPS clock set this way read 2057 (`knowledge/crbasic/lessons.md` "`+` with
150
+ the converted text: a GPS clock set this way read 2057 (open-crbasic-skills `lessons.md` "`+` with
151
151
  a string operand concatenates", hardware 2026-10-22). Only a chain mixing numeric and String
152
152
  operands is reported: a String alone (`X = Mid(...)`) is a plain conversion, and Strings joined
153
153
  into a number's text (`X = HexStr(6) + "." + HexStr(7)`, GEUS programs) are deliberate."""
@@ -263,8 +263,8 @@ def _zero_divisors(program: S.Program, profile) -> list[Finding]:
263
263
 
264
264
  def _unsized_string_parameters(program: S.Program, profile) -> list[Finding]:
265
265
  """OCB005: a `Sub`/`Function` String parameter without `* size`. It holds 23 characters (24
266
- bytes with the terminator) and a longer argument is cut (hardware, `knowledge/crbasic/
267
- lessons.md` "Default string parameter size"). Information only: a short argument is fine."""
266
+ bytes with the terminator) and a longer argument is cut (hardware, open-crbasic-skills
267
+ `lessons.md` "Default string parameter size"). Information only: a short argument is fine."""
268
268
  return [
269
269
  Finding(
270
270
  "OCB005",
@@ -284,7 +284,7 @@ def _unsized_string_parameters(program: S.Program, profile) -> list[Finding]:
284
284
  def _unaligned_intervals(program: S.Program, profile) -> list[Finding]:
285
285
  """OCB006: a constant `DataInterval` that divides neither an hour nor a day (`7 Min`, `25 Hr`).
286
286
  Its records are phased from the start hour and may not align with midnight (hardware,
287
- `knowledge/crbasic/lessons.md` "`DataInterval` phase"). Information only; an interval of 0
287
+ open-crbasic-skills `lessons.md` "`DataInterval` phase"). Information only; an interval of 0
288
288
  (every call) is not reported."""
289
289
  env, out = _const_env(program, profile), []
290
290
  for st in _statements(program):
@@ -11,7 +11,8 @@ accepted (matching the exe's own option set) but has no implemented effect: orac
11
11
  have none observable from the CLI either. Extra long options: --logger
12
12
  {cr6,cr1000x,cr300,cr350} (default: from the file extension, else cr6) and --no-message-file (do
13
13
  not write the message file), --strict (opencrbasic checks, `checks.py`, on stderr only), --report FILE
14
- (JSON report, `docs/checker.md` "Check configuration and report file"), --config FILE and
14
+ (JSON report, `docs/checker.md` "Check configuration and report file"), --expand FILE (expand
15
+ `#Define`/`#Macro` macros into FILE and compile FILE instead, `macros.py`), --config FILE and
15
16
  --check CODE=SEVERITY (check configuration; both only act together with --strict or --report).
16
17
  Output goes to stdout and to the message file (`<LOGGER>cmple.xxx` by default, e.g. CR6cmple.xxx;
17
18
  CRLF line endings);
@@ -47,6 +48,7 @@ class _Options(TypedDict):
47
48
  strict: bool
48
49
  report: str | None
49
50
  config: str | None
51
+ expand: str | None
50
52
  check: list[str]
51
53
 
52
54
 
@@ -68,6 +70,7 @@ def _parse(argv: list[str]) -> tuple[_Options, list[str]]:
68
70
  "strict": False,
69
71
  "report": None,
70
72
  "config": None,
73
+ "expand": None,
71
74
  "check": [],
72
75
  }
73
76
  positional: list[str] = []
@@ -75,10 +78,10 @@ def _parse(argv: list[str]) -> tuple[_Options, list[str]]:
75
78
  while i < len(argv):
76
79
  arg = argv[i]
77
80
  if arg.startswith("--"):
78
- if arg in ("--logger", "--report", "--config", "--check"):
81
+ if arg in ("--logger", "--report", "--config", "--expand", "--check"):
79
82
  value = argv[i + 1] if i + 1 < len(argv) else ""
80
83
  i += 2
81
- elif arg.split("=", 1)[0] in ("--logger", "--report", "--config", "--check"):
84
+ elif arg.split("=", 1)[0] in ("--logger", "--report", "--config", "--expand", "--check"):
82
85
  arg, value = arg.split("=", 1)
83
86
  i += 1
84
87
  else:
@@ -87,7 +90,7 @@ def _parse(argv: list[str]) -> tuple[_Options, list[str]]:
87
90
  if arg == "--check":
88
91
  opts["check"].append(value)
89
92
  else:
90
- opts[cast(Literal["logger", "report", "config"], arg[2:])] = value # noqa: F821 -- ruff reads the Literal strings as names
93
+ opts[cast(Literal["logger", "report", "config", "expand"], arg[2:])] = value # noqa: F821 -- ruff reads the Literal strings as names
91
94
  continue
92
95
  if arg == "--no-message-file":
93
96
  opts["message_file"] = False
@@ -139,7 +142,7 @@ def _message_bytes(lines: list[str]) -> bytes:
139
142
  """`lines` joined the way the message file actually wants them: CRLF throughout, except the
140
143
  blank line straight after a `-- Compile Failed!` header, which the real exe writes as a bare
141
144
  LF (oracle-confirmed on both a plain failing compile and a `-t`/`-b` one, `docs/cli.md`
142
- "Message file layout" -- previously assumed to be a uniform CRLF join, which this replaces).
145
+ "Message file layout").
143
146
  """
144
147
  if len(lines) >= 2 and lines[1] == "" and lines[0].endswith("Failed!"):
145
148
  return (lines[0] + "\r\n\n" + "\r\n".join(lines[2:]) + "\r\n").encode("latin-1", "replace")
@@ -254,6 +257,20 @@ def main(argv: list[str] | None = None) -> int:
254
257
  _write_report(opts["report"], checks.missing_program_report(program.name, profile.name, opts["strict"]))
255
258
  return 1
256
259
 
260
+ if opts["expand"]:
261
+ from . import macros
262
+
263
+ try:
264
+ expanded = macros.expand(program.read_bytes().decode("latin-1"))
265
+ except macros.MacroError as e:
266
+ lines = [f"{positional[0]} -- Compile Failed!", "", f"line {e.line}: {e}"]
267
+ print("\n".join(lines))
268
+ if opts["message_file"]:
269
+ _write_message_file(message_path, lines)
270
+ return 1
271
+ program = Path(opts["expand"])
272
+ program.write_bytes(expanded.encode("latin-1", "replace"))
273
+
257
274
  result = compile_file(program, profile, display_name=program.name, warn_unused=opts["u"], provenance=False)
258
275
  if opts["p"]:
259
276
  from . import preprocess
@@ -156,7 +156,7 @@ class CompileResult:
156
156
  # no-line `-u` warning, regardless of which included file declared it, prints before every
157
157
  # "table declared but never called" warning). `_no_line_kind` below reproduces that
158
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
159
+ # without `checker/warnings.py` needing to tag its own
160
160
  # diagnostics; a stable sort keeps each kind's own relative (insertion) order within itself.
161
161
  # On one line the reachability warning follows that line's errors (oracle: 15 of 15 corpus
162
162
  # and fuzz transcripts); other warnings keep their check order.
@@ -200,9 +200,12 @@ def compile_file(
200
200
  source: SourceProvider | None = None,
201
201
  signature: bool = True,
202
202
  provenance: bool = True,
203
+ macros: bool = False,
203
204
  ) -> CompileResult:
204
205
  """`provenance=False` (the CLI and the tools) skips the position data only the language server
205
206
  reads (`LogicalLine.pieces`, `Program.line_map`, `Stmt.origin`); the compile output is unchanged.
207
+ `macros=True` (the language server) expands the entry file's `#Define`/`#Macro` macros
208
+ (`macros.py`), which the reference rejects.
206
209
 
207
210
  Never lets an unexpected exception escape (`docs/checker.md` "Robustness"): the real
208
211
  compiler never crashes on a syntactically-loadable file, so an internal bug in this project
@@ -215,7 +218,7 @@ def compile_file(
215
218
  """
216
219
  profile = profile or profiles.for_file(path)
217
220
  name = display_name or path.name
218
- result = _compile_once(path, profile, name, None, source, provenance)
221
+ result = _compile_once(path, profile, name, None, source, provenance, macros)
219
222
  if profile.single_pass:
220
223
  # CR300/CR350 stop pulling a `Public`/`Dim` initializer's next line once any error has been
221
224
  # reported ahead of the declaration (oracle, 2026-10-10, `docs/checker.md` "Round 8"): re-run
@@ -233,7 +236,7 @@ def compile_file(
233
236
  if bad is None or (limit is not None and bad >= limit):
234
237
  break
235
238
  limit = bad
236
- result = _compile_once(path, profile, name, limit, source, provenance)
239
+ result = _compile_once(path, profile, name, limit, source, provenance, macros)
237
240
  if result.program_ast is None:
238
241
  return result
239
242
  return _finish(result, path, warn_unused, source, signature)
@@ -246,12 +249,13 @@ def _compile_once(
246
249
  pull_limit: tuple[int, ...] | None,
247
250
  source: SourceProvider | None = None,
248
251
  provenance: bool = True,
252
+ macros: bool = False,
249
253
  ):
250
254
  from . import checker, parser
251
255
 
252
256
  try:
253
257
  program, diags = parser.parse_file(
254
- path, profile=profile, pull_limit=pull_limit, source=source, provenance=provenance
258
+ path, profile=profile, pull_limit=pull_limit, source=source, provenance=provenance, macros=macros
255
259
  )
256
260
  except Exception as exc:
257
261
  diags = Diagnostics()
@@ -0,0 +1,161 @@
1
+ """`crbc --expand FILE`: opencrbasic's own macro step, not a reference-compiler feature.
2
+
3
+ The reference compilers reject `#Define` (`Unknown instruction`), so a program that uses macros is
4
+ expanded first into plain CRBasic, which any compiler (`crbc`, `CR6Comp.exe`) accepts and which is
5
+ the file to send to the logger. Macros expand inline, so they cost no call depth (the logger runs at
6
+ most four nested `Sub`/`Function` calls).
7
+
8
+ #Define DEBUG 1 object-like, one line
9
+ #Define LOGD(msg) DebugMsg = msg function-like, one line (an empty body is allowed)
10
+ #Macro LOGV(name, value) function-like, several lines
11
+ DebugMsg = name & "=" & value
12
+ CallTable Debug
13
+ #EndMacro
14
+
15
+ - Names and parameters are case-insensitive, like CRBasic. A function-like name not followed by
16
+ `(` is left alone; a macro is defined from its line on and cannot be redefined.
17
+ - Nothing inside a `"..."` string or a `'` comment is expanded. A one-line body loses its
18
+ trailing comment.
19
+ - A macro body is expanded again after substitution, without expanding itself (no recursion).
20
+ - Every other line, `#If` and the other reference directives included, is kept as written apart
21
+ from the expansion of macro names; the definition lines become comments so line numbers before
22
+ the first multi-line expansion stay the same. The lines of a multi-line expansion take the
23
+ indentation of the line that uses it.
24
+ - A macro call must close on its own line. Macros in `Include` files are not read or expanded.
25
+ - The language server expands macros the same way while analysing (`source.py`, the entry file
26
+ only), so a program with macros gets the diagnostics of its expansion, each on the line of the
27
+ macro use. `crbc` without `--expand` compiles like the reference and rejects `#Define`.
28
+ """
29
+
30
+ import re
31
+ import textwrap
32
+
33
+ _DEFINE_RE = re.compile(r"^\s*#\s*(Define|Macro)\s+([A-Za-z_]\w*)(?:\(([^)]*)\))?(.*)$", re.IGNORECASE)
34
+ _END_RE = re.compile(r"^\s*#\s*EndMacro\b", re.IGNORECASE)
35
+ # strings and comments are copied whole; a number or `&H`/`&B` literal is never a name
36
+ _TOKEN_RE = re.compile(r'"[^"\n]*"?|\'[^\n]*|\d\w*|&[HhBb]\w*|[A-Za-z_]\w*|.', re.DOTALL)
37
+
38
+
39
+ class MacroError(Exception):
40
+ def __init__(self, line: int, message: str):
41
+ super().__init__(message)
42
+ self.line = line
43
+
44
+
45
+ class _Macro:
46
+ __slots__ = ("params", "body")
47
+
48
+ def __init__(self, params: list[str] | None, body: str):
49
+ self.params = params # None: object-like
50
+ self.body = body
51
+
52
+
53
+ def _strip_comment(text: str) -> str:
54
+ return "".join(t for t in _TOKEN_RE.findall(text) if not t.startswith("'"))
55
+
56
+
57
+ def _call_args(text: str, i: int, line: int, name: str) -> tuple[list[str], int]:
58
+ """The arguments of the call whose `(` is at `text[i]`, and the index after its `)`."""
59
+ args, current, depth = [], [], 0
60
+ for m in _TOKEN_RE.finditer(text, i + 1):
61
+ tok = m.group()
62
+ if tok == "\n":
63
+ break
64
+ if tok == ")" and depth == 0:
65
+ args.append("".join(current).strip())
66
+ return ([] if args == [""] else args), m.end()
67
+ if tok == "," and depth == 0:
68
+ args.append("".join(current).strip())
69
+ current = []
70
+ continue
71
+ depth += {"(": 1, ")": -1}.get(tok, 0)
72
+ current.append(tok)
73
+ raise MacroError(line, f"Macro {name}: missing ).")
74
+
75
+
76
+ def _expand(text: str, macros: dict[str, _Macro], line: int, disabled: frozenset[str] = frozenset()) -> str:
77
+ out: list[str] = []
78
+ i = 0
79
+ while m := _TOKEN_RE.match(text, i):
80
+ tok, i = m.group(), m.end()
81
+ key = tok.upper()
82
+ macro = macros.get(key)
83
+ if macro is None or key in disabled:
84
+ out.append(tok)
85
+ continue
86
+ body = macro.body
87
+ if macro.params is not None:
88
+ j = i
89
+ while j < len(text) and text[j] in " \t":
90
+ j += 1
91
+ if j >= len(text) or text[j] != "(":
92
+ out.append(tok)
93
+ continue
94
+ args, i = _call_args(text, j, line, tok)
95
+ if len(args) != len(macro.params):
96
+ raise MacroError(line, f"Macro {tok} takes {len(macro.params)} argument(s), got {len(args)}.")
97
+ values = dict(zip(macro.params, args, strict=True))
98
+ body = "".join(values.get(t.upper(), t) for t in _TOKEN_RE.findall(body))
99
+ body = _expand(body, macros, line, disabled | {key})
100
+ head = text[text.rfind("\n", 0, m.start()) + 1 : m.start()]
101
+ indent = head[: len(head) - len(head.lstrip(" \t"))]
102
+ out.append(body.replace("\n", "\n" + indent))
103
+ return "".join(out)
104
+
105
+
106
+ class Macros:
107
+ """The macros of one file, fed its lines in order (`expand` here, the loader for the editor)."""
108
+
109
+ def __init__(self):
110
+ self.table: dict[str, _Macro] = {}
111
+ self._open: tuple[str, list[str] | None, int, list[str]] | None = None # a #Macro being read
112
+
113
+ def feed(self, text: str, line: int) -> str | None:
114
+ """`text` with macros expanded, or None when it is part of a definition."""
115
+ if self._open is not None:
116
+ if _END_RE.match(text):
117
+ name, params, start, body = self._open
118
+ self._open = None
119
+ self._define(name, params, textwrap.dedent("\n".join(body)).strip("\n"), start)
120
+ else:
121
+ self._open[3].append(text)
122
+ return None
123
+ d = _DEFINE_RE.match(text)
124
+ if d is None:
125
+ return _expand(text, self.table, line)
126
+ kind, name, rest = d.group(1).upper(), d.group(2), d.group(4)
127
+ params = None if d.group(3) is None else [p.strip().upper() for p in d.group(3).split(",")]
128
+ if params == [""]:
129
+ params = []
130
+ if params is not None and (
131
+ not all(re.fullmatch(r"[A-Za-z_]\w*", p) for p in params) or len(set(params)) < len(params)
132
+ ):
133
+ raise MacroError(line, f"Macro {name}: invalid parameter list.")
134
+ if kind == "MACRO":
135
+ if params is None or _strip_comment(rest).strip():
136
+ raise MacroError(line, f"#Macro {name} needs a (parameter list) and its body on the following lines.")
137
+ self._open = (name, params, line, [])
138
+ else:
139
+ self._define(name, params, _strip_comment(rest).strip(), line)
140
+ return None
141
+
142
+ def finish(self) -> None:
143
+ if self._open is not None:
144
+ raise MacroError(self._open[2], f"#Macro {self._open[0]} without #EndMacro.")
145
+
146
+ def _define(self, name: str, params: list[str] | None, body: str, line: int) -> None:
147
+ if name.upper() in self.table:
148
+ raise MacroError(line, f"Macro {name} is already defined.")
149
+ self.table[name.upper()] = _Macro(params, body)
150
+
151
+
152
+ def expand(text: str) -> str:
153
+ """`text` with its `#Define`/`#Macro` definitions commented out and their uses expanded."""
154
+ newline = "\r\n" if "\r\n" in text else "\n"
155
+ macros = Macros()
156
+ out = []
157
+ for n, raw in enumerate(text.splitlines(), 1):
158
+ expanded = macros.feed(raw, n)
159
+ out.append("'" + raw if expanded is None else expanded.replace("\n", newline))
160
+ macros.finish()
161
+ return newline.join(out) + (newline if text.endswith(("\n", "\r")) else "")
@@ -3174,6 +3174,7 @@ def parse_file(
3174
3174
  pull_limit: tuple[int, ...] | None = None,
3175
3175
  source: SourceProvider | None = None,
3176
3176
  provenance: bool = True,
3177
+ macros: bool = False,
3177
3178
  ) -> tuple[S.Program, Diagnostics]:
3178
3179
  """`provenance=False` skips the pieces/line-map/origin data only the language server reads (the CLI
3179
3180
  path); everything the checker sees is unchanged. `profile` is normally left to the file's own extension (`profiles.for_file`, the historical
@@ -3191,6 +3192,7 @@ def parse_file(
3191
3192
  _CR3XX_INCLUDE_EXTS.get((profile or _profile_for(path.name)).name),
3192
3193
  source,
3193
3194
  provenance,
3195
+ macros,
3194
3196
  )
3195
3197
  pull_log = diags.pull_log
3196
3198
  parser = _Parser(profile or _profile_for(path.name), diags, provenance)
@@ -29,6 +29,7 @@ from pathlib import Path
29
29
  from typing import NamedTuple
30
30
 
31
31
  from .diagnostics import Diagnostics
32
+ from .macros import MacroError, Macros
32
33
  from .sources import DiskSource, SourceProvider
33
34
 
34
35
  _INCLUDE_RE = re.compile(r'^\s*Include\s*\(?\s*"([^"]*)"\s*\)?\s*$', re.IGNORECASE)
@@ -302,7 +303,9 @@ class _Loader:
302
303
  cr3xx_exts: tuple[str, ...] | None = None,
303
304
  source: SourceProvider | None = None,
304
305
  provenance: bool = True,
306
+ macros: bool = False,
305
307
  ):
308
+ self.macros = Macros() if macros else None # the entry file's macros (`macros.py`)
306
309
  self.provenance = provenance # False: skip `LogicalLine.pieces` (only the language server reads them)
307
310
  self.source = source or DiskSource()
308
311
  self.includes: dict[str, Path] = {} # include display name -> the path it resolved to
@@ -416,6 +419,22 @@ class _Loader:
416
419
  if text.strip() == "":
417
420
  i += 1
418
421
  continue
422
+ if self.macros is not None and occ == 0:
423
+ try:
424
+ expanded = self.macros.feed(text, start_line)
425
+ except MacroError as e:
426
+ self.diags.error(e.line, str(e), filename)
427
+ expanded = None
428
+ if expanded != text:
429
+ # an expansion is reported, and spans, where the macro is used; no `pieces`
430
+ for part in (expanded or "").split("\n"):
431
+ if part.strip():
432
+ phys = (start_line, i + 1)
433
+ out.append(
434
+ LogicalLine(part, last_line - shift, filename, section, phys=phys, occurrence=occ)
435
+ )
436
+ i += 1
437
+ continue
419
438
  pulls, bad = 0, None
420
439
  orig = text
421
440
  orig_pieces = pieces
@@ -570,13 +589,19 @@ def load_program(
570
589
  cr3xx_exts: tuple[str, ...] | None = None,
571
590
  source: SourceProvider | None = None,
572
591
  provenance: bool = True,
592
+ macros: bool = False,
573
593
  ) -> tuple[list[LogicalLine], Diagnostics]:
574
594
  """Load `path` and every file it `Include`s/`IncludeSection`s, as one logical-line stream."""
575
595
  path = Path(path)
576
596
  diags = Diagnostics()
577
- loader = _Loader(path.parent, diags, own_ext, pull_init, cr3xx_exts, source, provenance)
597
+ loader = _Loader(path.parent, diags, own_ext, pull_init, cr3xx_exts, source, provenance, macros)
578
598
  loader.pull_limit = pull_limit
579
599
  lines = loader.load_file(path, section=None, in_include=False)
600
+ try:
601
+ if loader.macros is not None:
602
+ loader.macros.finish()
603
+ except MacroError as e:
604
+ diags.error(e.line, str(e), path.name)
580
605
  diags.pull_log = loader.pull_log
581
606
  diags.pull_site = loader.site
582
607
  diags.include_paths = loader.includes
@@ -1,21 +1,21 @@
1
- # Test corpora
2
-
3
- | Directory | Contents | In git |
4
- |---|---|---|
5
- | `firnmelt/` | The FirnMelt station programs and libraries, a snapshot of `crbasic/` in [firnmelt-station](https://github.com/UtrechtUniversity/firnmelt-station) at monorepo commit `bd4c30c1` (2026-11-01). Tests pin results to these exact files, so refresh them only together with the expectations. | yes |
6
- | `fuzz/` | Saved fuzz reproducers seeded from `firnmelt/`, replayed by `tools/fuzz_replay.py`. | yes |
7
- | `chm/` | The 507 complete example programs in Campbell's CRBasic help. | no |
8
- | `../../examples/Logger Programs/` | The 66 GEUS station programs used as a CR1000X corpus. | no |
9
-
10
- The help examples and GEUS programs belong to others and are not redistributed
11
- ([docs/legal.md](../../docs/legal.md)). Tests that need them skip when they are absent. To run
12
- those tests locally:
13
-
14
- 1. Extract the CRBasic help (the `.chm` installed with CRBasic Editor) into `extracted_chm/` at the
15
- repository root, for example with `7z x CRBasic.chm -oextracted_chm`.
16
- 2. `uv run python tools/extract_chm_examples.py` writes `tests/corpus/chm/`.
17
- 3. Put the GEUS programs, if you have them, in `examples/Logger Programs/`.
18
- 4. For the differential tools, copy the Campbell compilers into `compiler/` (README, "How it was
19
- built").
20
-
21
- All of these paths are git-ignored.
1
+ # Test corpora
2
+
3
+ | Directory | Contents | In git |
4
+ |---|---|---|
5
+ | `station/` | A field station's CR6 programs and libraries (2026-11-01 snapshot). Tests pin results to these exact files, so refresh them only together with the expectations. | yes |
6
+ | `fuzz/` | Saved fuzz reproducers seeded from `station/`, replayed by `tools/fuzz_replay.py`. | yes |
7
+ | `chm/` | The 507 complete example programs in Campbell's CRBasic help. | no |
8
+ | `../../examples/Logger Programs/` | The 66 GEUS station programs used as a CR1000X corpus. | no |
9
+
10
+ The help examples and GEUS programs belong to others and are not redistributed
11
+ ([docs/legal.md](../../docs/legal.md)). Tests that need them skip when they are absent. To run
12
+ those tests locally:
13
+
14
+ 1. Extract the CRBasic help (the `.chm` installed with CRBasic Editor) into `extracted_chm/` at the
15
+ repository root, for example with `7z x CRBasic.chm -oextracted_chm`.
16
+ 2. `uv run python tools/extract_chm_examples.py` writes `tests/corpus/chm/`.
17
+ 3. Put the GEUS programs, if you have them, in `examples/Logger Programs/`.
18
+ 4. For the differential tools, copy the Campbell compilers into `compiler/` (README, "How it was
19
+ built").
20
+
21
+ All of these paths are git-ignored.
File without changes
File without changes