import eu.svjatoslav.crtbasic.ast.Ast;
import eu.svjatoslav.crtbasic.audio.SoundQueue;
import eu.svjatoslav.crtbasic.input.KeyboardQueue;
+import eu.svjatoslav.crtbasic.io.ConsoleOutput;
+import eu.svjatoslav.crtbasic.io.FileHandle;
+import eu.svjatoslav.crtbasic.io.IoSubsystem;
import eu.svjatoslav.crtbasic.machine.MachineMemory;
import eu.svjatoslav.crtbasic.video.CapturedRegion;
import eu.svjatoslav.crtbasic.video.ScreenMode;
private boolean blockOnInput = true;
/** Value returned by COMMAND$ (the text after the program name). */
private String commandLine = "";
- /**
- * Open file channels (OPEN ... FOR INPUT AS #n). Each reader carries a
- * one-line lookahead so EOF(n) can answer without consuming input.
- */
- private final Map<Integer, java.io.BufferedReader> fileChannels = new HashMap<>();
- /** File path per text channel (lazy byte-handle creation for INPUT$). */
- private final Map<Integer, java.nio.file.Path> fileChannelPaths = new HashMap<>();
- /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */
- private final Map<Integer, java.io.BufferedWriter> fileOutputs = new HashMap<>();
- private final Map<Integer, Integer> fileOutputColumns = new HashMap<>();
- private final Map<Integer, String> fileLookahead = new HashMap<>();
- /** Unconsumed tail of the current INPUT # line, per channel. */
- private final Map<Integer, String> fileRemainder = new HashMap<>();
- /** Directory that relative OPEN paths resolve against (the .bas directory). */
- private java.nio.file.Path baseDirectory = java.nio.file.Path.of("");
- /** CHDIR state; null = resolve relative to {@link #baseDirectory}. */
- private java.nio.file.Path currentDirectory;
+ /** All file/device channel state (OPEN table, CON/COM, DOS utilities). */
+ private final IoSubsystem io = new IoSubsystem(this::error, new ConsoleOutput() {
+ @Override
+ public void print(final String text) {
+ vga.print(text);
+ }
+
+ @Override
+ public void newLine() {
+ vga.newLine();
+ }
+ });
/**
* ON ERROR GOTO target (lowercased label), null when trapping is off.
* Trapping is module-global.
private boolean currentProcAllStatic;
/** Call stack of procedure names (SUB/FUNCTION), for static storage keys. */
private final Deque<String> procStack = new ArrayDeque<>();
- /** BINARY/RANDOM channels: byte-exact content plus a file pointer. */
- private final Map<Integer, FileHandle> binaryChannels = new HashMap<>();
- /** Channels opened on "CON" (console output). */
- private final java.util.Set<Integer> consoleChannels = new java.util.HashSet<>();
- /** Null-modem COM channels: input always empty, output discarded. */
- private final java.util.Set<Integer> comChannels = new java.util.HashSet<>();
/**
* A user TYPE layout: field order, per-field kind and byte offsets.
* plus a 0-based file pointer. RANDOM channels address records of
* recordLen bytes; BINARY channels address bytes.
*/
- private static final class FileHandle {
- private byte[] data;
- private int length;
- private long pos;
- private final int recordLen;
- private final boolean random; // RANDOM: positions are record numbers
- private final boolean writable;
- private final java.nio.file.Path path; // null for pipes like CON
-
- FileHandle(final byte[] content, final int recordLen, final boolean random,
- final boolean writable, final java.nio.file.Path path) {
- data = content;
- length = content.length;
- this.recordLen = recordLen <= 0 ? 128 : recordLen;
- this.random = random;
- this.writable = writable;
- this.path = path;
- }
-
- /** Positions the pointer: record number for RANDOM, 1-based byte for BINARY. */
- void seek(final long position) {
- pos = random ? (position - 1) * recordLen : position - 1;
- }
-
- /** Current 1-based position as SEEK(n) reports it. */
- long position() {
- return random ? pos / recordLen + 1 : pos + 1;
- }
-
- byte[] read(final int count) {
- final int available = (int) Math.max(0, Math.min(count, length - pos));
- final byte[] out = new byte[available];
- System.arraycopy(data, (int) pos, out, 0, available);
- pos += available;
- return out;
- }
-
- void write(final byte[] bytes) {
- final long end = pos + bytes.length;
- if (end > data.length) {
- data = Arrays.copyOf(data, (int) Math.max(end, data.length * 2 + 128));
- }
- System.arraycopy(bytes, 0, data, (int) pos, bytes.length);
- pos = end;
- length = (int) Math.max(length, end);
- }
-
- /** Persists the content (after writes), DOS CRLF untouched. */
- void flush() {
- if (!writable || path == null) {
- return;
- }
- try {
- java.nio.file.Files.write(path, Arrays.copyOf(data, length));
- } catch (final java.io.IOException e) {
- throw new InterpreterException("File write error: " + e.getMessage());
- }
- }
- }
-
public Interpreter(final VgaDevice vgaDevice, final KeyboardQueue keyboard,
final SoundQueue soundQueue) {
vga = vgaDevice;
/** Sets the directory that relative OPEN paths resolve against. */
public void setBaseDirectory(final java.nio.file.Path directory) {
- baseDirectory = directory;
+ io.paths().setBaseDirectory(directory);
}
public long statementsExecuted() {
: ((Number) eval(s.seed())).doubleValue();
rndReseed(-seed);
}
- case Ast.OpenStmt s -> execOpen(s);
- case Ast.ChdirStmt s -> {
- final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line()));
- if (!java.nio.file.Files.isDirectory(dir)) {
- throw error("Path not found", s.line());
- }
- currentDirectory = dir.normalize();
- }
- case Ast.MkdirStmt s -> {
- final java.nio.file.Path dir = resolveOpenPath(str(eval(s.path()), s.line()));
- try {
- java.nio.file.Files.createDirectory(dir);
- } catch (final java.nio.file.FileAlreadyExistsException e) {
- throw error("Path/file access error", s.line());
- } catch (final java.io.IOException e) {
- throw error("Path not found", s.line());
- }
+ case Ast.OpenStmt s -> {
+ final int channel = toInt(eval(s.channel()), s.line());
+ final String path = str(eval(s.path()), s.line());
+ final int recordLen = s.recordLength() == null ? 128
+ : toInt(eval(s.recordLength()), s.line());
+ io.open(channel, path, s.mode(), recordLen, s.line());
}
+ case Ast.ChdirStmt s -> io.paths().chdir(str(eval(s.path()), s.line()), this::error, s.line());
+ case Ast.MkdirStmt s -> io.paths().mkdir(str(eval(s.path()), s.line()), this::error, s.line());
case Ast.CloseStmt s -> {
if (s.channels() == null) {
- for (final int channel : List.copyOf(fileChannels.keySet())) {
- closeChannel(channel, s.line());
- }
- for (final int channel : List.copyOf(fileOutputs.keySet())) {
- closeOutputChannel(channel, s.line());
- }
- for (final int channel : List.copyOf(binaryChannels.keySet())) {
- binaryChannels.remove(channel).flush();
- }
- comChannels.clear();
- consoleChannels.clear();
+ io.closeAll(s.line());
} else {
for (final Ast.Expr channelExpr : s.channels()) {
- final int channel = toInt(eval(channelExpr), s.line());
- closeChannel(channel, s.line());
- closeOutputChannel(channel, s.line());
- final FileHandle handle = binaryChannels.remove(channel);
- if (handle != null) {
- handle.flush();
- }
- comChannels.remove(channel);
- consoleChannels.remove(channel);
+ io.close(toInt(eval(channelExpr), s.line()), s.line());
}
}
}
case Ast.LineInputStmt s -> {
final int channel = toInt(eval(s.channel()), s.line());
- final String lineText = readFileLine(channel, s.line());
+ final String lineText = io.readLine(channel, s.line());
if (lineText == null) {
throw error("Input past end of file", s.line());
}
case Ast.InputFileStmt s -> {
final int channel = toInt(eval(s.channel()), s.line());
for (final Ast.ReadTarget target : s.targets()) {
- setTarget(target, readFileItem(channel, target.name(), s.line()), s.line());
+ setTarget(target, io.readItem(channel, isStringTarget(target, s.line()),
+ s.line()), s.line());
}
}
case Ast.ConsoleInputStmt s -> execConsoleInput(s);
case Ast.TypeDefStmt s -> {
// TYPE layouts are registered by run(); no runtime effect.
}
- case Ast.NameStmt s -> execName(s);
- case Ast.KillStmt s -> execKill(s);
- case Ast.ShellStmt s -> execShell(s);
- case Ast.SeekStmt s -> {
- final FileHandle handle = binaryChannel(toInt(eval(s.channel()), s.line()), s.line());
- handle.seek((long) num(eval(s.position()), s.line()));
- }
+ case Ast.NameStmt s -> io.rename(str(eval(s.oldPath()), s.line()),
+ str(eval(s.newPath()), s.line()), s.line());
+ case Ast.KillStmt s -> io.kill(str(eval(s.pathSpec()), s.line()), s.line());
+ case Ast.ShellStmt s -> io.shell(s.command() == null ? ""
+ : str(eval(s.command()), s.line()), s.line());
+ case Ast.SeekStmt s -> io.byteHandle(toInt(eval(s.channel()), s.line()), s.line())
+ .seek((long) num(eval(s.position()), s.line()));
case Ast.GetFileStmt s -> execGetFile(s);
case Ast.PutFileStmt s -> execPutFile(s);
case Ast.ViewportStmt s -> {
final int border = s.border() == null ? vga.textForeground()
: toInt(eval(s.border()), s.line());
vga.paintPattern(num(eval(s.x()), s.line()), num(eval(s.y()), s.line()),
- cp437Bytes(pattern), vga.textForeground(), border);
+ Cp437.bytesOf(pattern), vga.textForeground(), border);
} else {
final int fill = fillValue == null ? vga.textForeground()
: toInt(fillValue, s.line());
throw error("No such field '" + fieldName + "' in TYPE " + record.def.name, line);
}
- /** OPEN: text modes plus BINARY/RANDOM byte channels, CON and null-modem COM ports. */
- private void execOpen(final Ast.OpenStmt s) {
- final int channel = toInt(eval(s.channel()), s.line());
- final String path = str(eval(s.path()), s.line());
- final int recordLen = s.recordLength() == null ? 128
- : toInt(eval(s.recordLength()), s.line());
- // Serial ports: "COM1:1200,N,8,1" — emulated as a silent null modem:
- // nothing ever arrives (EOF stays true, LOC is 0), output is discarded.
- if (path.toUpperCase().startsWith("COM")) {
- comChannels.add(channel);
- return;
- }
- if ("CON".equalsIgnoreCase(path)) {
- consoleChannels.add(channel);
- return;
- }
- final java.nio.file.Path resolved = resolveOpenPath(path);
- switch (s.mode()) {
- case "OUTPUT", "APPEND" -> {
- try {
- fileOutputs.put(channel, java.nio.file.Files.newBufferedWriter(
- resolved, java.nio.charset.Charset.forName("IBM437"),
- "APPEND".equals(s.mode())
- ? new java.nio.file.OpenOption[]{
- java.nio.file.StandardOpenOption.CREATE,
- java.nio.file.StandardOpenOption.APPEND}
- : new java.nio.file.OpenOption[0]));
- fileOutputColumns.put(channel, 1);
- } catch (final java.io.IOException e) {
- throw error("Cannot create file: " + path, s.line());
- }
- }
- case "INPUT" -> {
- try {
- fileChannels.put(channel, java.nio.file.Files.newBufferedReader(
- resolved, java.nio.charset.Charset.forName("IBM437")));
- fileChannelPaths.put(channel, resolved);
- } catch (final java.io.IOException e) {
- throw error("File not found: " + path, s.line());
- }
- }
- case "BINARY" -> {
- try {
- binaryChannels.put(channel, new FileHandle(
- java.nio.file.Files.readAllBytes(resolved), recordLen, false, true,
- resolved));
- } catch (final java.io.IOException e) {
- throw error("File not found: " + path, s.line());
- }
- }
- case "RANDOM" -> {
- // RANDOM files read AND write; a missing file starts empty.
- final byte[] content;
- try {
- content = java.nio.file.Files.exists(resolved)
- ? java.nio.file.Files.readAllBytes(resolved) : new byte[0];
- } catch (final java.io.IOException e) {
- throw error("File not found: " + path, s.line());
- }
- binaryChannels.put(channel,
- new FileHandle(content, recordLen, true, true, resolved));
- }
- default -> throw error("OPEN mode not supported: " + s.mode(), s.line());
- }
- }
- /** The byte channel for INPUT$/GET/SEEK/LOF, or an error. */
- private FileHandle binaryChannel(final int channel, final int line) {
- FileHandle handle = binaryChannels.get(channel);
- if (handle == null && fileChannels.containsKey(channel)) {
- // INPUT$ on a text (FOR INPUT) channel: switch to byte semantics,
- // lazily creating the byte handle over the whole file. Programs
- // that mix byte reads with INPUT #/LINE INPUT # on one channel
- // lose the text position — none of the current programs do.
- final java.nio.file.Path path = fileChannelPaths.get(channel);
- try {
- handle = new FileHandle(java.nio.file.Files.readAllBytes(path),
- 0, false, false, path);
- } catch (final java.io.IOException e) {
- throw error("File read error: " + e.getMessage(), line);
- }
- binaryChannels.put(channel, handle);
- closeChannel(channel, line);
- }
- if (handle == null) {
- throw error("Bad file number: " + channel, line);
- }
- return handle;
- }
- /** NAME old AS new: rename; classic errors 53 (missing) and 58 (target exists). */
- private void execName(final Ast.NameStmt s) {
- final String oldPath = str(eval(s.oldPath()), s.line());
- final String newPath = str(eval(s.newPath()), s.line());
- final java.nio.file.Path source = resolveOpenPath(oldPath);
- final java.nio.file.Path target = resolveOpenPath(newPath);
- if (!java.nio.file.Files.exists(source)) {
- lastErrorCode = 53;
- throw error("File not found", s.line());
- }
- if (java.nio.file.Files.exists(target)) {
- lastErrorCode = 58;
- throw error("File already exists", s.line());
- }
- try {
- java.nio.file.Files.move(source, target);
- } catch (final java.io.IOException e) {
- lastErrorCode = 76;
- throw error("Path not found", s.line());
- }
- }
-
- /** KILL filespec: delete; DOS wildcards * and ? in the last component. */
- private void execKill(final Ast.KillStmt s) {
- final String spec = str(eval(s.pathSpec()), s.line());
- final String normalized = spec.replace('\\', '/');
- final int slash = normalized.lastIndexOf('/');
- final String dirPart = slash < 0 ? "" : normalized.substring(0, slash);
- final String pattern = slash < 0 ? normalized : normalized.substring(slash + 1);
- final java.nio.file.Path dir = resolveOpenPath(dirPart.isEmpty() ? "." : dirPart);
- if (!java.nio.file.Files.isDirectory(dir)) {
- lastErrorCode = 76;
- throw error("Path not found", s.line());
- }
- final String regex = pattern.toUpperCase()
- .replace(".", "\\.").replace("*", ".*").replace("?", ".");
- int killed = 0;
- try (var stream = java.nio.file.Files.list(dir)) {
- for (final java.nio.file.Path entry
- : stream.filter(p -> p.getFileName().toString().toUpperCase().matches(regex)
- && java.nio.file.Files.isRegularFile(p)).toList()) {
- java.nio.file.Files.delete(entry);
- killed++;
- }
- } catch (final java.io.IOException e) {
- throw error("Path/file access error", s.line());
- }
- if (killed == 0) {
- lastErrorCode = 53;
- throw error("File not found", s.line());
- }
- }
- /**
- * SHELL command: a small built-in DOS subset. Currently {@code DIR path
- * [> outfile]} producing the classic 8.3 listing format WHEREIS.BAS
- * parses (name cols 1-8, extension cols 10-12, "DIR" at cols 15-17;
- * non-entry lines start with a space or '.'). Unsupported commands are
- * silently ignored, like a DOS that succeeds doing nothing.
- */
- private void execShell(final Ast.ShellStmt s) {
- final String command = s.command() == null ? "" : str(eval(s.command()), s.line()).trim();
- final String upper = command.toUpperCase();
- if (!upper.startsWith("DIR")) {
- return; // Only DIR is modeled.
- }
- String rest = command.substring(3).trim();
- String outFile = null;
- final int redirect = upper.indexOf('>');
- if (redirect >= 0) {
- outFile = command.substring(redirect + 1).trim();
- rest = command.substring(3, redirect).trim();
- }
- if (rest.endsWith("/W") || rest.endsWith("/w")) {
- rest = rest.substring(0, rest.length() - 2).trim();
- }
- // The directory to list: "\" (drive root) and "" both map to the
- // current directory of the emulated machine.
- final java.nio.file.Path dir = resolveOpenPath(
- rest.isEmpty() || rest.equals("\\") || rest.equals("/") ? "." : rest);
- if (!java.nio.file.Files.isDirectory(dir)) {
- lastErrorCode = 76;
- throw error("Path not found", s.line());
- }
- final StringBuilder listing = new StringBuilder();
- listing.append(" Volume in drive C is DOS\r\n");
- listing.append(" Directory of ").append(rest.isEmpty() ? "\\" : rest.toUpperCase())
- .append("\r\n\r\n");
- listing.append(".\r\n..\r\n");
- int files = 0;
- try (var stream = java.nio.file.Files.list(dir)) {
- for (final java.nio.file.Path entry : stream.sorted().toList()) {
- final String name = entry.getFileName().toString().toUpperCase();
- final boolean isDir = java.nio.file.Files.isDirectory(entry);
- // DOS 8.3 names: split and truncate.
- final int dot = name.lastIndexOf('.');
- String base = dot < 0 ? name : name.substring(0, dot);
- String ext = dot < 0 ? "" : name.substring(dot + 1);
- if (base.length() > 8) {
- base = base.substring(0, 8);
- }
- if (ext.length() > 3) {
- ext = ext.substring(0, 3);
- }
- if (isDir) {
- listing.append(String.format("%-8s %-3s DIR\r\n", base, ext));
- } else {
- long size = 0;
- try {
- size = java.nio.file.Files.size(entry);
- } catch (final java.io.IOException ignored) {
- // Size is cosmetic.
- }
- listing.append(String.format(java.util.Locale.ROOT, "%-8s %-3s %9d\r\n",
- base, ext, size));
- files++;
- }
- }
- } catch (final java.io.IOException e) {
- throw error("Path/file access error", s.line());
- }
- listing.append(String.format(java.util.Locale.ROOT, " %d File(s)\r\n", files));
- if (outFile == null) {
- vga.print(listing.toString().replace("\r\n", "\n"));
- return;
- }
- try {
- java.nio.file.Files.write(resolveOpenPath(outFile),
- listing.toString().getBytes(java.nio.charset.Charset.forName("IBM437")));
- } catch (final java.io.IOException e) {
- throw error("Cannot create file: " + outFile, s.line());
- }
- }
+
/** GET #ch, [position], var: reads bytes into a record or scalar variable. */
private void execGetFile(final Ast.GetFileStmt s) {
final int channel = toInt(eval(s.channel()), s.line());
- final FileHandle handle = binaryChannel(channel, s.line());
+ final FileHandle handle = io.byteHandle(channel, s.line());
if (s.position() != null) {
handle.seek((long) num(eval(s.position()), s.line()));
}
- final int count = handle.random ? handle.recordLen : byteSizeOf(s.target(), s.line());
+ final int count = handle.isRandom() ? handle.recordLength()
+ : byteSizeOf(s.target(), s.line());
final byte[] bytes = handle.read(count);
decodeInto(s.target(), bytes, s.line());
}
/** PUT #ch, [position], var: writes a record or scalar variable, then syncs. */
private void execPutFile(final Ast.PutFileStmt s) {
final int channel = toInt(eval(s.channel()), s.line());
- final FileHandle handle = binaryChannel(channel, s.line());
+ final FileHandle handle = io.byteHandle(channel, s.line());
if (s.position() != null) {
handle.seek((long) num(eval(s.position()), s.line()));
}
final String key = canonical(target.name());
final Object value;
if (key.endsWith("$")) {
- value = cp437String(bytes);
+ value = Cp437.stringOf(bytes);
} else {
value = switch (key.charAt(key.length() - 1)) {
case '%' -> (double) (short) (bytes.length >= 2
switch (field.type().toUpperCase()) {
case "STRING" -> {
final byte[] slice = Arrays.copyOfRange(bytes, offset, offset + available);
- String text = cp437String(slice);
+ String text = Cp437.stringOf(slice);
if (text.length() < field.stringLength()) {
text += " ".repeat(field.stringLength() - text.length());
}
final String key = canonical(target.name());
final Object value = getTarget(target, line);
if (key.endsWith("$")) {
- return cp437Bytes(str(value, line));
+ return Cp437.bytesOf(str(value, line));
}
final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
.allocate(8).order(java.nio.ByteOrder.LITTLE_ENDIAN);
final Object value = record.values[i];
switch (field.type().toUpperCase()) {
case "STRING" -> {
- final byte[] text = cp437Bytes(str(value, line));
+ final byte[] text = Cp437.bytesOf(str(value, line));
final byte[] padded = new byte[field.stringLength()];
Arrays.fill(padded, (byte) ' ');
System.arraycopy(text, 0, padded, 0,
return buffer.array();
}
- /** CP437 bytes → interpreter string. */
- private static String cp437String(final byte[] bytes) {
- final StringBuilder sb = new StringBuilder(bytes.length);
- for (final byte b : bytes) {
- sb.append(Cp437.charOf(b & 0xFF));
- }
- return sb.toString();
- }
- /** Interpreter string → CP437 bytes. */
- private static byte[] cp437Bytes(final String text) {
- final byte[] out = new byte[text.length()];
- for (int i = 0; i < text.length(); i++) {
- out[i] = (byte) Cp437.byteOf(text.charAt(i));
- }
- return out;
- }
/** PALETTE: attribute form, USING array form, or the bare reset. */
private void execPalette(final Ast.PaletteStmt s) {
final java.nio.ByteBuffer buffer = java.nio.ByteBuffer
.allocate(4).order(java.nio.ByteOrder.LITTLE_ENDIAN);
buffer.putFloat((float) num(singleArgValue(c), c.line()));
- return cp437String(buffer.array());
+ return Cp437.stringOf(buffer.array());
}
case "CVL" -> {
// CVL(s): a 4-byte little-endian string as a signed LONG.
- final byte[] bytes = cp437Bytes(str(singleArgValue(c), c.line()));
+ final byte[] bytes = Cp437.bytesOf(str(singleArgValue(c), c.line()));
int result = 0;
for (int i = Math.min(3, bytes.length - 1); i >= 0; i--) {
result = (result << 8) | (bytes[i] & 0xFF);
}
case "FREEFILE" -> {
// Lowest channel number (1-255) not currently open.
- for (int channel = 1; channel <= 255; channel++) {
- if (!fileChannels.containsKey(channel) && !fileOutputs.containsKey(channel)
- && !binaryChannels.containsKey(channel)
- && !comChannels.contains(channel)
- && !consoleChannels.contains(channel)) {
- return (double) channel;
- }
- }
- return 0.0;
+ return (double) io.freeFile();
}
case "LOF" -> {
// LOF(n): the file's length in bytes.
- return (double) binaryChannel(toInt(singleArgValue(c), c.line()), c.line()).length;
+ return (double) io.byteHandle(toInt(singleArgValue(c), c.line()), c.line()).length();
}
case "SEEK" -> {
// SEEK(n): the next position (record number for RANDOM,
// 1-based byte position otherwise).
- return (double) binaryChannel(toInt(singleArgValue(c), c.line()), c.line())
+ return (double) io.byteHandle(toInt(singleArgValue(c), c.line()), c.line())
.position();
}
case "LOC" -> {
// LOC(n): bytes waiting (COM ports) — files report remaining.
final int channel = toInt(singleArgValue(c), c.line());
- if (comChannels.contains(channel)) {
+ if (io.isCom(channel)) {
return 0.0;
}
- final FileHandle handle = binaryChannel(channel, c.line());
- return (double) Math.max(0, handle.length - handle.pos);
+ return (double) io.byteHandle(channel, c.line()).remaining();
}
case "EOF" -> {
// -1 when the channel has no more input, 0 otherwise.
- return fileEof(toInt(singleArgValue(c), c.line()), c.line()) ? -1.0 : 0.0;
+ return io.eof(toInt(singleArgValue(c), c.line()), c.line()) ? -1.0 : 0.0;
}
case "ASC" -> {
// ASC(s): the CP437 byte value of the first character —
if (args.size() == 2) {
final int n = toInt(eval(args.get(0)), c.line());
final int channel = toInt(eval(args.get(1)), c.line());
- if (comChannels.contains(channel)) {
- return "";
- }
- return cp437String(binaryChannel(channel, c.line()).read(n));
+ return Cp437.stringOf(io.readBytes(channel, n, c.line()));
}
final int n = toInt(singleArgValue(c), c.line());
final StringBuilder sb = new StringBuilder();
}
}
- /**
- * Reads the next line from an open channel, consuming the one-line
- * lookahead first when EOF(n) filled it. Returns null at end of file
- * (does not throw).
- */
- private String readFileLine(final int channel, final int line) {
- final java.io.BufferedReader reader = fileChannels.get(channel);
- if (reader == null) {
- throw error("Bad file number: " + channel, line);
- }
- final String buffered = fileLookahead.remove(channel);
- if (buffered != null) {
- return buffered;
- }
- try {
- return reader.readLine();
- } catch (final java.io.IOException e) {
- throw error("File read error: " + e.getMessage(), line);
- }
- }
-
- /**
- * Reads the next data item for {@code INPUT #channel} and converts it
- * for the target variable. Classic delimiters: numbers end at a comma,
- * whitespace or end of line; strings end at a comma or end of line
- * (inner spaces kept, surrounding quotes stripped). An empty slot
- * between commas reads as 0 / "". A statement may span lines and
- * several statements may share one line.
- */
- private Object readFileItem(final int channel, final String variable, final int line) {
- final boolean stringTarget = canonical(variable).endsWith("$");
- String rest = fileRemainder.get(channel);
- while (true) {
- if (rest == null) {
- rest = readFileLine(channel, line);
- if (rest == null) {
- throw error("Input past end of file", line);
- }
- continue;
- }
- rest = rest.stripLeading();
- if (rest.isEmpty()) {
- // Line exhausted: the item comes from the next line.
- rest = null;
- fileRemainder.remove(channel);
- continue;
- }
- if (rest.charAt(0) == ',') {
- // Empty item between two commas.
- fileRemainder.put(channel, rest.substring(1));
- return stringTarget ? "" : 0.0;
- }
- break;
- }
- if (stringTarget && rest.startsWith("\"")) {
- final int close = rest.indexOf('"', 1);
- if (close < 0) {
- // Unterminated quote: take the rest of the line.
- fileRemainder.remove(channel);
- return rest.substring(1);
- }
- String tail = rest.substring(close + 1).stripLeading();
- if (tail.startsWith(",")) {
- tail = tail.substring(1);
- }
- fileRemainder.put(channel, tail);
- return rest.substring(1, close);
- }
- final int end;
- if (stringTarget) {
- final int comma = rest.indexOf(',');
- end = comma < 0 ? rest.length() : comma;
- } else {
- int i = 0;
- while (i < rest.length() && rest.charAt(i) != ','
- && !Character.isWhitespace(rest.charAt(i))) {
- i++;
- }
- end = i;
- }
- final String item = stringTarget
- ? rest.substring(0, end).stripTrailing() : rest.substring(0, end);
- String tail = rest.substring(end).stripLeading();
- if (tail.startsWith(",")) {
- tail = tail.substring(1);
- }
- fileRemainder.put(channel, tail);
- if (stringTarget) {
- return item;
- }
- try {
- return Double.parseDouble(item);
- } catch (final NumberFormatException e) {
- return 0.0;
- }
- }
-
- /**
- * Resolves an OPEN path against the .bas directory. DOS paths use
- * backslashes and are case-insensitive; on a case-sensitive filesystem
- * each missing component is matched case-insensitively against the
- * directory listing (e.g. {@code IMG/pilv.i01} finds {@code img/}).
- */
- private java.nio.file.Path resolveOpenPath(final String path) {
- String normalized = path.replace('\\', '/');
- if (normalized.matches("^[A-Za-z]:.*")) {
- normalized = normalized.substring(2); // drop DOS drive letter
- }
- final java.nio.file.Path base = currentDirectory != null ? currentDirectory : baseDirectory;
- final boolean absolute = normalized.startsWith("/");
- // DOS-absolute paths ("\SUB\X") start at the drive root — in this
- // sandbox the drive root is the .bas directory, not the host root.
- final java.nio.file.Path direct = absolute
- ? baseDirectory.resolve(normalized.substring(1))
- : base.resolve(normalized);
- if (java.nio.file.Files.exists(direct)) {
- return direct;
- }
- java.nio.file.Path current = absolute ? baseDirectory : base;
- for (final String component : normalized.split("/")) {
- if (component.isEmpty() || component.equals(".")) {
- continue;
- }
- final java.nio.file.Path candidate = current.resolve(component);
- if (java.nio.file.Files.exists(candidate)) {
- current = candidate;
- continue;
- }
- try (var entries = java.nio.file.Files.list(current)) {
- final java.nio.file.Path match = entries
- .filter(p -> p.getFileName().toString().equalsIgnoreCase(component))
- .findFirst().orElse(null);
- if (match == null) {
- return direct; // let the open fail with the original path
- }
- current = match;
- } catch (final java.io.IOException e) {
- return direct;
- }
- }
- return current;
- }
- /** EOF(n): fills the lookahead so EOF never consumes input. */
- private boolean fileEof(final int channel, final int line) {
- // Byte-oriented channels (BINARY/RANDOM, or INPUT$ used on a text
- // file) answer from the file pointer; COM ports are always empty.
- if (comChannels.contains(channel)) {
- return true;
- }
- final FileHandle handle = binaryChannels.get(channel);
- if (handle != null) {
- return handle.pos >= handle.length;
- }
- final java.io.BufferedReader reader = fileChannels.get(channel);
- if (reader == null) {
- throw error("Bad file number: " + channel, line);
- }
- if (fileLookahead.containsKey(channel)) {
- return false;
- }
- try {
- final String ahead = reader.readLine();
- if (ahead == null) {
- return true;
- }
- fileLookahead.put(channel, ahead);
- return false;
- } catch (final java.io.IOException e) {
- throw error("File read error: " + e.getMessage(), line);
- }
- }
- private void closeChannel(final int channel, final int line) {
- final java.io.BufferedReader reader = fileChannels.remove(channel);
- fileChannelPaths.remove(channel);
- fileLookahead.remove(channel);
- fileRemainder.remove(channel);
- if (reader == null) {
- return; // CLOSE on an unopened channel is ignored
- }
- try {
- reader.close();
- } catch (final java.io.IOException e) {
- throw error("File close error: " + e.getMessage(), line);
- }
- }
- private void closeOutputChannel(final int channel, final int line) {
- final java.io.BufferedWriter writer = fileOutputs.remove(channel);
- if (writer == null) {
- return;
- }
- fileOutputColumns.remove(channel);
- try {
- writer.close();
- } catch (final java.io.IOException e) {
- throw error("File close error: " + e.getMessage(), line);
- }
- }
+
+
/**
- * {@code PRINT #channel, items}: the same item formatting as console
- * PRINT (classic number style, ';' compact, ',' 14-column zones),
- * written as DOS text — lines end with CRLF. Zones wrap at column 255
- * (QBasic's default file width).
+ * {@code PRINT #channel, items}: items are formatted here exactly as
+ * console PRINT (classic number style, exprIsDouble precision, USING
+ * when present); the io layer owns writing (CON bridge, zones, CRLF).
*/
private void execPrintFile(final Ast.PrintStmt s) {
final int channel = toInt(eval(s.channel()), s.line());
- if (comChannels.contains(channel)) {
- // Null modem: bytes go nowhere.
- return;
- }
- if (consoleChannels.contains(channel)) {
- // OPEN "CON" FOR OUTPUT: PRINT # writes to the screen.
- for (final Ast.PrintStmt.Item item : s.items()) {
- final Object value = eval(item.expr());
- vga.print(value instanceof String text ? text
- : basicNumber(num(value, s.line()), exprIsDouble(item.expr())));
- }
- if (s.newline()) {
- vga.newLine();
- }
- return;
- }
- final java.io.BufferedWriter writer = fileOutputs.get(channel);
- if (writer == null) {
- throw error("Bad file number", s.line());
- }
+ final java.util.List<IoSubsystem.PrintItem> items = new ArrayList<>();
if (s.usingFormat() != null) {
- // PRINT #n, USING — same formatting as the console.
- try {
- writer.write(usingFormatted(s));
- if (s.newline()) {
- writer.write("\r\n");
- }
- writer.flush();
- } catch (final java.io.IOException e) {
- throw error("File write error: " + e.getMessage(), s.line());
- }
- return;
- }
- int column = fileOutputColumns.get(channel);
- try {
+ items.add(new IoSubsystem.PrintItem(usingFormatted(s), '\0'));
+ } else {
for (final Ast.PrintStmt.Item item : s.items()) {
final Object value = eval(item.expr());
- final String text = value instanceof String string ? string
- : basicNumber(num(value, s.line()), exprIsDouble(item.expr()));
- writer.write(text);
- column += text.length();
- if (item.separatorAfter() == ',') {
- final int target = ((column - 1) / 14 + 1) * 14 + 1;
- if (target > 255) {
- writer.write("\r\n");
- column = 1;
- } else {
- final String padding = " ".repeat(target - column);
- writer.write(padding);
- column = target;
- }
- }
- }
- if (s.newline()) {
- writer.write("\r\n");
- column = 1;
+ items.add(new IoSubsystem.PrintItem(
+ value instanceof String text ? text
+ : basicNumber(num(value, s.line()), exprIsDouble(item.expr())),
+ item.separatorAfter()));
}
- // Flush eagerly: programs that record while running (flight
- // scripts) must not lose data when the JVM is killed.
- writer.flush();
- } catch (final java.io.IOException e) {
- throw error("File write error: " + e.getMessage(), s.line());
}
- fileOutputColumns.put(channel, column);
+ io.printItems(channel, items, s.newline(), s.line());
}
+
/**
* RND(-n): reseeds the generator from n. The SINGLE bit pattern of
* |n| folds into the 24-bit seed as
--- /dev/null
+package eu.svjatoslav.crtbasic.io;
+
+import eu.svjatoslav.crtbasic.Cp437;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The emulated machine's file and device I/O: the OPEN channel table
+ * (text input/output, byte-addressable BINARY/RANDOM, CON, COM null
+ * modem), text record reading (INPUT # / LINE INPUT #), file printing
+ * (PRINT # with 14-column zones, CRLF line endings, column-255 wrap),
+ * and the DOS file utilities (NAME, KILL with wildcards, SHELL "DIR").
+ *
+ * <p>All channel state lives in {@link Channel} objects in one table —
+ * adding a per-channel fact means adding a field, not a parallel map.
+ */
+public final class IoSubsystem {
+
+ /** One PRINT # item: already-formatted text plus the separator after it. */
+ public record PrintItem(String text, char separatorAfter) {
+ }
+
+ private final Map<Integer, Channel> channels = new HashMap<>();
+ private final IoErrors errors;
+ private final ConsoleOutput console;
+ private final DosPaths paths = new DosPaths();
+
+ public IoSubsystem(final IoErrors errorsBridge, final ConsoleOutput consoleBridge) {
+ errors = errorsBridge;
+ console = consoleBridge;
+ }
+
+ public DosPaths paths() {
+ return paths;
+ }
+
+ // ------------------------------------------------------------ open/close
+
+ /** OPEN path FOR mode AS #n; COM ports are a silent null modem. */
+ public void open(final int number, final String dosPath, final String mode,
+ final int recordLen, final int line) {
+ // Serial ports: "COM1:1200,N,8,1" — emulated as a silent null modem:
+ // nothing ever arrives (EOF stays true, LOC is 0), output discarded.
+ if (dosPath.toUpperCase().startsWith("COM")) {
+ channels.put(number, new Channel(number, Channel.Kind.COM));
+ return;
+ }
+ if ("CON".equalsIgnoreCase(dosPath)) {
+ channels.put(number, new Channel(number, Channel.Kind.CONSOLE));
+ return;
+ }
+ final java.nio.file.Path resolved = paths.resolve(dosPath);
+ switch (mode) {
+ case "OUTPUT", "APPEND" -> {
+ try {
+ final Channel channel = new Channel(number, Channel.Kind.TEXT_OUTPUT);
+ channel.writer = java.nio.file.Files.newBufferedWriter(
+ resolved, Cp437.charset(),
+ "APPEND".equals(mode)
+ ? new java.nio.file.OpenOption[]{
+ java.nio.file.StandardOpenOption.CREATE,
+ java.nio.file.StandardOpenOption.APPEND}
+ : new java.nio.file.OpenOption[0]);
+ channels.put(number, channel);
+ } catch (final java.io.IOException e) {
+ throw errors.error("Cannot create file: " + dosPath, line);
+ }
+ }
+ case "INPUT" -> {
+ try {
+ final Channel channel = new Channel(number, Channel.Kind.TEXT_INPUT);
+ channel.reader = java.nio.file.Files.newBufferedReader(
+ resolved, Cp437.charset());
+ channel.path = resolved;
+ channels.put(number, channel);
+ } catch (final java.io.IOException e) {
+ throw errors.error("File not found: " + dosPath, line);
+ }
+ }
+ case "BINARY" -> {
+ try {
+ final Channel channel = new Channel(number, Channel.Kind.BYTES);
+ channel.handle = new FileHandle(
+ java.nio.file.Files.readAllBytes(resolved), recordLen, false, true,
+ resolved);
+ channels.put(number, channel);
+ } catch (final java.io.IOException e) {
+ throw errors.error("File not found: " + dosPath, line);
+ }
+ }
+ case "RANDOM" -> {
+ // RANDOM files read AND write; a missing file starts empty.
+ final byte[] content;
+ try {
+ content = java.nio.file.Files.exists(resolved)
+ ? java.nio.file.Files.readAllBytes(resolved) : new byte[0];
+ } catch (final java.io.IOException e) {
+ throw errors.error("File not found: " + dosPath, line);
+ }
+ final Channel channel = new Channel(number, Channel.Kind.BYTES);
+ channel.handle = new FileHandle(content, recordLen, true, true, resolved);
+ channels.put(number, channel);
+ }
+ default -> throw errors.error("OPEN mode not supported: " + mode, line);
+ }
+ }
+
+ /** CLOSE #n on one channel; CLOSE on an unopened channel is ignored. */
+ public void close(final int number, final int line) {
+ final Channel channel = channels.remove(number);
+ if (channel == null) {
+ return;
+ }
+ closeQuietly(channel, line);
+ }
+
+ /** Bare CLOSE: every channel. */
+ public void closeAll(final int line) {
+ for (final Channel channel : List.copyOf(channels.values())) {
+ closeQuietly(channel, line);
+ }
+ channels.clear();
+ }
+
+ private void closeQuietly(final Channel channel, final int line) {
+ try {
+ if (channel.reader != null) {
+ channel.reader.close();
+ }
+ if (channel.writer != null) {
+ channel.writer.close();
+ }
+ if (channel.handle != null) {
+ channel.handle.flush();
+ }
+ } catch (final java.io.IOException e) {
+ throw errors.error("File close error: " + e.getMessage(), line);
+ }
+ }
+
+ /** FREEFILE: the lowest channel number (1-255) not currently open. */
+ public int freeFile() {
+ for (int number = 1; number <= 255; number++) {
+ if (!channels.containsKey(number)) {
+ return number;
+ }
+ }
+ return 0;
+ }
+
+ // ------------------------------------------------------------ byte access
+
+ /**
+ * The byte channel for INPUT$/GET/PUT/SEEK/LOF/LOC. A text (FOR INPUT)
+ * channel is switched to byte semantics lazily, over the whole file —
+ * programs that mix byte reads with INPUT #/LINE INPUT # on one channel
+ * lose the text position (none of the current programs do).
+ */
+ public FileHandle byteHandle(final int number, final int line) {
+ Channel channel = channels.get(number);
+ if (channel != null && channel.kind == Channel.Kind.TEXT_INPUT) {
+ try {
+ channel.handle = new FileHandle(
+ java.nio.file.Files.readAllBytes(channel.path), 0, false, false,
+ channel.path);
+ } catch (final java.io.IOException e) {
+ throw errors.error("File read error: " + e.getMessage(), line);
+ }
+ try {
+ channel.reader.close();
+ } catch (final java.io.IOException e) {
+ throw errors.error("File close error: " + e.getMessage(), line);
+ }
+ channel.kind = Channel.Kind.BYTES;
+ channel.reader = null;
+ channel.lookahead = null;
+ channel.remainder = null;
+ }
+ if (channel == null || channel.handle == null) {
+ throw errors.error("Bad file number: " + number, line);
+ }
+ return channel.handle;
+ }
+
+ /** Whether the channel is a COM port (null modem). */
+ public boolean isCom(final int number) {
+ final Channel channel = channels.get(number);
+ return channel != null && channel.kind == Channel.Kind.COM;
+ }
+
+ /** EOF(n): true when the channel has no more input. */
+ public boolean eof(final int number, final int line) {
+ final Channel channel = channels.get(number);
+ if (channel == null) {
+ throw errors.error("Bad file number: " + number, line);
+ }
+ switch (channel.kind) {
+ case COM -> {
+ return true;
+ }
+ case BYTES -> {
+ return channel.handle.atEnd();
+ }
+ case TEXT_INPUT -> {
+ if (channel.lookahead != null) {
+ return false;
+ }
+ try {
+ channel.lookahead = channel.reader.readLine();
+ return channel.lookahead == null;
+ } catch (final java.io.IOException e) {
+ throw errors.error("File read error: " + e.getMessage(), line);
+ }
+ }
+ default -> throw errors.error("Bad file number: " + number, line);
+ }
+ }
+
+ // ------------------------------------------------------------ text input
+
+ /**
+ * Reads one line from a text channel (LINE INPUT #, or one source line
+ * for INPUT # items). Null at end of file.
+ */
+ public String readLine(final int number, final int line) {
+ final Channel channel = channels.get(number);
+ if (channel == null || channel.kind != Channel.Kind.TEXT_INPUT) {
+ throw errors.error("Bad file number: " + number, line);
+ }
+ if (channel.lookahead != null) {
+ final String buffered = channel.lookahead;
+ channel.lookahead = null;
+ return buffered;
+ }
+ try {
+ return channel.reader.readLine();
+ } catch (final java.io.IOException e) {
+ throw errors.error("File read error: " + e.getMessage(), line);
+ }
+ }
+
+ /**
+ * Reads one INPUT # item: numbers end at comma/whitespace/newline,
+ * strings at comma/newline (inner spaces kept, quotes stripped),
+ * empty slots read as 0 / "". Channel state is the unconsumed line
+ * tail — several INPUT # can share one line and one can span lines.
+ */
+ public Object readItem(final int number, final boolean stringTarget, final int line) {
+ final Channel channel = channels.get(number);
+ if (channel == null || channel.kind != Channel.Kind.TEXT_INPUT) {
+ throw errors.error("Bad file number: " + number, line);
+ }
+ String rest = channel.remainder;
+ while (true) {
+ if (rest == null) {
+ rest = readLine(number, line);
+ if (rest == null) {
+ throw errors.error("Input past end of file", line);
+ }
+ continue;
+ }
+ rest = rest.stripLeading();
+ if (rest.isEmpty()) {
+ // Line exhausted: the item comes from the next line.
+ rest = null;
+ channel.remainder = null;
+ continue;
+ }
+ if (rest.charAt(0) == ',') {
+ // Empty item between two commas.
+ channel.remainder = rest.substring(1);
+ return stringTarget ? "" : 0.0;
+ }
+ break;
+ }
+ if (stringTarget && rest.startsWith("\"")) {
+ final int closeQuote = rest.indexOf('"', 1);
+ if (closeQuote < 0) {
+ // Unterminated quote: take the rest of the line.
+ channel.remainder = null;
+ return rest.substring(1);
+ }
+ String tail = rest.substring(closeQuote + 1).stripLeading();
+ if (tail.startsWith(",")) {
+ tail = tail.substring(1);
+ }
+ channel.remainder = tail;
+ return rest.substring(1, closeQuote);
+ }
+ final int end;
+ if (stringTarget) {
+ final int comma = rest.indexOf(',');
+ end = comma < 0 ? rest.length() : comma;
+ } else {
+ int i = 0;
+ while (i < rest.length() && rest.charAt(i) != ','
+ && !Character.isWhitespace(rest.charAt(i))) {
+ i++;
+ }
+ end = i;
+ }
+ final String item = stringTarget
+ ? rest.substring(0, end).stripTrailing() : rest.substring(0, end);
+ String tail = rest.substring(end).stripLeading();
+ if (tail.startsWith(",")) {
+ tail = tail.substring(1);
+ }
+ channel.remainder = tail;
+ if (stringTarget) {
+ return item;
+ }
+ try {
+ return Double.parseDouble(item);
+ } catch (final NumberFormatException e) {
+ return 0.0;
+ }
+ }
+
+ /** INPUT$(n, #ch): n raw bytes from a channel (COM never has any). */
+ public byte[] readBytes(final int number, final int count, final int line) {
+ if (isCom(number)) {
+ return new byte[0];
+ }
+ return byteHandle(number, line).read(count);
+ }
+
+ // ------------------------------------------------------------ text output
+
+ /**
+ * PRINT #channel, items: preformatted item texts with ';' compact and
+ * ',' 14-column zones, written as DOS text — lines end with CRLF.
+ * Zones wrap at column 255 (QBasic's default file width). Writes
+ * flush eagerly: programs that record while running (flight scripts)
+ * must not lose data when the JVM is killed.
+ */
+ public void printItems(final int number, final List<PrintItem> items,
+ final boolean newline, final int line) {
+ final Channel channel = channels.get(number);
+ if (channel == null) {
+ throw errors.error("Bad file number", line);
+ }
+ switch (channel.kind) {
+ case COM -> {
+ // Null modem: bytes go nowhere.
+ }
+ case CONSOLE -> {
+ for (final PrintItem item : items) {
+ console.print(item.text());
+ }
+ if (newline) {
+ console.newLine();
+ }
+ }
+ case TEXT_OUTPUT -> {
+ try {
+ for (final PrintItem item : items) {
+ channel.writer.write(item.text());
+ channel.column += item.text().length();
+ if (item.separatorAfter() == ',') {
+ final int target = ((channel.column - 1) / 14 + 1) * 14 + 1;
+ if (target > 255) {
+ channel.writer.write("\r\n");
+ channel.column = 1;
+ } else {
+ channel.writer.write(" ".repeat(target - channel.column));
+ channel.column = target;
+ }
+ }
+ }
+ if (newline) {
+ channel.writer.write("\r\n");
+ channel.column = 1;
+ }
+ channel.writer.flush();
+ } catch (final java.io.IOException e) {
+ throw errors.error("File write error: " + e.getMessage(), line);
+ }
+ }
+ default -> throw errors.error("Bad file number", line);
+ }
+ }
+
+ // ------------------------------------------------------------ DOS utilities
+
+ /** NAME old AS new: rename; classic errors 53 (missing) and 58 (target exists). */
+ public void rename(final String oldDosPath, final String newDosPath, final int line) {
+ final java.nio.file.Path source = paths.resolve(oldDosPath);
+ final java.nio.file.Path target = paths.resolve(newDosPath);
+ if (!java.nio.file.Files.exists(source)) {
+ throw errors.error("File not found", line);
+ }
+ if (java.nio.file.Files.exists(target)) {
+ throw errors.error("File already exists", line);
+ }
+ try {
+ java.nio.file.Files.move(source, target);
+ } catch (final java.io.IOException e) {
+ throw errors.error("Path not found", line);
+ }
+ }
+
+ /** KILL filespec: delete; DOS wildcards * and ? in the last component. */
+ public void kill(final String dosSpec, final int line) {
+ final String normalized = dosSpec.replace('\\', '/');
+ final int slash = normalized.lastIndexOf('/');
+ final String dirPart = slash < 0 ? "" : normalized.substring(0, slash);
+ final String pattern = slash < 0 ? normalized : normalized.substring(slash + 1);
+ final java.nio.file.Path dir = paths.resolve(dirPart.isEmpty() ? "." : dirPart);
+ if (!java.nio.file.Files.isDirectory(dir)) {
+ throw errors.error("Path not found", line);
+ }
+ final String regex = pattern.toUpperCase()
+ .replace(".", "\\.").replace("*", ".*").replace("?", ".");
+ int killed = 0;
+ try (var stream = java.nio.file.Files.list(dir)) {
+ for (final java.nio.file.Path entry
+ : stream.filter(p -> p.getFileName().toString().toUpperCase().matches(regex)
+ && java.nio.file.Files.isRegularFile(p)).toList()) {
+ java.nio.file.Files.delete(entry);
+ killed++;
+ }
+ } catch (final java.io.IOException e) {
+ throw errors.error("Path/file access error", line);
+ }
+ if (killed == 0) {
+ throw errors.error("File not found", line);
+ }
+ }
+
+ /**
+ * SHELL command: a small built-in DOS subset. Currently {@code DIR path
+ * [> outfile]} producing the classic 8.3 listing format WHEREIS.BAS
+ * parses (name cols 1-8, extension cols 10-12, "DIR" at cols 15-17;
+ * non-entry lines start with a space or '.'). Unsupported commands are
+ * silently ignored, like a DOS that succeeds doing nothing.
+ */
+ public void shell(final String command, final int line) {
+ final String trimmed = command.trim();
+ final String upper = trimmed.toUpperCase();
+ if (!upper.startsWith("DIR")) {
+ return; // Only DIR is modeled.
+ }
+ String rest = trimmed.substring(3).trim();
+ String outFile = null;
+ final int redirect = upper.indexOf('>');
+ if (redirect >= 0) {
+ outFile = trimmed.substring(redirect + 1).trim();
+ rest = trimmed.substring(3, redirect).trim();
+ }
+ if (rest.endsWith("/W") || rest.endsWith("/w")) {
+ rest = rest.substring(0, rest.length() - 2).trim();
+ }
+ // The directory to list: "\" (drive root) and "" both map to the
+ // current directory of the emulated machine.
+ final java.nio.file.Path dir = paths.resolve(
+ rest.isEmpty() || rest.equals("\\") || rest.equals("/") ? "." : rest);
+ if (!java.nio.file.Files.isDirectory(dir)) {
+ throw errors.error("Path not found", line);
+ }
+ final StringBuilder listing = new StringBuilder();
+ listing.append(" Volume in drive C is DOS\r\n");
+ listing.append(" Directory of ").append(rest.isEmpty() ? "\\" : rest.toUpperCase())
+ .append("\r\n\r\n");
+ listing.append(".\r\n..\r\n");
+ int files = 0;
+ try (var stream = java.nio.file.Files.list(dir)) {
+ for (final java.nio.file.Path entry : stream.sorted().toList()) {
+ final String name = entry.getFileName().toString().toUpperCase();
+ final boolean isDir = java.nio.file.Files.isDirectory(entry);
+ // DOS 8.3 names: split and truncate.
+ final int dot = name.lastIndexOf('.');
+ String base = dot < 0 ? name : name.substring(0, dot);
+ String ext = dot < 0 ? "" : name.substring(dot + 1);
+ if (base.length() > 8) {
+ base = base.substring(0, 8);
+ }
+ if (ext.length() > 3) {
+ ext = ext.substring(0, 3);
+ }
+ if (isDir) {
+ listing.append(String.format("%-8s %-3s DIR\r\n", base, ext));
+ } else {
+ long size = 0;
+ try {
+ size = java.nio.file.Files.size(entry);
+ } catch (final java.io.IOException ignored) {
+ // Size is cosmetic.
+ }
+ listing.append(String.format(java.util.Locale.ROOT, "%-8s %-3s %9d\r\n",
+ base, ext, size));
+ files++;
+ }
+ }
+ } catch (final java.io.IOException e) {
+ throw errors.error("Path/file access error", line);
+ }
+ listing.append(String.format(java.util.Locale.ROOT, " %d File(s)\r\n", files));
+ if (outFile == null) {
+ console.print(listing.toString().replace("\r\n", "\n"));
+ return;
+ }
+ try {
+ java.nio.file.Files.write(paths.resolve(outFile), Cp437.bytesOf(listing.toString()));
+ } catch (final java.io.IOException e) {
+ throw errors.error("Cannot create file: " + outFile, line);
+ }
+ }
+}