refactor(io): extract channel I/O from Interpreter into an io package
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:49:00 +0000 (23:49 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Thu, 3 Sep 2026 20:49:00 +0000 (23:49 +0300)
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
src/main/java/eu/svjatoslav/crtbasic/Cp437.java
src/main/java/eu/svjatoslav/crtbasic/interp/Interpreter.java
src/main/java/eu/svjatoslav/crtbasic/io/Channel.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/ConsoleOutput.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/DosPaths.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/FileHandle.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/IoErrors.java [new file with mode: 0644]
src/main/java/eu/svjatoslav/crtbasic/io/IoSubsystem.java [new file with mode: 0644]

index a42e845..eadfbf4 100644 (file)
@@ -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 / =""= |
index 454922b..60508c3 100644 (file)
@@ -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;
+    }
 }
index 34193ec..94b982f 100644 (file)
@@ -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<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.
@@ -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<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.
@@ -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<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
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 (file)
index 0000000..ac3d538
--- /dev/null
@@ -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 (file)
index 0000000..f6d53f1
--- /dev/null
@@ -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 (file)
index 0000000..d49098a
--- /dev/null
@@ -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 (file)
index 0000000..3962f38
--- /dev/null
@@ -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 (file)
index 0000000..88f0424
--- /dev/null
@@ -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 (file)
index 0000000..32f87cb
--- /dev/null
@@ -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").
+ *
+ * <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);
+        }
+    }
+}