From 3ccaa91a38175d95edcf4eb3b8fcb5f812eb3f17 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Thu, 3 Sep 2026 23:49:00 +0300 Subject: [PATCH] refactor(io): extract channel I/O from Interpreter into an io package MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Interpreter grew a god-object sprawl of nine parallel maps keyed by channel number (text readers, paths, writers, columns, lookahead, remainder, binary handles, CON set, COM set) — every file feature had to touch several of them, and CLOSE had to clean each separately. New package eu.svjatoslav.crtbasic.io: - Channel: one holder for all per-channel state (kind, reader, writer, column, lookahead, remainder, byte handle) — new per-channel facts become fields, not new maps - FileHandle: the in-memory BINARY/RANDOM file image (moved out of Interpreter, accessor-encapsulated) - DosPaths: DOS path resolution (backslashes, drive letters, case-insensitive components, drive-root anchoring) + CHDIR/MKDIR - IoSubsystem: the channel table and all operations — OPEN/CLOSE, FREEFILE, EOF/LOF/LOC/SEEK, readLine/readItem (INPUT # tokenizer), readBytes (INPUT$ file form), PRINT # with 14-column zones and column-255 wrap, NAME, KILL wildcards, SHELL "DIR" listings - ConsoleOutput/IoErrors bridges keep the interpreter's screen and ON ERROR semantics without io depending back on it CP437 helpers consolidate on the existing Cp437 class (stringOf/ bytesOf/charset); Interpreter's private copies are gone. Docs: the BINARY/RANDOM row no longer claims FIELD (never implemented). Verification: 93/93 tests green; the full QB45 EXAMPLES suite re-run through the GameDriver — every deterministic screen pixel-identical to the pre-refactor captures, CRLF/ENTAB outputs byte-exact, WHEREIS recursive SHELL-DIR scan pixel-identical. --- Documentation/language/index.org | 2 +- .../java/eu/svjatoslav/crtbasic/Cp437.java | 23 + .../crtbasic/interp/Interpreter.java | 724 ++---------------- .../eu/svjatoslav/crtbasic/io/Channel.java | 31 + .../svjatoslav/crtbasic/io/ConsoleOutput.java | 12 + .../eu/svjatoslav/crtbasic/io/DosPaths.java | 85 ++ .../eu/svjatoslav/crtbasic/io/FileHandle.java | 97 +++ .../eu/svjatoslav/crtbasic/io/IoErrors.java | 12 + .../svjatoslav/crtbasic/io/IoSubsystem.java | 510 ++++++++++++ 9 files changed, 840 insertions(+), 656 deletions(-) create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/Channel.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/ConsoleOutput.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/DosPaths.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/FileHandle.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/IoErrors.java create mode 100644 src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java diff --git a/Documentation/language/index.org b/Documentation/language/index.org index a42e845..eadfbf4 100644 --- a/Documentation/language/index.org +++ b/Documentation/language/index.org @@ -210,7 +210,7 @@ END FUNCTION | =RANDOMIZE [seed]= / =RND[(n)]= | Bit-exact generator; no seed = =RANDOMIZE TIMER= | | =OPEN path FOR INPUT AS #n= | Text files; DOS paths resolve relative to the =.bas= file (or the =CHDIR= state): backslash separators and case-insensitive components like the DOS filesystem; a leading =\dir\file= resolves against the emulated drive root | | =OPEN path FOR OUTPUT/APPEND AS #n=, =PRINT #n, ...= | Writing; OUTPUT truncates, APPEND adds at end; =PRINT #= uses console formatting with CRLF line endings | -| =OPEN path FOR BINARY/RANDOM [LEN=n] AS #n= | Byte-addressable files; =GET #n, pos, var= / =PUT #n, pos, var= read/write whole values or records; =FIELD #n, w AS f$, …= overlays fixed strings on the record buffer; =SEEK #n, p= / =SEEK(n)= / =LOF(n)= / =LOC(n)= work | +| =OPEN path FOR BINARY/RANDOM [LEN=n] AS #n= | Byte-addressable files; =GET #n, pos, var= / =PUT #n, pos, var= read/write whole values or TYPE records; =SEEK #n, p= / =SEEK(n)= / =LOF(n)= / =LOC(n)= work | | =OPEN "COM1:…"= | Serial channel: emulated null modem (input never arrives, output accepted); TERMINAL.BAS runs but never receives | | =CHDIR path$=, =MKDIR path$= | Current-directory state and directory creation; =MKDIR= on an existing directory raises *Path/file access error* like real QBasic | | =INPUT #n, var, …= | Numbers delimited by comma, space or newline; strings by comma/newline (quotes stripped); empty slots read as 0 / =""= | diff --git a/src/main/java/eu/svjatoslav/crtbasic/Cp437.java b/src/main/java/eu/svjatoslav/crtbasic/Cp437.java index 454922b..60508c3 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/Cp437.java +++ b/src/main/java/eu/svjatoslav/crtbasic/Cp437.java @@ -45,4 +45,27 @@ public final class Cp437 { return b < 128 ? String.valueOf((char) b) : new String(new byte[]{(byte) b}, CHARSET); } + + /** CP437 bytes → interpreter string. */ + public static String stringOf(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(bytes.length); + for (final byte b : bytes) { + sb.append(charOf(b & 0xFF)); + } + return sb.toString(); + } + + /** Interpreter string → CP437 bytes. */ + public static byte[] bytesOf(final String text) { + final byte[] out = new byte[text.length()]; + for (int i = 0; i < text.length(); i++) { + out[i] = (byte) byteOf(text.charAt(i)); + } + return out; + } + + /** The charset itself, for whole-file readers/writers. */ + public static Charset charset() { + return CHARSET; + } } diff --git a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java index 34193ec..94b982f 100644 --- a/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java +++ b/src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java @@ -4,6 +4,9 @@ import eu.svjatoslav.crtbasic.Cp437; 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; @@ -250,23 +253,18 @@ public final class Interpreter { 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 fileChannels = new HashMap<>(); - /** File path per text channel (lazy byte-handle creation for INPUT$). */ - private final Map fileChannelPaths = new HashMap<>(); - /** OPEN ... FOR OUTPUT channels; column tracked per channel for PRINT # zones. */ - private final Map fileOutputs = new HashMap<>(); - private final Map fileOutputColumns = new HashMap<>(); - private final Map fileLookahead = new HashMap<>(); - /** Unconsumed tail of the current INPUT # line, per channel. */ - private final Map 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. @@ -322,12 +320,6 @@ public final class Interpreter { private boolean currentProcAllStatic; /** Call stack of procedure names (SUB/FUNCTION), for static storage keys. */ private final Deque procStack = new ArrayDeque<>(); - /** BINARY/RANDOM channels: byte-exact content plus a file pointer. */ - private final Map binaryChannels = new HashMap<>(); - /** Channels opened on "CON" (console output). */ - private final java.util.Set consoleChannels = new java.util.HashSet<>(); - /** Null-modem COM channels: input always empty, output discarded. */ - private final java.util.Set comChannels = new java.util.HashSet<>(); /** * A user TYPE layout: field order, per-field kind and byte offsets. @@ -401,66 +393,6 @@ public final class Interpreter { * 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; @@ -492,7 +424,7 @@ public final class Interpreter { /** 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() { @@ -995,54 +927,27 @@ public final class Interpreter { : ((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()); } @@ -1051,7 +956,8 @@ public final class Interpreter { 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); @@ -1108,13 +1014,13 @@ public final class Interpreter { 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 -> { @@ -1189,7 +1095,7 @@ public final class Interpreter { 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()); @@ -2055,238 +1961,20 @@ public final class Interpreter { 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()); } @@ -2294,7 +1982,7 @@ public final class Interpreter { /** 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())); } @@ -2345,7 +2033,7 @@ public final class Interpreter { 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 @@ -2372,7 +2060,7 @@ public final class Interpreter { 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()); } @@ -2414,7 +2102,7 @@ public final class Interpreter { 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); @@ -2444,7 +2132,7 @@ public final class Interpreter { 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, @@ -2461,23 +2149,7 @@ public final class Interpreter { 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) { @@ -2786,11 +2458,11 @@ public final class Interpreter { 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); @@ -2799,38 +2471,29 @@ public final class Interpreter { } 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 — @@ -2926,10 +2589,7 @@ public final class Interpreter { 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(); @@ -3441,281 +3101,35 @@ public final class Interpreter { } } - /** - * 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 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 diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/Channel.java b/src/main/java/eu/svjatoslav/crtbasic/io/Channel.java new file mode 100644 index 0000000..ac3d538 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/Channel.java @@ -0,0 +1,31 @@ +package eu.svjatoslav.crtbasic.io; + +/** + * State of one OPEN channel. Replaces the old design's nine parallel + * maps keyed by channel number: every per-channel fact lives here. + */ +final class Channel { + + enum Kind { TEXT_INPUT, TEXT_OUTPUT, BYTES, CONSOLE, COM } + + final int number; + Kind kind; + + // TEXT_INPUT + java.io.BufferedReader reader; + java.nio.file.Path path; // backing file: needed for the lazy byte switch + String lookahead; // EOF(n) peek — never consumes input + String remainder; // INPUT# item tail: several INPUT # share a line + + // TEXT_OUTPUT + java.io.BufferedWriter writer; + int column = 1; // print-zone column (14-column zones, wrap 255) + + // BYTES (BINARY/RANDOM, or a TEXT_INPUT channel switched by INPUT$/GET) + FileHandle handle; + + Channel(final int number, final Kind kind) { + this.number = number; + this.kind = kind; + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/ConsoleOutput.java b/src/main/java/eu/svjatoslav/crtbasic/io/ConsoleOutput.java new file mode 100644 index 0000000..f6d53f1 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/ConsoleOutput.java @@ -0,0 +1,12 @@ +package eu.svjatoslav.crtbasic.io; + +/** + * Console bridge for channel I/O that lands on the screen: OPEN "CON" + * FOR OUTPUT and a bare SHELL "DIR" (no redirect) write through this. + */ +public interface ConsoleOutput { + + void print(String text); + + void newLine(); +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/DosPaths.java b/src/main/java/eu/svjatoslav/crtbasic/io/DosPaths.java new file mode 100644 index 0000000..d49098a --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/DosPaths.java @@ -0,0 +1,85 @@ +package eu.svjatoslav.crtbasic.io; + +/** + * DOS-flavored path resolution for the emulated machine: backslash + * separators, drive letters dropped, case-insensitive component matching + * against real directory listings, and DOS-absolute paths ({@code \SUB\X}) + * anchored at the drive root — which in this sandbox is the .bas file's + * directory, not the host filesystem root. + */ +public final class DosPaths { + + private java.nio.file.Path baseDirectory = java.nio.file.Path.of(""); + private java.nio.file.Path currentDirectory; + + /** The directory of the running .bas file = the emulated drive root. */ + public void setBaseDirectory(final java.nio.file.Path directory) { + baseDirectory = directory; + currentDirectory = null; + } + + /** CHDIR: moves the current directory; the target must exist. */ + public void chdir(final String dosPath, final IoErrors errors, final int line) { + final java.nio.file.Path dir = resolve(dosPath); + if (!java.nio.file.Files.isDirectory(dir)) { + throw errors.error("Path not found", line); + } + currentDirectory = dir.normalize(); + } + + /** MKDIR: creates a directory; existing target is error 75 like real QB. */ + public void mkdir(final String dosPath, final IoErrors errors, final int line) { + final java.nio.file.Path dir = resolve(dosPath); + try { + java.nio.file.Files.createDirectory(dir); + } catch (final java.nio.file.FileAlreadyExistsException e) { + throw errors.error("Path/file access error", line); + } catch (final java.io.IOException e) { + throw errors.error("Path not found", line); + } + } + + /** + * Resolves a DOS path against the current directory. 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/}). + */ + public java.nio.file.Path resolve(final String dosPath) { + String normalized = dosPath.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("/"); + 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; + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/FileHandle.java b/src/main/java/eu/svjatoslav/crtbasic/io/FileHandle.java new file mode 100644 index 0000000..3962f38 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/FileHandle.java @@ -0,0 +1,97 @@ +package eu.svjatoslav.crtbasic.io; + +import java.util.Arrays; + +/** + * Byte-addressable file image for BINARY and RANDOM channels: the whole + * file is held in memory, writes grow it, and {@link #flush()} persists. + * RANDOM channels address records (1-based, recordLen bytes each), + * BINARY channels address bytes (1-based). + */ +public 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 + + public 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. */ + public void seek(final long position) { + pos = random ? (position - 1) * recordLen : position - 1; + } + + /** Current 1-based position as SEEK(n) reports it. */ + public long position() { + return random ? pos / recordLen + 1 : pos + 1; + } + + public 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; + } + + public 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. */ + public 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 IoException("File write error: " + e.getMessage()); + } + } + + public boolean atEnd() { + return pos >= length; + } + + public long length() { + return length; + } + + public long remaining() { + return Math.max(0, length - pos); + } + + public int recordLength() { + return recordLen; + } + + public boolean isRandom() { + return random; + } + + /** Failure inside the io layer before line context is attached. */ + public static final class IoException extends RuntimeException { + public IoException(final String message) { + super(message); + } + } +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/IoErrors.java b/src/main/java/eu/svjatoslav/crtbasic/io/IoErrors.java new file mode 100644 index 0000000..88f0424 --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/IoErrors.java @@ -0,0 +1,12 @@ +package eu.svjatoslav.crtbasic.io; + +/** + * How the io layer reports failures: the interpreter supplies the + * bridge so errors carry BASIC source-line context and trap through + * ON ERROR like any other runtime error. + */ +public interface IoErrors { + + /** Builds the runtime error for {@code message} raised at {@code line}. */ + RuntimeException error(String message, int line); +} diff --git a/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java b/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java new file mode 100644 index 0000000..32f87cb --- /dev/null +++ b/src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java @@ -0,0 +1,510 @@ +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"). + * + *

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 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 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); + } + } +} -- 2.20.1