Document optional line numbers

Chapter 2 gets the rule and the refusal, chapter 4 gets the payoff for
LABEL, chapter 9 says DSAVE writes the numbers it handed out and to
RENUMBER first if you want gaps, chapter 10 shows a host loading numberless
source, chapter 13 gets the QuickBASIC-shaped divergence, and chapter 14's
source[] passage gets its second half.

Chapter 14 said "two prescans" and listed two; there were three before this
and there are four now, so it lists all four and says which of them reports
against the right line.

TODO.md section 6 records four things found on the way and deliberately not
fixed: set_label() filing into the active scope rather than the root, three
prescans reporting the wrong line number, duplicate written line numbers
still replacing silently, and renumber.c's file-scope scratch arrays.

examples/embed.c runs the same program twice, numbered and not, so the
example compiles the feature rather than describing it. Its header pointed
at ./build/examples/embed, which is not where the binary lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 16:43:14 -04:00
parent 6273be580a
commit 28ea99a638
8 changed files with 240 additions and 5 deletions

55
TODO.md
View File

@@ -1803,6 +1803,61 @@ the reference's semantics reproduced faithfully; the third is the port's own.
of `DOPEN` is genuinely more useful to a program than a flat 519 — so this is not
"translate everything", which is exactly why it needs deciding rather than doing.
### Found while making line numbers optional
26. **`akbasic_environment_set_label()` files into the active scope, not the root.**
`src/environment.c:246` walks up until `obj->runtime->environment == obj`, which is the
*currently executing* scope, despite the comment above it saying "Only the top-level
environment creates labels". So a `LABEL` reached inside a `GOSUB` or `FOR` body is filed
into that scope and dies when the body pops, while `akbasic_runtime_scan_labels()`
(`src/runtime.c:1286`) files the same label into the real root.
**The consequence is that a prescanned label and a re-filed one behave differently**, and
which one you get depends on whether control has passed through the `LABEL` statement
inside a scope. Nothing in either corpus does that, so nothing catches it. The fix is one
condition -- walk to `parent == NULL` -- plus a test that `GOSUB`s past a `LABEL` and then
branches to it from the top level. Wants checking against deviation 32 first, because
"`LABEL` still executes and re-files itself" is deliberate and the re-filing is the part
worth keeping.
27. **Three of the four prescans report against the wrong line.**
`akbasic_runtime_set_mode()`'s handler builds its BASIC error line from
`environment->lineno`, which after a load is wherever the loader stopped -- usually the
last line of the program. The label, `DATA` and `TYPE` scans therefore print `? 47 :` for
a fault on line 3, and work around it by putting `Line %d:` in the message text
(`src/structtype.c:342`), so the message names the number twice and the prefix is wrong.
`akbasic_runtime_check_targets()` does not have the problem because it points
`environment->lineno` at the line it is walking. That is the fix, and it belongs in the
wrapper rather than in four places: give each prescan a way to say which line it failed
on, or have each set the cursor as the target check does. Cosmetic, but it is the first
number a person reads when a program will not start.
28. **Two lines carrying the same written line number still silently keep the last.**
`akbasic_runtime_file_line()` refuses a collision that involves an *assigned* number, but
leaves numbered-onto-numbered alone, which is what a file with `10 PRINT "A"` twice has
always done. A duplicate in a hand-written file is a mistake rather than an intent, and
refusing it would be consistent with the other three cases.
Not changed with the rest because it is the one collision that can appear in an existing
program, so it carries corpus risk the others do not: 41 reference cases and 22 local ones
would all have to be shown clean first. `src/runtime.c`, in the `FAIL_NONZERO_RETURN` that
already names the slot.
29. **`akbasic_renumber()` keeps two 9999-entry `static` locals.**
`src/renumber.c` holds `map[]` and `rewritten[]` at file scope, and
`akbasic_runtime_check_targets()` now adds a `static` scratch buffer beside them. That is
against the no-file-scope-mutable-state rule in `MAINTENANCE.md`, and it means `RENUMBER`
and the target prescan are not reentrant across two `akbasic_Runtime`s in one process --
the exact thing that rule exists to guarantee.
They are `static` because they will not fit on a default stack, which is the same reason
`akbasic_Runtime` itself is too big for one. The honest fix is to hang them off the
runtime like every other pool, which costs another ~2.5MB inline per interpreter for
something used by one verb and one prescan. Recorded rather than done, because "make it
reentrant" and "do not grow the runtime by a third for a scratch buffer" are both right
and picking between them is a decision.
## 7. Filing gaps against `libakgl`
When phase 7 or phase 8 needs something `libakgl` does not have, **stop and file it** in

View File

@@ -12,8 +12,8 @@ READY
`READY` is printed whenever the interpreter is waiting for you, which is at startup
and after a program stops.
Anything you type **with a line number** is stored as part of a program. Anything you
type **without** one runs immediately:
At the prompt, anything you type **with a line number** is stored as part of a program,
and anything you type **without** one runs immediately:
```basic repl
PRINT 2 + 2
@@ -23,6 +23,9 @@ PRINT 2 + 2
4
```
That rule is the prompt's, and only the prompt's. **A program in a file does not need
line numbers at all** — see [Line numbers](#line-numbers) below.
## Your first program
```basic repl
@@ -69,6 +72,50 @@ range, and `RENUMBER` tidies the whole program up — it rewrites every `GOTO` a
`AUTO 10` turns on automatic numbering so you do not have to type them; `AUTO 0` turns
it off again.
### A program in a file does not need them
Numbers are what tells the *prompt* a program line from a statement to run now. A file
has no such problem, so a program loaded from one — or handed to the library as a string
by a host — may leave them out entirely:
```basic
PRINT "COUNTING:"
FOR I# = 1 TO 3
PRINT I# * I#
NEXT I#
PRINT "DONE"
```
```output
COUNTING:
1
4
9
DONE
```
Lines with no number are given the next one going, so they still run in the order you
wrote them, and a blank line costs nothing. You can mix the two: a numbered line sets
where the next unnumbered one goes.
**A program written this way branches by `LABEL`**, which is what makes it worth doing —
see [Chapter 4](04-control-flow.md#labels). Numbers you never wrote are not numbers you
can branch to, and saying so is the one thing the interpreter is strict about:
```basic
PRINT "A"
GOTO 2
```
```output
? 2 : PARSE ERROR Line 2: branch to line 2, which the program did not number. Branch by LABEL, or RENUMBER first
```
`LIST` shows the numbers it handed out, so you can still edit a loaded program at the
prompt, and `DSAVE` writes them into the file. They come out one apart, with no gaps to
insert into — `RENUMBER` before you save if you want the gaps back.
## Several statements on one line
Statements are separated by colons:

View File

@@ -183,6 +183,33 @@ ARRIVED
using for its own sake: a program written with labels is immune to `RENUMBER`, because
there is no number to rewrite.
Follow that one step further and the numbers stop earning their keep at all. A program
in a file can leave them out — see
[Chapter 2](02-getting-started.md#a-program-in-a-file-does-not-need-them) — and then
every branch it makes is by name:
```basic
GOSUB GREET
GOTO FINISH
PRINT "NOT REACHED"
LABEL GREET
PRINT "HELLO"
RETURN
LABEL FINISH
PRINT "DONE"
```
```output
HELLO
DONE
```
Nothing in that program can be broken by inserting a line into it, which is the whole
argument.
## ON
`ON` picks the *n*th target from a list, counting from one:

View File

@@ -20,6 +20,12 @@ OK
`VERIFY`. A program is saved as plain text with its line numbers, so you can edit it in
anything.
**`DSAVE` always writes line numbers, including ones you did not.** A program loaded
from a file that had none is given them
([Chapter 2](02-getting-started.md#a-program-in-a-file-does-not-need-them)), and those
are what gets written back. They come out one apart, so there is no room to insert
between them: `RENUMBER` before `DSAVE` if you want the gaps.
`VERIFY` compares what is in memory against the file and prints `OK`, or reports how
many lines differ.

View File

@@ -56,6 +56,28 @@ akerr_ErrorContext AKERR_NOIGNORE *run_script(const char *source)
`akbasic_runtime_run(rt, n)` runs at most `n` steps and returns. That bound is what
keeps a runaway script from owning your process.
**The source you hand `akbasic_runtime_load()` does not need line numbers.** A game
script is written in a text editor and branches by `LABEL`, so numbering its lines is
work with nothing on the other end of it:
```c wrap=hostloop
static const char *SCRIPT =
"PRINT \"SPAWNING\"\n"
"FOR I# = 1 TO HEALTH#\n"
" PRINT I#\n"
"NEXT I#\n"
"GOTO DONE\n"
"PRINT \"NOT REACHED\"\n"
"LABEL DONE\n"
"PRINT \"READY\"\n";
```
Lines that carry a number are filed under it and lines that do not are given the next
one going, so the two can be mixed and a numbered script still loads exactly as it did.
What a script may *not* do is `GOTO 100` when nothing wrote a line 100 — the interpreter
refuses that before the first line runs rather than branching somewhere plausible and
wrong.
`akbasic_runtime_settime()` is how the interpreter knows what time it is. It reads no
clock of its own, because it owns no loop. If you never call it, every duration expires
immediately — audible, but never a hang.

View File

@@ -92,6 +92,29 @@ A statement typed with no line number runs immediately, as it should. This was n
true until recently — the interpreter used to file everything but a handful of verbs as
program text.
## Line numbers
**A program in a file does not need them.** Every BASIC this dialect descends from
required a number on every line; here that requirement belongs to the prompt alone,
where the number is the only thing separating program text from a statement to run now.
A program loaded from a file, or handed to the library as a string, may leave them out,
and a line without one is given the next number going. The two mix: a numbered line sets
where the next unnumbered one goes.
This is QuickBASIC's idea rather than the C128's, and it is here for the same reason
QuickBASIC had it — a program that branches by `LABEL` never names a line number, so the
numbers are maintenance with nothing on the other end of it.
Two consequences worth knowing:
- **`GOTO <number>` must name a number the program wrote.** In a file with no line
numbers `GOTO 100` would otherwise find the hundredth line and branch there. It is
refused before the program runs. `GOTO <label>` is unaffected.
- **`LIST` and `DSAVE` show the numbers that were handed out**, one apart. `RENUMBER`
before `DSAVE` if you want gaps to insert into.
An unnumbered program is capped at 9998 lines, which is the cap that already applied.
## Errors
`ER` and `EL` are **`ER#` and `EL#`**, ordinary global variables. `ER#` holds this

View File

@@ -108,6 +108,19 @@ array slot, so `GOTO 500` is one assignment — `environment->nextline = 500`
search. Blank slots are skipped by the step loop. That is why line numbers are capped at
9998 and why a nine-line program still costs a 9999-entry array.
It is also why **a loaded line does not have to arrive with a number**. Execution needs a
slot, not a number somebody typed, so a line that comes without one is simply given the
slot after the last line filed. `akbasic_runtime_file_line()` is the one place that rule
lives, shared by `akbasic_runtime_load()`, `RUNSTREAM` and `DLOAD`; the step loop, the
branch verbs, the four prescans and `RENUMBER` never learn that anything changed, because
all of them already worked in slots. `akbasic_SourceLine::numbered` records which kind a
line was, and exactly one thing reads it — `akbasic_runtime_check_targets()`, which
refuses `GOTO 100` when line 100 is a number nobody wrote.
The prompt is deliberately outside all of this: `process_line_repl()` files a line only
when it *had* a number, because there the number is what separates program text from a
statement to run now.
## One step
`akbasic_runtime_step()` is the whole loop, unrolled to a single iteration. The
@@ -202,7 +215,7 @@ anything else sets it to `QUIT`, so `basic program.bas` exits. It is one field,
the whole difference between an interactive session and a script runner.
**`akbasic_runtime_set_mode()` is not just an assignment.** Entering `REPL` prints
`READY`. Entering `RUN` does two prescans of the whole program first:
`READY`. Entering `RUN` does four prescans of the whole program first:
- **Labels.** Every `LABEL` in the source is filed before anything executes, textually
rather than by parsing. Without it a label would exist only from the moment its `LABEL`
@@ -211,11 +224,23 @@ the whole difference between an interactive session and a script runner.
by label at all.
- **`DATA` items.** `READ` walks a cursor along a list built before the program runs, so a
`DATA` line above its `READ` is found.
- **`TYPE` declarations.** A declaration has to be in effect wherever control goes, so
`DIM P@ AS RECT` cannot run before `RECT` exists even if a branch skipped the lines that
declared it.
- **Branch targets.** The only one that reads nothing in and merely refuses: a numeric
`GOTO`, `GOSUB`, `RUN`, `RESTORE`, `TRAP` or `COLLISION` target naming a line the program
did not number. It shares `RENUMBER`'s walk in `src/renumber.c` rather than repeating it.
Every path into a run — `akbasic_runtime_start()`, the `RUN` verb, `CONT`, and the end of
a `RUNSTREAM` load — goes through that one function, which is why the prescans live there
and not in any of the four callers.
All four run inside one `ATTEMPT`, because **a prescan failure is the program's mistake
and not the host's** and has to leave as a BASIC error line rather than a raised context.
Three of them report against whichever line the loader stopped on and put the real one in
the message text; the branch-target scan points `environment->lineno` at the line it is
walking so the `? N :` prefix is right. `TODO.md` §5 item 64 records the difference.
The REPL's split between *file it* and *run it now* is one boolean: the scanner sets
`hadlinenumber` when the line it just read began with a number. A line typed with a
number is program text; a line typed without one is direct mode and runs immediately.

View File

@@ -4,7 +4,7 @@
*
* This is the code in README.md's "Embedding the interpreter" section, kept here
* so it is compiled by every build rather than rotting in a document. Build it
* with -DAKBASIC_BUILD_EXAMPLES=ON and run `./build/examples/embed`.
* with -DAKBASIC_BUILD_EXAMPLES=ON and run `./build/akbasic_example_embed`.
*/
#include <stdio.h>
@@ -114,6 +114,24 @@ static const char *PROGRAM =
"40 NEXT I#\n"
"50 PRINT \"DONE\"\n";
/*
* The same thing with no line numbers, which is what a game script actually
* looks like: written in a text editor, branching by LABEL, with nothing to
* renumber. A loaded line that arrives without a number is given the slot after
* the last one filed, so the two spellings load and run identically. Only the
* prompt still needs numbers, because there a number is the one thing separating
* program text from a statement to run now.
*/
static const char *UNNUMBERED_PROGRAM =
"PRINT \"COUNTING:\"\n"
"FOR I# = 1 TO 5\n"
" PRINT I# * I#\n"
"NEXT I#\n"
"GOTO DONE\n"
"PRINT \"NOT REACHED\"\n"
"LABEL DONE\n"
"PRINT \"DONE\"\n";
/*
* One frame's worth of script. The interpreter never surrenders control: it runs
* at most `budget` source lines and returns, so a host game keeps its frame rate
@@ -173,6 +191,18 @@ int main(void)
* is what the reference does too.
*/
CATCH(errctx, game_loop(AKBASIC_MAX_SOURCE_LINES, 8, &frames));
printf("--- run to completion ---\n%s", CONSOLE.buffer);
/*
* Then the same program again with no line numbers on it. A fresh runtime
* rather than a second load into this one, because load() adds to whatever
* is already filed rather than replacing it.
*/
CATCH(errctx, console_sink_init());
CATCH(errctx, akbasic_runtime_init(&SCRIPT, &CONSOLE_SINK));
CATCH(errctx, akbasic_runtime_load(&SCRIPT, UNNUMBERED_PROGRAM));
CATCH(errctx, akbasic_runtime_start(&SCRIPT, AKBASIC_MODE_RUN));
CATCH(errctx, game_loop(AKBASIC_MAX_SOURCE_LINES, 8, &frames));
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
@@ -180,6 +210,6 @@ int main(void)
rc = 1;
} FINISH_NORETURN(errctx);
printf("--- run to completion ---\n%s", CONSOLE.buffer);
printf("--- the same program with no line numbers ---\n%s", CONSOLE.buffer);
return rc;
}