Skip to content

Commit ffca3d4

Browse files
feat(qbasic): support archaic line numbers and implicit GOTO
Enables parsing legacy 80s sources that use numeric line labels and the IF...THEN <line> shorthand. - statements.js: reusable labelTarget combinator accepting identifiers and signed integers for GOTO/GOSUB/RESUME/RESTORE - controlFlow.js: implicitGotoLine combinator + lazy singleLineClause enabling recursive nested ELSE IF blocks (Antoni Gual's ball.bas) - tests: AST recursion test + Truth Vector compliance tests Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com>
1 parent 21ec73c commit ffca3d4

5 files changed

Lines changed: 171 additions & 24 deletions

File tree

src/parser/qbasic/controlFlow.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// src/parser/controlFlow.js
22
import { choice, many, regex, optional, capture, sequenceObj, lazy, sequenceOf } from '../monad.js';
33
import { Tokens } from './tokens.js';
4-
import { identifier, keyword, optWs, ws, eos } from './lexers.js';
4+
import { identifier, keyword, optWs, ws, eos, signedNumberLiteral } from './lexers.js';
55
import { expression } from './expressions.js';
66
import {
77
labelDef, clsStmt, viewPrintStmt, playStmt, beepStmt, soundStmt, sleepStmt, printStmt, locateStmt, colorStmt,
@@ -197,15 +197,33 @@ const multiLineIfStmt = sequenceObj([
197197
elseBlock: obj.elseBlockOpt || []
198198
}));
199199

200+
/**
201+
* Parses an implicit GOTO when a line number is provided directly after THEN or ELSE.
202+
* Wraps the result in an array to perfectly mimic a standard statementList.
203+
*/
204+
const implicitGotoLine = signedNumberLiteral.map(node => [{ type: 'GOTO', label: String(node.value) }]);
205+
206+
/**
207+
* Defines the valid payload for a THEN or ELSE clause on a single line.
208+
* Evaluated lazily to allow mutual recursion with singleLineIfStmt.
209+
*/
210+
const singleLineClause = lazy(() => choice([
211+
implicitGotoLine,
212+
singleLineIfStmt.map(ast => [ast]), // Clean recursion for nested IFs (e.g., ELSE IF)
213+
statementList
214+
]));
215+
200216
/**
201217
* Single-line IF. Statements follow immediately on the same line.
218+
* Supports archaic implicit GOTO jumps (e.g., IF X THEN 10)
219+
* and recursive nested IFs (e.g., IF A THEN B ELSE IF C THEN D).
202220
*/
203221
const singleLineIfStmt = sequenceObj([
204222
keyword(Tokens.IF), ws, capture('condition', expression), ws, keyword(Tokens.THEN), optWs,
205-
capture('thenBlock', statementList),
223+
capture('thenBlock', singleLineClause),
206224
capture('elseBlockOpt', optional(sequenceObj([
207225
optWs, keyword(Tokens.ELSE), optWs,
208-
capture('elseBlock', statementList)
226+
capture('elseBlock', singleLineClause)
209227
]).map(obj => obj.elseBlock)))
210228
]).map(obj => ({
211229
type: 'IF',

src/parser/qbasic/controlFlow.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,30 @@ registerSuite('QBasic Control Flow (AST)', () => {
8787
assertEqual(success.result.elseBlock[0].values[0].value, 'Alive');
8888
});
8989

90+
test('singleLineIfStmt() should recursively parse nested ELSE IF blocks (ball.bas quirk)', () => {
91+
// The legendary line from Antoni Gual's ball.bas
92+
const code = `IF p >= 16 THEN 3 ELSE IF p = 0 THEN SCREEN 12 ELSE PALETTE p, b`;
93+
const success = ifStmt.run(code);
94+
95+
assertEqual(success.isError, false);
96+
assertEqual(success.result.type, 'IF');
97+
98+
// THEN block is an implicit GOTO 3
99+
assertEqual(success.result.thenBlock[0].type, 'GOTO');
100+
assertEqual(success.result.thenBlock[0].label, '3');
101+
102+
// ELSE block contains the nested IF
103+
const nestedIf = success.result.elseBlock[0];
104+
assertEqual(nestedIf.type, 'IF');
105+
106+
// Nested THEN is SCREEN 12
107+
assertEqual(nestedIf.thenBlock[0].type, 'SCREEN_STMT');
108+
assertEqual(nestedIf.thenBlock[0].mode.value, 12);
109+
110+
// Nested ELSE is PALETTE
111+
assertEqual(nestedIf.elseBlock[0].type, 'PALETTE');
112+
});
113+
90114
test('selectCaseStmt() should parse SELECT CASE while ignoring empty lines', () => {
91115
const code = `SELECT CASE level
92116

src/parser/qbasic/statements.js

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,35 @@ export const callStmt = sequenceObj([
163163
]).map(obj => obj.args || [])))
164164
]).map(obj => ({ type: 'CALL', callee: obj.callee, args: obj.argsOpt || [] }));
165165

166-
export const labelDef = sequenceObj([
167-
capture('name', identifier), optWs, str(':')
168-
]).map(obj => ({ type: 'LABEL', name: obj.name.value }));
166+
/**
167+
* Reusable parser for jump targets.
168+
* Supports modern text labels ("MainLoop") and archaic line numbers ("10").
169+
*/
170+
const labelTarget = choice([
171+
identifier.map(id => id.value),
172+
signedNumberLiteral.map(num => String(num.value))
173+
]);
174+
175+
/**
176+
* Parses either a modern text label (MyLabel:) or an archaic line number (10).
177+
* Archaic line numbers do NOT have a trailing colon.
178+
*/
179+
export const labelDef = choice([
180+
sequenceObj([
181+
capture('name', identifier), optWs, str(':')
182+
]).map(obj => ({ type: 'LABEL', name: obj.name.value })),
183+
184+
// Convert numeric line numbers to string labels for the GOTO resolver
185+
signedNumberLiteral.map(node => ({ type: 'LABEL', name: String(node.value) }))
186+
]);
169187

170188
export const gotoStmt = sequenceObj([
171-
keyword(Tokens.GOTO), ws, capture('label', identifier)
172-
]).map(obj => ({ type: 'GOTO', label: obj.label.value }));
189+
keyword(Tokens.GOTO), ws, capture('label', labelTarget)
190+
]).map(obj => ({ type: 'GOTO', label: obj.label }));
173191

174192
export const gosubStmt = sequenceObj([
175-
keyword(Tokens.GOSUB), ws, capture('label', identifier)
176-
]).map(obj => ({ type: 'GOSUB', label: obj.label.value }));
193+
keyword(Tokens.GOSUB), ws, capture('label', labelTarget)
194+
]).map(obj => ({ type: 'GOSUB', label: obj.label }));
177195

178196
export const returnStmt = keyword(Tokens.RETURN).map(() => ({ type: 'RETURN' }));
179197

@@ -255,7 +273,7 @@ export const readStmt = sequenceObj([
255273

256274
export const restoreStmt = sequenceObj([
257275
keyword(Tokens.RESTORE),
258-
capture('label', optional(sequenceOf([ws, identifier]).map(arr => arr[1].value)))
276+
capture('label', optional(sequenceOf([ws, labelTarget]).map(arr => arr[1])))
259277
]).map(obj => ({
260278
type: 'RESTORE',
261279
label: obj.label || null
@@ -425,21 +443,14 @@ export const onErrorStmt = sequenceObj([
425443
*/
426444
export const resumeStmt = sequenceObj([
427445
keyword(Tokens.RESUME),
428-
// The target is optional. It can be the keyword NEXT, or a label identifier.
446+
// Target can be the keyword NEXT, a text label, or a line number
429447
capture('targetOpt', optional(sequenceOf([
430-
ws, choice([keyword(Tokens.NEXT), identifier])
448+
ws, choice([keyword(Tokens.NEXT), labelTarget])
431449
]).map(arr => arr[1])))
432-
]).map(obj => {
433-
let target = null;
434-
if (obj.targetOpt) {
435-
// If it's an identifier (label), extract its value. Otherwise, it's the string 'NEXT'
436-
target = obj.targetOpt.type === 'IDENTIFIER' ? obj.targetOpt.value : 'NEXT';
437-
}
438-
return {
439-
type: 'RESUME',
440-
target: target // Will be null, 'NEXT', or a string (label name)
441-
};
442-
});
450+
]).map(obj => ({
451+
type: 'RESUME',
452+
target: obj.targetOpt || null // Already extracts 'NEXT', 'MainLabel', or '10'
453+
}));
443454

444455
/**
445456
* Parses the 'PALETTE' statement.

src/runtime/qbasic/compatibility.test.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,39 @@ registerSuite('Truth Vector: CORE: Control Flow & Jumps', () => {
549549
assertEqual(env.lookup('Runs'), 2, "Runs assertion failed");
550550
assertEqual(env.lookup('FalseRuns'), 0, "FalseRuns assertion failed");
551551
});
552+
553+
test('Line Numbers (Archaic Labels)', () => {
554+
const code = `
555+
LineVal = 0
556+
10 LineVal = 10
557+
GOTO 30
558+
20 LineVal = 20
559+
30 FinalLine = LineVal
560+
`;
561+
const env = executeToEnv(code);
562+
563+
assertEqual(env.lookup('FinalLine'), 10, "FinalLine assertion failed");
564+
});
565+
566+
test('Implicit GOTO (IF THEN Line Number)', () => {
567+
const code = `
568+
Flag = 1
569+
IF Flag = 1 THEN 100 ELSE 200
570+
100 ThenPath = 1
571+
GOTO 300
572+
200 ThenPath = 2
573+
300 Flag = 0
574+
IF Flag = 1 THEN 400 ELSE 500
575+
400 ElsePath = 1
576+
GOTO 600
577+
500 ElsePath = 2
578+
600 FinalEnd = 1
579+
`;
580+
const env = executeToEnv(code);
581+
582+
assertEqual(env.lookup('ThenPath'), 1, "ThenPath assertion failed");
583+
assertEqual(env.lookup('ElsePath'), 2, "ElsePath assertion failed");
584+
});
552585
});
553586

554587
registerSuite('Truth Vector: CORE: Memory, Types & Structures', () => {

tests/truth_vectors/qbasic/control_flow.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,67 @@
308308
{ "var": "FalseRuns", "val": 0, "type": "number", "message": "WHILE must completely skip execution if the initial condition is 0" }
309309
]
310310
}
311+
},
312+
{
313+
"name": "Line Numbers (Archaic Labels)",
314+
"syntax": "linenumber statement",
315+
"description": "In older BASIC dialects, every line required a number. QBasic supports these numbers as archaic labels for control flow statements like GOTO, GOSUB, and RESTORE. They do not require a trailing colon.",
316+
"example": {
317+
"code": [
318+
"10 PRINT \"START\"",
319+
"GOTO 30",
320+
"20 PRINT \"SKIPPED\"",
321+
"30 PRINT \"END\""
322+
],
323+
"output": "START\nEND"
324+
},
325+
"quirks_and_tests": {
326+
"description": "Validates that a raw integer at the start of a statement acts as a valid jump target without requiring a trailing colon.",
327+
"setup": [
328+
"LineVal = 0",
329+
"10 LineVal = 10",
330+
"GOTO 30",
331+
"20 LineVal = 20",
332+
"30 FinalLine = LineVal"
333+
],
334+
"assertions": [
335+
{ "var": "FinalLine", "val": 10, "type": "number", "message": "GOTO must successfully bypass line 20 and land on line 30 using numeric labels." }
336+
]
337+
}
338+
},
339+
{
340+
"name": "Implicit GOTO (IF THEN Line Number)",
341+
"syntax": "IF condition THEN linenumber [ELSE linenumber]",
342+
"description": "A legacy shorthand where providing a line number directly after THEN or ELSE implicitly executes a GOTO instruction.",
343+
"example": {
344+
"code": [
345+
"IF 1 = 1 THEN 100 ELSE 200",
346+
"100 PRINT \"TRUE\"",
347+
"END",
348+
"200 PRINT \"FALSE\""
349+
],
350+
"output": "TRUE"
351+
},
352+
"quirks_and_tests": {
353+
"description": "CRITICAL QUIRK: This implicit jump ONLY works with numeric line numbers, not text labels. It is heavily used in code golf and demoscene programs.",
354+
"setup": [
355+
"Flag = 1",
356+
"IF Flag = 1 THEN 100 ELSE 200",
357+
"100 ThenPath = 1",
358+
"GOTO 300",
359+
"200 ThenPath = 2",
360+
"300 Flag = 0",
361+
"IF Flag = 1 THEN 400 ELSE 500",
362+
"400 ElsePath = 1",
363+
"GOTO 600",
364+
"500 ElsePath = 2",
365+
"600 FinalEnd = 1"
366+
],
367+
"assertions": [
368+
{ "var": "ThenPath", "val": 1, "type": "number", "message": "Implicit THEN jump must successfully land on line 100." },
369+
{ "var": "ElsePath", "val": 2, "type": "number", "message": "Implicit ELSE jump must successfully land on line 500." }
370+
]
371+
}
311372
}
312373
]
313374
}

0 commit comments

Comments
 (0)