Files
SplitBit-Emulator/Programs/CosmOS/Source/script.asm
T
AnachronautandClaude Opus 5 2466d79d9c Let the shell run a file of commands
'do <file>' runs the lines in a file as though they had been typed. The
only thing a script changes is where the next line comes from: everything
below shellReadLine - splitting the line, matching it, loading a program -
cannot tell the difference and does not have to.

What makes a file a script is '#!' on the front of it, not its name and not
a flag in its entry. The rule this filesystem keeps is that an entry holds
only what the content cannot say about itself, and a script can say what it
is; the loader already refuses anything that is not SBEX, so the two kinds
of runnable file turn each other away without either knowing about the
other. It is also the deferred half of the file-typing design, which said
to wait for a second kind of runnable thing before building any of it. This
is that second kind.

'#' is a directive and ';' is a comment, as in SplitBit assembly - one rule
across the machine rather than two dialects. Not Unix's convention: there
'#!' really is a comment that only the kernel reads, while here the shell
requires it and refuses the file without it, so calling it a comment would
be a lie about what it does.

A script stops at the first line that does not work, which is what the
LineFailed groundwork was for. Comments and blank lines are dropped by the
reader rather than by the dispatch, so they are not echoed either. A script
running out hands back to the console rather than ending the shell, because
running out of file and running out of typing are not the same thing. The
interactive assembler reads through the same path, so a script can contain
a block of assembly.

Three things this cost that were not obvious:

  - RET puts A and B back, so a routine cannot answer in them. scriptByte
    returning the character in A assembled, ran, and handed the caller its
    own A back every time. It answers in memory now.
  - A last line with no newline is still a line. Text files do not reliably
    end with one and an editor eating it is a bad way to find out a command
    did not run.
  - Not LastStatus. See the commit before this one.

Six checks in three tests, two of which are about byte positions rather
than behaviour - a command lying across the boundary between two blocks,
and that missing newline - so their fixtures are generated rather than
committed, where an editor cannot helpfully repair them.

Nesting is not in yet: a script cannot run a script. That wants a stack of
positions rather than the one the reader keeps.

Also derives native.sh's self-hosting source list from cosmos.asm's own
#Include lines. It was a hand written list and went stale the moment
script.asm existed - the fourth time a list beside a thing has drifted from
the thing - so it now asks the thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
2026-08-30 15:50:26 -04:00

353 lines
7.5 KiB
NASM

; Reading shell lines out of a file.
;
; DP0 = the file's name
; CALL scriptOpen Q = 0 and a script is running, or Q says what was wrong:
; 1 there is no such file
; 2 it is not a script - no #! on the front
; DP0 = a buffer, B = how much room
; CALL scriptLine Q = 0 and there is a line in the buffer, or nonzero at the end
;
; Written by Anachronaut
;
; ---- Why the shell reads files and not the other way round ----
;
; fileStream.asm does almost this and is deliberately not used. It is application machinery:
; More, Type and Wander each include it and each get their own copy in their own Data
; Segment. The shell reading a script through it would be a fourth copy, and the shell is the
; one place where that copy has to survive running a program - which is exactly the thing
; those programs are. Here the state belongs to the system and nothing a program does can
; reach it.
;
; ---- A block, and a nought on the end of it ----
;
; The reader walks a Data Pointer along the block and stops at a nought. A block is 256 bytes
; and a count of them does not fit in a byte, so every other way of knowing where the block
; ends costs sixteen bit arithmetic on every character. Writing a nought after the last byte
; costs one store per block and turns the whole question into "is this byte zero".
;
; The buffer is 257 bytes for that reason: a full block leaves the nought at the end of it.
; A script cannot contain a nought, which is not a restriction anybody will notice - it is
; text, and the #! on the front is what stops a program being read as one.
#Program
; ---- Opening ----
;
; The name is COPIED rather than remembered by address. osFileBlock is given the name again
; for every block, and the caller's copy is CommandLine, which the next line typed will
; overwrite. fileStream remembers an address and says in its own comment that the address
; must stay valid; here it cannot, because the thing that reads the next line is the reason
; the name is needed.
scriptOpen:
SETD.1 ScriptName
INIB 0d63
CALL copyText
SETD.0 ScriptName
SWI osFileInfo
BRQ scriptOpenThere
INIA 0x01
BRI scriptOpenFailed
scriptOpenThere:
; DP3 is how many blocks. A file of none is not a script.
PSHD.3
POPB
POPA
SETD.1 ScriptBlocks
STA.1
INCD.1
STB.1
OR
BRQ scriptOpenNotOne
SETD.1 ScriptIndex
RSTA
STA.1
INCD.1
STA.1
; The first block, so that the #! can be looked at before anything is promised.
CALL scriptFill
BNQ scriptOpenNotOne
; ---- What makes a file a script ----
;
; Two bytes, and the rest of the line ignored. A directive rather than a comment, the way
; #Program is in assembly: the shell reads it and refuses the file without it, so calling
; it a comment would be a lie about what it does. What follows the #! is where the name of
; an interpreter goes when there is a second one; today there is one and it is this shell.
SETD.1 ScriptBlock
LDA.1
INIB 0d35 ; '#'
CCF
SUB
BNQ scriptOpenNotOne
INCD.1
LDA.1
INIB 0d33 ; '!'
CCF
SUB
BNQ scriptOpenNotOne
; Past the shebang line, wherever it ends.
CALL scriptSkipLine
INIA 0x01
SETD.1 ScriptRunning
STA.1
RSTA
BRI scriptOpenFailed ; A is nought, which is the answer for "it opened".
scriptOpenNotOne:
INIA 0x02
scriptOpenFailed:
; Q is the answer, and A holds it. Adding nought is how a register becomes Q.
RSTB
CCF
ADD
RET
; ---- One line ----
;
; Comments and blank lines never reach the shell. The reader drops them, so the echo does not
; print them and the dispatch never sees a line it would have to know to ignore.
scriptLine:
SETD.1 ScriptRoom
STB.1
SETD.1 ScriptInto
STD.0.1
scriptLineAgain:
SETD.1 ScriptLength
RSTA
STA.1
SETD.0 ScriptInto
LDD.0.0
scriptLineNext:
CALL scriptByte
BNQ scriptLineEnded
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BRQ scriptLineDone
; A still holds the character: SUB writes Q and leaves it alone.
INIB 0d13 ; Carriage return, from a file written on a host that uses them.
CCF
SUB
BRQ scriptLineNext
; Room?
SETD.1 ScriptLength
LDA.1
SETD.1 ScriptRoom
LDB.1
CCF
SUB
BRQ scriptLineNext ; Full. Read on and drop what comes, the way readLine does.
SETD.1 ScriptChar
LDA.1
STA.0
INCD.0
SETD.1 ScriptLength
LDA.1
INCA
STA.1
BRI scriptLineNext
scriptLineDone:
; Terminate it, then decide whether the shell wants to see it.
RSTA
STA.0
SETD.0 ScriptInto
LDD.0.0
LDA.0
BRA scriptLineAgain ; Empty.
INIB 0d59 ; ';' - a comment, the same as everywhere else on this machine.
CCF
SUB
BRQ scriptLineAgain
RSTA ; Q = 0: there is a line.
RSTB
CCF
ADD
RET
scriptLineEnded:
; ---- A last line with no newline on it is still a line ----
;
; Text files do not reliably end with one, and a script whose final command silently did
; not run because somebody's editor left the newline off is a bad way to find that out.
; If anything has been gathered, finish it the ordinary way; the next call comes back here
; with nothing gathered and ends for real.
SETD.1 ScriptLength
LDA.1
BRA scriptLineNoMore
BRI scriptLineDone
scriptLineNoMore:
CALL scriptClose
INIA 0x01
RSTB
CCF
ADD
RET
; ---- One character, or the end ----
;
; Q = 0 and the character is in ScriptChar, or Q is one and there are no more.
;
; IN MEMORY RATHER THAN IN A REGISTER, because RET puts A and B back the way the caller had
; them - only Q and Data Pointer 3 survive a CALL. Handing the character back in A looked
; right, assembled, and returned the caller's own A every time.
scriptByte:
SETD.1 ScriptAt
LDD.1.1
LDA.1
BRA scriptByteRefill ; The nought at the end of the block.
; Step the saved pointer past it.
SETD.1 ScriptChar
STA.1
SETD.1 ScriptAt
LDD.0.1
INCD.0
STD.0.1
RSTA
RSTB
CCF
ADD ; Q = 0.
RET
scriptByteRefill:
CALL scriptFill
BNQ scriptByteNoMore
BRI scriptByte
scriptByteNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; ---- The next block, with a nought written after it ----
;
; Q = 0 if there is one.
scriptFill:
SETD.1 ScriptBlocks
LDA.1
INCD.1
LDB.1
OR
BRQ scriptFillNoMore
SETD.0 ScriptName
SETD.1 ScriptBlock
SETD.2 ScriptIndex
LDA.2
INCD.2
LDB.2
SWI osFileBlock
BNQ scriptFillNoMore
; DP3 is how many bytes came back. The nought goes after them.
SETD.1 ScriptBlock
PSHD.3
POPB
POPA
DPUW.1
RSTA
STA.1
SETD.1 ScriptAt
SETD.0 ScriptBlock
STD.0.1
; Index++, blocks--.
SETD.1 ScriptIndex
INCD.1
LDA.1
INCA
STA.1
BNC scriptFillCount
DECD.1
LDA.1
INCA
STA.1
scriptFillCount:
SETD.1 ScriptBlocks
INCD.1
LDA.1
BRA scriptFillBorrow
DECA
STA.1
BRI scriptFillGot
scriptFillBorrow:
INIA 0xFF
STA.1
DECD.1
LDA.1
DECA
STA.1
scriptFillGot:
RSTA
RSTB
CCF
ADD
RET
scriptFillNoMore:
INIA 0x01
RSTB
CCF
ADD
RET
; Everything up to and including the next line feed, thrown away. Used for the shebang.
scriptSkipLine:
CALL scriptByte
BNQ scriptSkipDone
SETD.1 ScriptChar
LDA.1
INIB 0d10
CCF
SUB
BNQ scriptSkipLine
scriptSkipDone:
RET
scriptClose:
RSTA
SETD.1 ScriptRunning
STA.1
RET
#Data
ScriptRunning:
0x00
ScriptName:
#Reserve 0d64
ScriptBlocks:
0x00 0x00
ScriptIndex:
0x00 0x00
ScriptAt:
0x00 0x00
ScriptInto:
0x00 0x00
ScriptRoom:
0x00
ScriptLength:
0x00
ScriptChar:
0x00
ScriptBlock:
#Reserve 0d257