LBLBASIC is a small preprocessor for classic line-numbered BASIC.
It lets you write BASIC using symbolic labels and a few structured-control extensions, then converts that source into ordinary numbered BASIC suitable for interpreters such as Microsoft MBASIC under CP/M.
LBLBASIC source files conventionally use the .bal extension: BASIC with labels.
The project contains four main files:
lblbasic.py— the reference implementation used to develop and test language changes. It uses only the Python standard library.lblbasic.awk— the original reference implementation. It is retained as a compact, portable implementation of the language as it stood when the reference was ported to Python.lblbasic.bal— an implementation of LBLBASIC written in BAL itself.lblbas.bas— the pre-converted MBASIC version oflblbasic.bal. The shorter filename is intentional for CP/M filesystems.
The Python and MBASIC versions are intended to implement the same BAL language. The test suite also checks the Python port against awk for the features they share.
The implementations intentionally have different feature boundaries:
| Capability | Python | BAL/MBASIC | Legacy awk |
|---|---|---|---|
| Labels and symbolic numeric substitution | Yes | Yes | Yes |
| Structured IF, WHILE, DO, EXIT, and CONTINUE | Yes | Yes | Yes |
| OFF/ON suppression and preserved blank lines | Yes | Yes | Yes |
| Configurable source characters and line-number limits | Yes | Yes | Yes |
| INCLUDE source files | Yes | Yes | No |
| CONST numeric symbols | Yes | Yes | No |
| OPT/ENDOPT, optional NOT, and ERROR | Yes | Yes | No |
| Numeric STACK/PUSH/POP | Yes | Yes | No |
| Trim, newline, and label-only comment CLI options | Yes | No | No |
Despite that difference, lblbasic.bal deliberately uses only the common
legacy subset in its own executable source. The awk translator can therefore
still generate the native MBASIC implementation.
Given a BAL source file:
':start
print "Hello"
goto @done
':unused
print "You should not see this"
':done
endrun:
python3 lblbasic.py program.bal > program.basThe output will be ordinary numbered BASIC, for example:
10 print "Hello"
20 goto 40
30 print "You should not see this"
40 endFor CP/M/MBASIC, convert generated files to DOS/CP/M line endings before transferring them if necessary:
unix2dos program.basThe same applies to .bal source files that will be read directly by the MBASIC implementation.
The included sample/hilo.bal game demonstrates structured
conditionals and loops, symbolic labels, compile-time options, and MBASIC random
number generation. On the left is part of the readable BAL source; on the right
is the translated program running under MBASIC on a CP/M system.
| BAL source | Running under MBASIC |
|---|---|
![]() |
![]() |
BAL is intentionally a small extension of ordinary BASIC.
The preprocessor does not parse or evaluate BASIC expressions. Expressions remain BASIC text and are evaluated later by the target BASIC interpreter.
The main additions are:
- symbolic labels
- symbolic line-number references
- structured
IF WHILEloopsDOloopsEXITandCONTINUE- source suppression with
OFF/ON - textual source inclusion
- named numeric constants
- optional compile-time source blocks
- a numeric save/restore stack
Everything else is passed through as BASIC.
A label definition is written as an apostrophe followed by the label-definition character, which defaults to ::
':startA reference uses the symbolic-reference character, which defaults to @:
goto @start
gosub @workerLabels are case-insensitive:
':Finished
goto @finishedis valid.
Leading and trailing whitespace in a label definition is ignored.
Labels may contain letters, digits, and underscores, but the first character must be a letter or underscore.
Examples:
':main
':read_record
':retry2
':_internalA symbolic reference is not limited to GOTO or GOSUB.
Outside quoted strings and comments, a symbolic reference is simply replaced by the BASIC line number assigned to that label.
For example:
goto @foo
gosub @foo
if x then @foo
on n goto @one,@two,@three
print 10 + @fooare all legal BAL syntax.
The last form may not be especially useful, but BAL deliberately does not try to decide where a line number is semantically appropriate.
References inside quoted strings and comments are not replaced:
print "goto @foo"
' this mentions @foo but does not reference itUndefined labels are left unchanged and produce a warning.
BAL label definitions are also comment-like source lines:
':fooWhether label lines and comments appear in generated output depends on the comments / COMMENTS setting.
With comments disabled, label-definition lines do not consume generated BASIC line numbers unless targetrem / TARGETREM requires the label line itself to be the branch target.
The Python translator can preserve label-definition comments independently
with -L/--keep-label-comments. The symbolic label then refers to the
following generated line rather than its own comment line.
Labels in the reserved __LBLB_ namespace are generated implementation
details. Their definitions are never emitted and never become comment targets,
regardless of comment or target-comment settings.
Blank and whitespace-only source lines are discarded by default. The Python
translator's -k/--keep-blanks option preserves each one as a bare,
unnumbered output line. Preserved blanks do not consume generated BASIC
line numbers. Legacy awk supports the equivalent -v keepblanks=1 setting;
BAL/MBASIC uses KEEPBLANKS.
Python and BAL/MBASIC support named signed-decimal integer constants:
'CONST! UMASK=125
'CONST! OFFSET=-20
X=X+@UMASK
Y=Y+@OFFSETCONST! uses the configured structured suffix and emits no BASIC line. The
name follows the same identifier rules as a symbolic label, while the value is
restricted to an optional + or - followed by decimal digits. Expressions,
fractions, exponent notation, type suffixes, and trailing comments are not
accepted.
Constants and labels use separate internal tables but share the configured
symbolic-reference character (@ by default). A reference may precede its
constant definition. Names are case-insensitive, and a name may not be defined
more than once or used as both a label and a constant. Constant directives in
an OFF! region are ignored; definitions in included files share the parent
translation's constant namespace. References are not expanded inside strings
or comments.
The legacy awk implementation remains at its pre-constant feature set.
Python and BAL/MBASIC can include or suppress source according to a literal or an earlier constant definition:
'CONST! DEBUG=1
'OPT! @DEBUG
PRINT "debugging is enabled"
'ENDOPT!
'OPT! NOT @DEBUG
PRINT "included only when debugging is disabled"
'ENDOPT!
'OPT! 0
PRINT "this source is omitted"
'ENDOPT!Zero suppresses the block; any nonzero signed decimal integer includes it.
The operand must be either one integer literal or one constant reference,
optionally preceded by case-insensitive NOT to invert the result. An
OPT! constant must have been defined earlier because optional-source state is
decided while the source is being read; ordinary constant substitutions may
still use forward references.
OPT! blocks may nest. Within an already suppressed OPT! or OFF! region,
nested option operands are not evaluated, although OPT! and ENDOPT! are
still balanced. Directives and suppressed source emit no BASIC lines. Both
keywords use the configured structured suffix. Legacy awk does not implement
optional blocks.
An active optional configuration can deliberately stop translation with a source-defined diagnostic:
'OPT! NOT @IGUESS
'ERROR! You forgot to enable IGUESS
'ENDOPT!ERROR! treats the trimmed remainder of its line as plain message text. It
performs no symbolic substitutions, emits no BASIC line, and makes translation
fail without partial output. An empty message is an error. Like other
preprocessor directives, it is ignored inside suppressed OPT! and OFF!
regions and uses the configured structured suffix.
The Python reference supports textual source inclusion:
'INCLUDE! common.balThe directive must occupy its physical source line apart from leading or trailing whitespace. Its filename is the trimmed remainder of the line; quotation marks and trailing comments have no special syntax. The directive is case-insensitive, uses the configured structured character, and is not emitted even when comments are preserved.
An included file behaves as though its physical lines replaced the directive.
Labels, structured blocks, source-suppression state, and generated line
numbering are shared across file boundaries. Includes inside an active OFF!
region are ignored without attempting to open the file.
In Python, relative filenames are resolved from the directory containing the including file. An include read from standard input is initially relative to the current working directory. Under MBASIC, filenames are passed directly to CP/M, so an unqualified filename is relative to the active drive and user area.
Nested includes are limited to 20 levels, active recursive cycles are errors, and including the same file again after it has returned is allowed. The BAL/MBASIC implementation retains bounded memory by closing a parent file while reading its child, then reopening the parent and skipping back to its saved physical line. The legacy awk implementation remains at its pre-include feature set.
The Python and BAL/MBASIC implementations provide a small numeric stack for preserving scalar variables around subroutine calls:
STACK! 200
PUSH! X%,Y!,Z#
GOSUB @worker
POP! X%,Y!,Z#STACK! accepts a positive integer literal from 1 through 32767. An
apostrophe comment may follow the size after whitespace, as in
STACK! 64 ' save up to 64 values. It must appear exactly once and before
executable source, although labels and comments may precede it. PUSH! and
POP! accept comma-separated scalar numeric
variables whose names begin with a letter and contain letters or digits, with
an optional %, !, or # suffix. Strings, arrays, expressions, empty
operands, and the reserved LBLBSTACK and LBLBSP names are rejected.
Whitespace around variables and commas is allowed. A trailing apostrophe
comment may follow the complete variable list on PUSH! and POP!, just as it
may on STACK!. An apostrophe comment without any preceding operands is still
an empty-list error.
Values are restored positionally: after PUSH! X,Y,Z, POP! A,B,C assigns
the saved values of X, Y, and Z to A, B, and C respectively. Stack state is
global and crosses include and structured-block boundaries. If you examine
the generated code, you'll see that POP! works "backwards" -- so Z is the
first item on the stack, Y is the 2nd an X is the 3rd. Do not reverse the stack
order manually. That is the correct way to restore PUSH! X,Y,Z is NOT POP! Z,Y,X but POP! X,Y,Z.
The generated array is LBLBSTACK# by default. -y/--stack-type selects
int, single, or double storage, generating %, !, or # respectively.
Conversion is left to BASIC; for example, choosing an integer stack knowingly
allows fractional values to be rounded or rejected by the target.
Overflow and underflow checks are generated by default. -b or
--no-stack-checks omits them; -B or --stack-checks explicitly enables
them. PUSH and POP operations are emitted as colon-separated BASIC lines and
are rejected if expansion would exceed 255 characters.
The BAL/MBASIC implementation provides equivalent configuration variables near
the beginning of lblbasic.bal and lblbas.bas:
STACKTYPE=3 ' 1=integer, 2=single, 3=double
STACKCHECK=-1 ' nonzero enables overflow and underflow checksThese defaults select double storage with checks enabled. Set STACKCHECK=0
to omit the generated checks.
BAL provides a block form of IF.
if x=y then!
print "equal"
endif!An optional ELSE! is supported:
if x=y then!
print "equal"
else!
print "not equal"
endif!The THEN! must be the final non-whitespace text on its physical source line.
ELSE! and ENDIF! must appear alone on their source lines apart from whitespace.
The implementation uses generated internal labels and branches rather than trying to negate the BASIC expression. Conceptually:
if x=y then!
print "yes"
else!
print "no"
endif!is lowered to logic equivalent to:
IF x=y THEN <true-block>
GOTO <false-block>
<true-block>
PRINT "yes"
GOTO <end-block>
<false-block>
PRINT "no"
<end-block>This keeps BAL from needing to understand BASIC operator precedence or expression semantics.
Structured IF blocks may be nested.
BAL supports top-tested loops:
while! x<10
print x
x=x+1
wend!The expression following WHILE! is passed unchanged to BASIC.
The loop continues while the expression is true.
WEND! must appear alone on its source line apart from whitespace.
Loops may contain nested IF, WHILE, or DO blocks.
An unconditional loop is:
do!
print x
x=x+1
loop!BAL also supports bottom-tested forms.
do!
print x
x=x+1
loop! while x<10The loop repeats while the expression is true.
do!
print x
x=x+1
loop! until x>=10The loop repeats until the expression is true.
As with the other structured constructs, the expression itself is not parsed by LBLBASIC.
EXIT! exits the innermost enclosing WHILE! or DO! loop:
while! x<100
x=x+1
if x=50 then!
exit!
endif!
wend!EXIT! may appear inside nested IF blocks. LBLBASIC searches outward for the innermost active loop.
Using EXIT! when there is no enclosing WHILE or DO block is an error.
CONTINUE! skips to the next iteration of the innermost enclosing loop.
For a WHILE loop:
while! x<100
x=x+1
if x=50 then!
continue!
endif!
print x
wend!CONTINUE! returns to the WHILE condition test.
For a DO loop:
do!
x=x+1
if x=5 then!
continue!
endif!
print x
loop! while x<10CONTINUE! jumps to the loop-bottom test. This is important for LOOP! WHILE and LOOP! UNTIL: the bottom condition is still evaluated.
Using CONTINUE! outside a WHILE or DO block is an error.
BAL provides a preprocessing facility similar in spirit to:
#if 0
...
#endifin C.
Use:
'OFF!to stop emitting source, and:
'ON!to resume.
Example:
print "before"
'OFF!
print "this code does not exist in the generated program"
goto @missing_label
':temporary
'ON!
print "after"Everything between OFF! and ON! is ignored completely:
- no BASIC output is generated
- no BASIC line numbers are allocated
- labels are not defined
- symbolic references are not checked
- structured blocks inside the disabled region do not affect the active block stack
OFF! / ON! blocks may be nested.
An unmatched ON! or end-of-file with an unterminated OFF! block is an error.
Structured constructs use a block stack and may be nested.
For example:
while! x<20
if x=5 then!
x=x+1
continue!
endif!
do!
y=y+1
if y=10 then!
exit!
endif!
loop! while y<20
x=x+1
wend!Closing constructs must match the active block.
For example, a WEND! encountered while the top-level active block is an IF produces an error rather than silently generating incorrect code.
The three BAL extension punctuation characters are configurable.
The defaults are:
@ symbolic reference prefix
! structured-keyword suffix
: label-definition marker
Thus the normal syntax is:
':foo
goto @foo
if x then!
endif!The Python implementation accepts long options:
python3 lblbasic.py \
--labelchar='%' \
--structchar='?' \
--defchar=';' \
program.balOther Python options include:
-c,--keep-comments, or--commentspreserves comments;-Cor--ignore-commentsexplicitly discards them. Comments are ignored by default.-tor--target-commentspreserves label-definition comments and makes labels refer to those comment lines.--targetremremains as a compatible alias.-Tor--no-target-commentsexplicitly selects the default.-L,--keep-label-comments, or--keep-labelspreserves label-definition comments without making them their own branch targets. Ordinary comments remain discarded unless-cis also used.-n,--dos-newlines, or--cpm-newlinesemits CRLF line endings ready for DOS or CP/M transfer. The default is Unix LF;--unix-newlinesstates that choice explicitly.-s/--startand-i/--incrementconfigure generated line numbering.-l/--labelchar,-S/--structchar, and-d/--defcharconfigure BAL's three extension characters.-mor--trimremoves leading and trailing spaces and tabs from every physical source line. It does not alter whitespace inside strings or in the interior of a line.-kor--keep-blankspreserves blank and whitespace-only physical lines as bare, unnumbered blank output lines. They do not consume generated BASIC line numbers.--trimalso normalizes whitespace-only lines to bare blanks.
For example, to generate a CP/M-ready file while discarding ordinary source comments:
python3 lblbasic.py -C -n program.bal > program.basThe awk implementation can override them with -v:
awk \
-v labelchar='%' \
-v structchar='?' \
-v defchar=';' \
-f lblbasic.awk program.balThe equivalent source syntax would then be:
';foo
goto %foo
if x then?
endif?The corresponding variables near the beginning of lblbasic.bal / lblbas.bas are:
LC$="@" ' symbolic-reference prefix
SC$="!" ' structured-keyword suffix
DC$=":" ' label-definition markerChange these before running the program if different syntax is desired.
The BASIC statement separator : is not configurable. It is part of BASIC itself. DC$ controls only the marker used in label definitions.
By default, generated BASIC begins at line 10 and increments by 10:
10
20
30
...
The defaults are configurable.
In the legacy awk implementation:
line = 10
incr = 10In the MBASIC implementation:
STARTLN=10
INCR=10Generated line numbers are limited to MBASIC's range of 0 through 65529. Python rejects a starting line outside that range and a non-positive increment. All three implementations stop before emitting output if an allocated line would exceed 65529; structured expansion and preserved comments count toward the limit, while preserved blank lines remain unnumbered.
Removing comments, labels, blank lines, or disabled code can cause source line numbers and generated BASIC line numbers to differ substantially.
Warnings therefore report both when useful.
Example:
Warning: duplicate label FOO at source line 17 (120);
first defined at source line 4 (30)
Labels are case-insensitive.
Defining the same label more than once produces a warning, including source and generated BASIC locations.
Example:
':foo
...
':FOOUndefined symbolic references are left unchanged in generated output and produce a warning.
Example:
goto @misspelledThis behavior is intentional. Leaving the unresolved token visible makes mistakes easy to spot rather than silently substituting an arbitrary value.
Repeated references to the same undefined label normally produce only one warning.
BAL recognizes both traditional BASIC REM comments and apostrophe comments.
Examples:
REM full-line comment
' full-line apostrophe comment
print x ' trailing commentQuoted apostrophes are not comments:
print "don't treat this as a comment"Likewise, label-like text inside a comment is not resolved:
' goto @foo
REM @foo is not a reference hereA REM following a BASIC statement separator is treated as comment-to-end-of-line:
goto @done : REM stop hereQuoted strings are preserved.
BAL understands doubled quotes inside BASIC strings:
print "He said ""hello"""Symbolic references, apostrophes, colons, and structured-looking text inside strings are not interpreted as BAL syntax.
For example:
print "goto @foo"
print "THEN!"
print "colon: inside string"are passed through as strings.
LBLBASIC diagnoses structural problems such as:
ELSE!without a matching IF- more than one
ELSE!in the same IF ENDIF!without a matching IFWEND!without a matching WHILELOOP!without a matching DO- mismatched block nesting
EXIT!outside a loopCONTINUE!outside a loop- unmatched
'ON! - unterminated
'OFF! - unmatched or unterminated
'OPT!/'ENDOPT! - invalid, duplicate, or conflicting
'CONST!definitions - a reached
'ERROR!directive - invalid stack declarations or operands
- generated line numbers beyond MBASIC's limit
- unterminated structured blocks at end-of-file
For structural errors, translation should be considered unsuccessful.
This is the current reference implementation and executable language specification. Use it with Python 3; it has no third-party dependencies:
python3 lblbasic.py input.bal > output.basNew language behavior is prototyped and regression-tested here before being ported to the BAL/MBASIC implementation.
This was the original reference implementation and the environment in which the BAL language was first developed.
Use it on a Unix-like system with awk:
awk -f lblbasic.awk input.bal > output.basIt can also bootstrap the checked-in native implementation:
awk -f lblbasic.awk lblbasic.bal > lblbas.baslblbasic.bal intentionally avoids using INCLUDE, CONST, OPT/ENDOPT, ERROR,
and STACK/PUSH/POP as directives in its own source, even though the resulting
native translator implements them. This keeps the legacy bootstrap viable.
It remains useful on small Unix-like installations and as a parity oracle for the original feature set. It does not implement INCLUDE, CONST, OPT/ENDOPT, ERROR, or STACK/PUSH/POP. New features are not required to be added to it now that the evolving reference implementation has moved to Python.
This is LBLBASIC implemented in BAL itself.
It demonstrates that BAL is usable for writing a nontrivial BASIC program while avoiding raw numeric branch targets.
To convert it to ordinary MBASIC source:
python3 lblbasic.py lblbasic.bal > lblbas.basBecause lblbasic.bal uses BAL features to implement BAL, lblbasic.py serves
as the current stage-0 bootstrap implementation. The awk program can also
bootstrap this version of the BAL source.
This is the already-converted MBASIC program.
The eight-character basename is intentional for CP/M compatibility.
Run it under Microsoft MBASIC and supply an input .BAL filename and output .BAS filename when prompted.
For example, conceptually:
A>MBASIC LBLBAS
LBLBASIC/MBASIC
Input .BAL file? TEST.BAL
Output .BAS file? TEST.BAS
Exact invocation may vary with the MBASIC version and CP/M environment.
Remember that CP/M tools commonly expect CRLF line endings. If files were created on Unix/Linux, convert them before transferring:
unix2dos lblbasic.bal
unix2dos program.baland likewise for generated .BAS files when needed.
LBLBASIC is intentionally developed in stages:
lblbasic.py
|
| processes
v
lblbasic.bal
|
v
lblbas.bas
|
| runs under MBASIC
v
other .bal programs
This lets the language be developed in a straightforward modern reference implementation while keeping an implementation that runs on the kind of classic BASIC environment the tool is intended to support.
A useful regression/bootstrap test is to use lblbas.bas under MBASIC to
process lblbasic.bal itself and compare that result with the lblbas.bas
produced by the Python version.
LBLBASIC is not intended to become a full BASIC compiler.
In particular, it deliberately avoids parsing BASIC expressions or implementing operator precedence.
For example, in:
if A=3 AND (B=7 OR C$="X") then!LBLBASIC treats:
A=3 AND (B=7 OR C$="X")
as opaque BASIC text.
It generates the necessary control-flow scaffolding and leaves expression parsing and evaluation to BASIC.
This keeps the preprocessor small enough to implement in MBASIC itself while still providing a substantial improvement over manually maintaining numeric line targets.
':main
x=0
while! x<10
x=x+1
if x=3 then!
continue!
endif!
if x=8 then!
exit!
endif!
print x
wend!
do!
x=x-1
if x=2 then!
print "two"
else!
print x
endif!
loop! while x>0
goto @done
'OFF!
':oldcode
print "this entire section is disabled"
goto @missing
'ON!
':done
print "done"
endRun:
python3 lblbasic.py example.bal > example.basThe resulting .bas contains only ordinary numbered BASIC and can be transferred to the target system.
The recommended conventions are:
.bal— LBLBASIC source.bas— generated ordinary BASIC
The name BAL simply means BASIC with labels.

