From be0630f8f1bc6b95297e6df1471663f959f117f9 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Tue, 9 Jun 2026 12:42:05 +0200 Subject: [PATCH 1/2] Add the Sqlite3Shell CLI driver as a product MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The argv parser plus the dot-command / REPL engine that reproduces the sqlite3 command-line shell (`.tables`, `.schema`, `.dump`, `.import`, `.mode`, `-safe`, ...). It is ArgumentParser-free and IO-agnostic — it reads, writes, and authorizes file paths through `ShellKit.Shell` — so a host can drive it in-process on any platform (Android included) and confine it to a sandbox. This is the shell layer that previously lived in SwiftPorts (Sources/SQLiteKit/Sqlite3Shell). Converging it here keeps the SDK and its shell driver in one repo and removes the drift / duplicate-symbol risk of a second copy (Cocoanetics/SwiftPorts#56). SwiftPorts now wraps this product in an ArgumentParser command to ship the `sqlite3` executable; SwiftBash registers it as a native builtin. - New `Sqlite3Shell` library product + target (deps: SQLiteKit, ShellKit core — no ArgumentParser, so no Android module-scanner cycle). - New ShellKit package dependency (pinned to main, like the others). SDK-only consumers of the `SQLiteKit` product never build this target or ShellKit into their link. - Sqlite3ShellTests moved here from SwiftPorts; runs on the full matrix (macOS / Linux / Windows / Android emulator), exercising the port via the ArgumentParser-free `Sqlite3Executable` entry point. - SwiftLint: the faithful CLI port (one dispatch switch, one REPL type) carries scoped `disable:next` directives; `file_length` is allowed as a file-wide disable since it can't be scoped. Verified on macOS: build + 107 tests (default and --traits FTS5,SQLiteVec), swiftlint --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .swiftlint.yml | 18 +- Package.swift | 40 + README.md | 30 + Sources/Sqlite3Shell/Parser.swift | 157 +++ Sources/Sqlite3Shell/Sqlite3Executable.swift | 1046 +++++++++++++++++ .../Sqlite3ExecutableTests.swift | 607 ++++++++++ 6 files changed, 1894 insertions(+), 4 deletions(-) create mode 100644 Sources/Sqlite3Shell/Parser.swift create mode 100644 Sources/Sqlite3Shell/Sqlite3Executable.swift create mode 100644 Tests/Sqlite3ShellTests/Sqlite3ExecutableTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index a04d4a8..276ee49 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,10 +2,13 @@ # local pre-commit hook, mirroring the Cocoanetics convention (ShellKit). # # A handful of default rules are tuned for what this package is: a thin SQLite -# C-interop SDK. Short names (`rc`, `db`, `op`, `i`) are the idiomatic spelling -# at the C boundary, and the cohesive `SQLiteDatabase` / `ResultFormatter` types -# read better whole than carved up to satisfy a line budget. Everything else is -# stock SwiftLint. +# C-interop SDK plus the `Sqlite3Shell` CLI port. Short names (`rc`, `db`, `op`, +# `i`) are the idiomatic spelling at the C boundary, and the cohesive +# `SQLiteDatabase` / `ResultFormatter` types read better whole than carved up to +# satisfy a line budget. The shell port (`Sources/Sqlite3Shell`) is a faithful +# 1:1 reproduction of the sqlite3 CLI — a long dispatch switch and one REPL type +# — so it carries scoped `swiftlint:disable:next` directives at the few oversized +# declarations. Everything else is stock SwiftLint. included: - Sources - Tests @@ -14,6 +17,13 @@ excluded: - .build - .swiftpm +blanket_disable_command: + # `file_length` is reported at end-of-file, so it cannot be scoped with + # `disable:next` like the other size rules. The shell port and its test suite + # disable it file-wide on purpose; allow that one rule to be blanket-disabled. + allowed_rules: + - file_length + identifier_name: # Allow the short, idiomatic names used at the C-interop boundary. min_length: diff --git a/Package.swift b/Package.swift index c6ead7a..cf73089 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,15 @@ let package = Package( // The SDK: a thin, pure-Swift wrapper over the vendored SQLite // amalgamation. FTS5 and sqlite-vec ride along behind opt-in traits. .library(name: "SQLiteKit", targets: ["SQLiteKit"]), + // The `sqlite3` shell driver — the argv parser plus the dot-command / + // REPL engine that reproduces the sqlite3 CLI. ArgumentParser-free and + // IO-agnostic (it reads / writes / authorizes paths through + // `ShellKit.Shell`), so a host can drive it in-process on any platform, + // Android included. The SwiftPorts `sqlite3` executable wraps it in an + // ArgumentParser command; SwiftBash registers it as a native builtin. + // Pulls in ShellKit — SDK-only consumers that depend on the `SQLiteKit` + // product never build this target (or ShellKit) into their link. + .library(name: "Sqlite3Shell", targets: ["Sqlite3Shell"]), ], // Opt-in, build-time engine toggles. Both off by default. // • depending on this package: .package(url: …, traits: ["FTS5", "SQLiteVec"]) @@ -35,6 +44,14 @@ let package = Package( .package(url: "https://github.com/stephencelis/CSQLite", exact: "3.50.4", traits: [.trait(name: "FTS5", condition: .when(traits: ["FTS5"]))]), + // Host runtime context for the `Sqlite3Shell` driver: the IO sinks it + // reads / writes through and the sandbox gate (`Shell.resolve` / + // `Shell.authorize`) every file-touching dot-command passes. Only the + // ArgumentParser-free `ShellKit` core product is used, so no + // ArgumentParser enters this package's graph. Pinned to `main` until + // ShellKit ships a tagged release. + .package(url: "https://github.com/Cocoanetics/ShellKit", + branch: "main"), ], targets: [ // Typed C wrappers for SQLite's variadic printf (`sqlite3_mprintf`), @@ -97,5 +114,28 @@ let package = Package( name: "SQLiteKitTests", dependencies: ["SQLiteKit"] ), + // The `sqlite3` shell driver: `Parser` (SQLite's single-dash long + // options, which ArgumentParser can't express) plus `Sqlite3Executable` + // / `Session` (the dot-command and REPL engine). Depends on the SDK and + // on ShellKit's core for IO + the sandbox gate; carries no + // ArgumentParser, so it builds on every platform (Android included). + .target( + name: "Sqlite3Shell", + dependencies: [ + "SQLiteKit", + .product(name: "ShellKit", package: "ShellKit"), + ] + ), + // Drives `Sqlite3Executable` directly (no ArgumentParser), so it builds + // and runs on every platform in the matrix — Android included — + // exercising the shell port on the emulator in CI. + .testTarget( + name: "Sqlite3ShellTests", + dependencies: [ + "Sqlite3Shell", + "SQLiteKit", + .product(name: "ShellKit", package: "ShellKit"), + ] + ), ] ) diff --git a/README.md b/README.md index a5c7dcc..964da01 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,36 @@ recompiles the engine, so they are opt-in. - **`SQLiteRow` / `ResultSet`** — typed rows, subscriptable by index and column name. - **`ResultFormatter`** — sqlite3-compatible rendering. +## The `sqlite3` shell driver + +The package also vends a **`Sqlite3Shell`** product — the argv parser plus the +dot-command / REPL engine that reproduces the `sqlite3` command-line shell +(`.tables`, `.schema`, `.dump`, `.import`, `.mode`, `-safe`, …). It is +ArgumentParser-free and IO-agnostic: it reads, writes, and authorizes file paths +through [`ShellKit.Shell`](https://github.com/Cocoanetics/ShellKit), so a host +can drive it in-process on any platform — Android included — and confine it to a +sandbox. SDK-only consumers that depend on the `SQLiteKit` product never build +this target (or ShellKit) into their link. + +```swift +.product(name: "Sqlite3Shell", package: "SQLiteKit"), +``` + +```swift +import Sqlite3Shell +import ShellKit + +let code = try await Sqlite3Executable.run( + argv: ["mydb.sqlite", "SELECT * FROM users;"], + stdin: Shell.current.stdin, + stdout: Shell.current.stdout, + stderr: Shell.current.stderr) +``` + +[Cocoanetics/SwiftPorts](https://github.com/Cocoanetics/SwiftPorts) wraps this +driver in an ArgumentParser command to ship the `sqlite3` executable, and +SwiftBash registers it as a native builtin. + ## Platforms macOS 13+, iOS 16+, tvOS 16+, watchOS 9+, visionOS 1+, Linux, Android, Windows. diff --git a/Sources/Sqlite3Shell/Parser.swift b/Sources/Sqlite3Shell/Parser.swift new file mode 100644 index 0000000..e9c392a --- /dev/null +++ b/Sources/Sqlite3Shell/Parser.swift @@ -0,0 +1,157 @@ +import Foundation +import SQLiteKit + +/// Hand-rolled argv parser for the `sqlite3` CLI. SQLite uses single-dash +/// long options (`-csv`, `-header`, `-separator X`), which ArgumentParser +/// can't express, so — like the `rg` / `fd` ports — we parse argv directly. +enum Parser { + struct Options { + var databasePath: String? + var sql: [String] = [] + var mode: OutputMode = .list + var showHeader = false + var headerExplicit = false + var separator = "|" + var nullValue = "" + var readonly = false + var interactive = false + var echo = false + var bail = false + var safe = false + var initFile: String? + var commands: [String] = [] + var special: Special = .none + } + + enum Special { case none, help, version } + + struct ArgError: Error { let message: String } + + // SQLite's single-dash long options are parsed by one flat switch over argv, + // inherently a long, high-branch function. + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func parse(_ argv: [String]) throws -> Options { + var options = Options() + var positionals: [String] = [] + var i = 0 + + func value(for flag: String) throws -> String { + guard i + 1 < argv.count else { + throw ArgError(message: "option requires an argument: \(flag)") + } + i += 1 + return argv[i] + } + + while i < argv.count { + let arg = argv[i] + switch arg { + case "-help", "--help", "-?": options.special = .help + case "-version", "--version": options.special = .version + case "-csv": options.mode = .csv + case "-json": options.mode = .json + case "-line": options.mode = .line + case "-column": options.mode = .column + case "-list": options.mode = .list + case "-tabs": options.mode = .tabs + case "-ascii": options.mode = .ascii + case "-html": options.mode = .html + case "-markdown": options.mode = .markdown + case "-table": options.mode = .table + case "-box": options.mode = .box + case "-quote": options.mode = .quote + case "-header", "-headers": options.showHeader = true; options.headerExplicit = true + case "-noheader", "-noheaders": options.showHeader = false; options.headerExplicit = true + case "-readonly": options.readonly = true + case "-batch": options.interactive = false + case "-interactive": options.interactive = true + case "-echo": options.echo = true + case "-bail": options.bail = true + case "-safe": options.safe = true + case "-separator": options.separator = try value(for: arg) + case "-nullvalue": options.nullValue = try value(for: arg) + case "-init": options.initFile = try value(for: arg) + case "-cmd": options.commands.append(try value(for: arg)) + default: + if arg.hasPrefix("-") && arg.count > 1 { + throw ArgError(message: "unknown option: \(arg)") + } + positionals.append(arg) + } + i += 1 + } + + if let first = positionals.first { + options.databasePath = first + options.sql = Array(positionals.dropFirst()) + } + return options + } + + static let helpText = """ + Usage: sqlite3 [OPTIONS] FILENAME [SQL] + + FILENAME is the SQLite database to open. Omit it (or use ":memory:") + for a transient in-memory database. A trailing SQL argument runs and + then exits; otherwise SQL is read from standard input. + + OPTIONS: + -version show the SQLite library version and exit + -help show this message and exit + -readonly open the database read-only + -init FILE run FILE before reading the main input + -cmd COMMAND run COMMAND before reading the main input + -echo print each statement before running it + -bail stop after the first error + -batch non-interactive mode + -interactive interactive mode (prompts; SQL run line-by-line) + -safe refuse dot-commands that touch the filesystem/shell + + -list values separated by .separator (default) + -csv comma-separated values + -tabs tab-separated values + -ascii 0x1F/0x1E separated values + -column left-aligned columns + -markdown Markdown table + -table ASCII-art table + -box Unicode box-drawing table + -line one value per line + -json JSON array of objects + -html HTML / rows + -quote SQL-literal values + -header / -noheader show or hide column headers + -separator SEP field separator for -list mode (default "|") + -nullvalue STR text to print for NULL values (default "") + + Dot-commands (at a statement boundary): + .tables [PATTERN] list tables and views + .schema [TABLE] show CREATE statements + .databases list attached databases + .indexes [TABLE] list indexes + .mode MODE [TABLE] set output mode (list/csv/tabs/ascii/column/ + markdown/table/box/line/json/html/quote/insert) + .headers on|off show or hide headers + .separator SEP set the -list separator + .nullvalue STR set the NULL placeholder + .width N1 N2 ... set column widths (negative right-justifies, 0 auto) + .limit [NAME [VAL]] show or set run-time limits + .dump [TABLE] dump the database (or one table) as SQL + .fullschema show the schema plus the ANALYZE (stat) tables + .echo on|off echo each statement before running it + .bail on|off stop after an error + .changes on|off report changed-row counts after each statement + .eqp on|off print the query plan before each statement + .print TEXT... print TEXT + .import FILE TABLE import delimited FILE into TABLE + .output [FILE] send output to FILE (stdout if omitted) + .once FILE send the next command's output to FILE + .read FILE run SQL from FILE + .open FILE close the current database and open FILE + .backup [DB] FILE back up the database to FILE + .restore [DB] FILE restore the database from FILE + .show show current settings + .help show this message + .quit / .exit exit + + """ +} diff --git a/Sources/Sqlite3Shell/Sqlite3Executable.swift b/Sources/Sqlite3Shell/Sqlite3Executable.swift new file mode 100644 index 0000000..b6e770e --- /dev/null +++ b/Sources/Sqlite3Shell/Sqlite3Executable.swift @@ -0,0 +1,1046 @@ +import Foundation +import ShellKit +import SQLiteKit + +// A faithful in-process port of the sqlite3 CLI shell. `handleDot` is a single +// dispatch switch over the ~30 dot-commands and `Session` is one cohesive REPL +// type; splitting either to satisfy per-function / per-type budgets would only +// blur the 1:1 mapping to sqlite3's behavior. The oversized declarations carry +// scoped `disable:next` directives below; `file_length` is the one size rule +// that can't be scoped that way (it's reported at EOF), so it's disabled +// file-wide here — the SDK proper stays strict. +// swiftlint:disable file_length + +/// Argv-level entry point for the `sqlite3` CLI. Returns the process exit +/// code. Kept in its own enum — mirroring the other ports — so embedders +/// can drive the CLI behavior in-process. +public enum Sqlite3Executable { + + @discardableResult + // swiftlint:disable:next cyclomatic_complexity function_body_length + public static func run(argv: [String], + stdin: InputSource, + stdout: OutputSink, + stderr: OutputSink) async throws -> Int32 { + let options: Parser.Options + do { + options = try Parser.parse(argv) + } catch let error as Parser.ArgError { + stderr.write("sqlite3: Error: \(error.message)\n") + return 1 + } + + switch options.special { + case .help: + stdout.write(Parser.helpText) + return 0 + case .version: + stdout.write("\(SQLiteDatabase.libVersion) \(SQLiteDatabase.sourceID) (64-bit)\n") + return 0 + case .none: + break + } + + // Resolve + authorize the database file through ShellKit so the + // tool honors the host's sandbox / path mapping. A missing name or + // ":memory:" means a transient in-memory database. + let location: SQLiteDatabase.Location + if let path = options.databasePath, path != ":memory:", !path.isEmpty { + let url = Shell.resolve(path) + do { + try await Shell.authorize(url) + } catch { + stderr.write("sqlite3: Error: \(error)\n") + return 1 + } + location = .file(url.path) + } else { + location = .memory + } + + let database: SQLiteDatabase + do { + database = try SQLiteDatabase(location, readonly: options.readonly) + } catch let error as SQLiteError { + stderr.write("sqlite3: Error: \(error.message)\n") + return 1 + } + // -safe also gates SQL-level filesystem access (ATTACH / load_extension) + // via an authorizer, not just the file-touching dot-commands. + if options.safe { database.enableSafeMode() } + + // None of the columnar command-line flags flip the headers setting: + // -box/-table/-markdown render a header regardless of it, and -column + // leaves it off (matching sqlite3 — only the `.mode column` + // dot-command turns the setting on). + let showHeader = options.showHeader + + // `.show`/`.open` display the database name as the user typed it + // (sqlite3's zDbFilename), not the sandbox-resolved host path — which + // also keeps host paths out of a sandboxed shell's output. + let filename: String = { + if let p = options.databasePath, p != ":memory:", !p.isEmpty { return p } + return ":memory:" + }() + let session = Session( + database: database, + formatter: ResultFormatter(mode: options.mode, + showHeader: showHeader, + separator: options.separator, + nullValue: options.nullValue), + stdout: stdout, + stderr: stderr, + interactive: options.interactive, + headerExplicit: options.headerExplicit, + echo: options.echo, + bail: options.bail, + safeMode: options.safe, + filename: filename) + + // -init FILE, then any -cmd commands, before the main input. + if let initFile = options.initFile { + await session.runScript(path: initFile) + } + for command in options.commands where !session.shouldQuit { + _ = await session.process(command, context: .inline) + } + + // A trailing SQL argument runs and exits; otherwise read stdin. + if !options.sql.isEmpty { + for statement in options.sql where !session.shouldQuit { + if await session.process(statement, context: .inline) == false { break } + } + } else if !session.shouldQuit { + if options.interactive { + await session.runInteractive(stdin: stdin) + } else { + let input = await stdin.readAllString() + _ = await session.process(input, context: .script) + } + } + + session.finishOutput() + return session.exitCode + } +} + +// swiftlint:disable type_body_length +/// Holds the mutable shell state (current database, output formatter) and +/// drives statement / dot-command execution. One instance per invocation. +final class Session { + /// Where the current input came from. SQLite formats errors (and sets + /// exit codes) differently for a command-line argument, a script, and + /// the interactive REPL. + enum SourceContext { case inline, script, interactive } + + private var database: SQLiteDatabase + private var formatter: ResultFormatter + /// The database filename as opened (":memory:" or a path), shown by `.show`. + private var filename: String + private let stdout: OutputSink + private let stderr: OutputSink + private let interactive: Bool + /// Whether the user pinned headers via `-header`/`-noheader`/`.headers`. + /// Until they do, `.mode column` turns headers on (matching sqlite3). + private var headerExplicit: Bool + private var echo: Bool + private var bail: Bool + /// `-safe` mode: refuse dot-commands that touch the filesystem or shell. + private let safeMode: Bool + /// Input line number of the dot-command currently dispatching, for the + /// `-safe` refusal message (0 for a command-line argument). + private var safeLine = 0 + private var changesMode = false + /// When on, print the EXPLAIN QUERY PLAN tree before each statement. + private var eqp = false + /// When set, result output is buffered to a file instead of stdout + /// (`.output` / `.once`). + private var redirect: Redirect? + + private struct Redirect { let url: URL; var buffer: String; let once: Bool } + + private(set) var shouldQuit = false + private(set) var exitCode: Int32 = 0 + private var buffer = "" + + init(database: SQLiteDatabase, + formatter: ResultFormatter, + stdout: OutputSink, + stderr: OutputSink, + interactive: Bool, + headerExplicit: Bool, + echo: Bool, + bail: Bool, + safeMode: Bool = false, + filename: String) { + self.database = database + self.formatter = formatter + self.filename = filename + self.stdout = stdout + self.stderr = stderr + self.interactive = interactive + self.headerExplicit = headerExplicit + self.echo = echo + self.bail = bail + self.safeMode = safeMode + } + + private func out(_ s: String) { + if redirect != nil { redirect!.buffer += s } else { stdout.write(s) } + } + private func err(_ s: String) { stderr.write(s) } + + /// Flushes the current `.output`/`.once` redirect to its file and + /// reverts to stdout. Called at end of input too. + func finishOutput() { + guard let active = redirect else { return } + redirect = nil + do { + try active.buffer.write(to: active.url, atomically: true, encoding: .utf8) + } catch { + err("Error: unable to write \"\(active.url.path)\"\n") + exitCode = 1 + } + } + + /// Processes a chunk of input (stdin, a SQL argument, a -cmd string, or + /// a script file), tracking line numbers for error reporting. Returns + /// `false` when a SQL error should stop a non-interactive run. + @discardableResult + func process(_ text: String, context: SourceContext) async -> Bool { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var lineNo = 0 + var statementStart = 1 + // Command-line SQL bails on the first error; a script keeps going + // unless `.bail on` is set (matching sqlite3). + func stopsOnError() -> Bool { context == .inline || bail } + for line in lines { + if shouldQuit { return true } + lineNo += 1 + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Dot-commands are only recognized at a statement boundary. + if buffer.isEmpty && trimmed.hasPrefix(".") { + if echo { out(trimmed + "\n") } + // sqlite3 reports a command-line argument as "line 0". + safeLine = context == .inline ? 0 : lineNo + await handleDot(trimmed) + continue + } + if buffer.isEmpty { statementStart = lineNo } + buffer += line + "\n" + if SQLiteDatabase.isCompleteStatement(buffer) { + let sql = buffer + buffer = "" + if !(await runStatement(sql, startLine: statementStart, context: context)) && stopsOnError() { + return false + } + } + } + // SQLite runs a final statement even without a trailing semicolon. + let leftover = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + buffer = "" + if !leftover.isEmpty { + if !(await runStatement(leftover, startLine: statementStart, context: context)) && stopsOnError() { + return false + } + } + return true + } + + /// The startup banner sqlite3 prints when entering interactive mode: + /// the library version plus the date/time prefix of the source id. + static var banner: String { + "SQLite version \(SQLiteDatabase.libVersion) \(String(SQLiteDatabase.sourceID.prefix(19)))\n" + + "Enter \".help\" for usage hints.\n" + } + + /// The line-buffered interactive REPL (`-interactive`): a startup + /// banner, then `sqlite> ` / ` ...> ` prompts until `.quit` or EOF. + /// + /// Triggered by the explicit flag rather than auto-detecting a TTY — + /// an embedded builtin doesn't own the terminal, so interactivity is + /// the host's call. A SIGINT→`sqlite3_interrupt` handler is likewise + /// left to the host: installing a process-global signal handler from a + /// library would be wrong. + func runInteractive(stdin: InputSource) async { + out(Self.banner) + var lineNo = 0 + var statementStart = 1 + while !shouldQuit { + // The continuation prompt shows while a statement is still open. + out(buffer.isEmpty ? "sqlite> " : " ...> ") + guard let line = await stdin.readLine() else { out("\n"); break } + lineNo += 1 + let trimmed = line.trimmingCharacters(in: .whitespaces) + // Dot-commands are recognized only at a statement boundary. + if buffer.isEmpty && trimmed.hasPrefix(".") { + if echo { out(trimmed + "\n") } + safeLine = lineNo + await handleDot(trimmed) + continue + } + if buffer.isEmpty { statementStart = lineNo } + buffer += line + "\n" + if SQLiteDatabase.isCompleteStatement(buffer) { + let sql = buffer + buffer = "" + _ = await runStatement(sql, startLine: statementStart, context: .interactive) + } + } + // Run a trailing statement left unterminated at EOF, like sqlite3. + let leftover = buffer.trimmingCharacters(in: .whitespacesAndNewlines) + buffer = "" + if !leftover.isEmpty { + _ = await runStatement(leftover, startLine: statementStart, context: .interactive) + } + } + + /// Runs one chunk of SQL and renders any result sets. Returns `false` + /// on error (after reporting it). + @discardableResult + // swiftlint:disable:next cyclomatic_complexity + private func runStatement(_ sql: String, startLine: Int, context: SourceContext) async -> Bool { + if echo { out(sql.trimmingCharacters(in: .whitespacesAndNewlines) + "\n") } + if eqp { renderQueryPlan(sql) } + // Gate any ATTACH'd file through the host sandbox before SQLite opens + // it — the same resolve/authorize path the db file and `.read` / + // `.open` take. (`-safe` blocks ATTACH outright via its authorizer, + // so this is the non-safe confinement path; `:memory:` / temp ATTACHes + // touch no file and are skipped.) + if !safeMode, sql.range(of: "attach", options: .caseInsensitive) != nil { + for target in database.attachTargets(in: sql) + where !target.isEmpty && target != ":memory:" { + do { + try await Shell.authorize(Shell.resolve(target)) + } catch { + err("Error: \(error)\n") + exitCode = 1 + return false + } + } + } + do { + for set in try database.evaluate(sql) { + out(formatter.render(set)) + } + if changesMode { + out("changes: \(database.changes) total_changes: \(database.totalChanges)\n") + } + if redirect?.once == true { finishOutput() } + return true + } catch let error as SQLiteError { + // A `-safe` authorizer denial surfaces as a generic SQLITE_AUTH + // error; replace it with sqlite3's safe-mode message and halt + // (line 0 for a command-line argument). + if let violation = database.safeModeViolation { + database.clearSafeModeViolation() + err("line \(context == .inline ? 0 : startLine): \(violation)\n") + exitCode = 1 + shouldQuit = true + return false + } + report(error, sql: sql, startLine: startLine, context: context) + return false + } catch { + err("Error: \(error)\n") + exitCode = 1 + return false + } + } + + /// Reproduces sqlite3's error reporting: script input gets + /// `Parse/Runtime error near line N:` (exit 1); a command-line argument + /// gets `Error: in prepare,/stepping,` (exit = SQLite result code). + /// Both append a caret pointer when SQLite reports an error offset, and + /// runtime errors append the result code. + private func report(_ error: SQLiteError, sql: String, startLine: Int, context: SourceContext) { + let header: String + switch context { + case .script: + let line = errorLine(start: startLine, sql: sql, offset: error.offset) + header = (error.phase == .prepare ? "Parse error" : "Runtime error") + " near line \(line): " + exitCode = 1 + case .inline: + header = error.phase == .prepare ? "Error: in prepare, " : "Error: stepping, " + exitCode = error.code + case .interactive: + // The REPL keeps going on error (no exit code) and omits the + // line number that script context carries. + header = (error.phase == .prepare ? "Parse error: " : "Runtime error: ") + } + var message = error.message + if error.phase == .step { message += " (\(error.code))" } + err(header + message + "\n") + if error.offset >= 0 { + err(caretBlock(sql: sql, offset: Int(error.offset))) + } + } + + private func errorLine(start: Int, sql: String, offset: Int32) -> Int { + guard offset >= 0 else { return start } + let newlines = sql.utf8.prefix(Int(offset)).reduce(0) { $0 + ($1 == 0x0a ? 1 : 0) } + return start + newlines + } + + /// Builds the ` \n ^--- error here\n` block that + /// sqlite3 prints under a failing statement. + private func caretBlock(sql: String, offset: Int) -> String { + let bytes = Array(sql.utf8) + let position = min(max(offset, 0), bytes.count) + var lineStart = position + while lineStart > 0 && bytes[lineStart - 1] != 0x0a { lineStart -= 1 } + var lineEnd = position + while lineEnd < bytes.count && bytes[lineEnd] != 0x0a { lineEnd += 1 } + // swiftlint:disable:next optional_data_string_conversion + let line = String(decoding: bytes[lineStart.. 3 ? (row[3].cliText ?? "") : "") + } + var output = "QUERY PLAN\n" + func level(_ parent: Int, _ prefix: String) { + let children = nodes.filter { $0.parent == parent } + for (i, node) in children.enumerated() { + let last = i == children.count - 1 + output += prefix + (last ? "`--" : "|--") + node.detail + "\n" + level(node.id, prefix + (last ? " " : "| ")) + } + } + level(0, "") + out(output) + } + + // MARK: Dot-commands + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func handleDot(_ line: String) async { + let tokens = Self.tokenize(line) + guard let command = tokens.first else { return } + let args = Array(tokens.dropFirst()) + + // `-safe` refuses any dot-command that could touch the filesystem or + // shell, and aborts — matching sqlite3. (SQL-level restrictions like + // ATTACH / load_extension would need an authorizer and are tracked + // separately.) + if safeMode, let message = Self.safeModeBlock(command, args) { + err("line \(safeLine): \(message)\n") + exitCode = 1 + shouldQuit = true + return + } + + switch command { + case ".quit", ".exit": + shouldQuit = true + + case ".help": + out(Parser.helpText) + + case ".tables": + introspect { + var names = try database.tableNames() + if let pattern = args.first { + names = names.filter { Self.glob(pattern, matches: $0) } + } + if !names.isEmpty { out(Self.columnize(names)) } + } + + case ".schema": + introspect { + // Filter on tbl_name so `.schema foo` also returns foo's + // indexes/triggers (matching sqlite3). Views get a trailing + // `/* name(cols) */` comment listing their result columns. + let filter = args.first.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? "" + let rows = try database.evaluate(""" + SELECT type, name, sql FROM sqlite_schema + WHERE sql NOT NULL\(filter) ORDER BY rowid; + """).first?.rows ?? [] + var lines: [String] = [] + for r in rows { + guard case .text(let type) = r[0], case .text(let name) = r[1], + case .text(let sql) = r[2] else { continue } + // sqlite3 appends the /* view(cols) */ comment only when the + // view's columns resolve; an unpreparable view (e.g. one + // referencing a missing table) prints just its stored CREATE. + if type == "view", + let cols = (try? database.evaluate( + "SELECT * FROM \(SQLiteDatabase.quoteIdentifier(name)) LIMIT 0;"))?.first?.columns, + !cols.isEmpty { + let list = cols.map { SQLiteDatabase.quoteIdentifier($0) }.joined(separator: ",") + lines.append("\(sql)\n/* \(SQLiteDatabase.quoteIdentifier(name))(\(list)) */;") + } else { + lines.append(sql + ";") + } + } + if !lines.isEmpty { out(lines.joined(separator: "\n") + "\n") } + } + + case ".fullschema": + introspect { + // The schema as plain statements (no view comments), then — + // if ANALYZE has run — the sqlite_stat[134] contents as + // INSERTs bracketed by `ANALYZE sqlite_schema;` markers, + // exactly like sqlite3's `.fullschema`. + let schema = try database.evaluate(""" + SELECT sql FROM ( + SELECT sql, type, name, rowid AS x FROM sqlite_schema UNION ALL + SELECT sql, type, name, rowid FROM sqlite_temp_schema) + WHERE type != 'meta' AND sql NOT NULL AND name NOT LIKE 'sqlite_%' + ORDER BY x; + """).first?.rows.compactMap { $0.first?.cliText } ?? [] + for sql in schema { out(sql + ";\n") } + let hasStats = try !(database.evaluate( + "SELECT 1 FROM sqlite_schema WHERE name GLOB 'sqlite_stat[134]' LIMIT 1;") + .first?.rows.isEmpty ?? true) + guard hasStats else { out("/* No STAT tables available */\n"); return } + out("ANALYZE sqlite_schema;\n") + for statTable in ["sqlite_stat1", "sqlite_stat4"] where try tableExists(statTable) { + let rows = try database.evaluate("SELECT * FROM \(statTable);").first?.rows ?? [] + for row in rows { + out("INSERT INTO \(statTable) VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n") + } + } + out("ANALYZE sqlite_schema;\n") + } + + case ".databases": + introspect { + // sqlite3 prints: : <"" if no file else path> + let lines = try database.databaseList().map { db -> String in + let file = db.file.isEmpty ? "\"\"" : db.file + return "\(db.name): \(file) \(database.isReadOnly(db.name) ? "r/o" : "r/w")" + } + if !lines.isEmpty { out(lines.joined(separator: "\n") + "\n") } + } + + case ".indexes", ".indices": + introspect { + let filter = args.first.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? "" + let sql = "SELECT name FROM sqlite_schema WHERE type='index'\(filter) ORDER BY name;" + let names = try database.evaluate(sql).first?.rows.compactMap { $0.first?.cliText } ?? [] + if !names.isEmpty { out(Self.columnize(names)) } + } + + case ".dump": + introspect { + out("PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n") + let only = args.first + let tableFilter = only.map { " AND name = '\(SQLiteDatabase.quote($0))'" } ?? "" + // Each table: its CREATE statement, then its rows as INSERTs. + let tables = try database.evaluate(""" + SELECT name, sql FROM sqlite_schema + WHERE type='table' AND name NOT LIKE 'sqlite_%' AND sql NOT NULL\(tableFilter) + ORDER BY rowid; + """).first?.rows ?? [] + for t in tables { + guard case .text(let name) = t[0], case .text(let createSQL) = t[1] else { continue } + out(createSQL + ";\n") + // Quote the table name the way sqlite3 does — bare for a + // simple identifier, double-quoted otherwise — so both the + // read-back and the emitted INSERTs stay valid for any name. + let ident = SQLiteDatabase.quoteIdentifier(name) + // SELECT only the non-generated columns so the emitted + // INSERT replays (sqlite3 omits VIRTUAL/STORED generated + // columns — their values can't be inserted). The INSERT + // itself stays column-list-free, exactly like sqlite3. + let cols = try database.nonGeneratedColumns(of: name) + let selectList = cols.isEmpty + ? "*" + : cols.map { SQLiteDatabase.quoteIdentifier($0) }.joined(separator: ",") + let rows = try database.evaluate("SELECT \(selectList) FROM \(ident);").first?.rows ?? [] + for row in rows { + out("INSERT INTO \(ident) VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n") + } + } + // AUTOINCREMENT high-water marks live in the internal + // sqlite_sequence table. sqlite3 re-emits its rows (no CREATE — + // it is created implicitly by the first AUTOINCREMENT table) + // after all table data and before views/triggers/indexes, and + // only for a full dump. + if only == nil, try tableExists("sqlite_sequence") { + let seq = try database.evaluate( + "SELECT name, seq FROM sqlite_sequence ORDER BY rowid;").first?.rows ?? [] + for row in seq { + out("INSERT INTO sqlite_sequence VALUES(\(row.map(\.sqlLiteral).joined(separator: ",")));\n") + } + } + // Then views + triggers, then indexes last (sqlite3's order). + let objFilter = only.map { " AND tbl_name = '\(SQLiteDatabase.quote($0))'" } ?? "" + for types in ["'view','trigger'", "'index'"] { + let sqls = try database.evaluate(""" + SELECT sql FROM sqlite_schema + WHERE type IN (\(types)) AND sql NOT NULL\(objFilter) ORDER BY rowid; + """).first?.rows.compactMap { $0.first?.cliText } ?? [] + for sql in sqls { out(sql + ";\n") } + } + out("COMMIT;\n") + } + + case ".mode": + guard let raw = args.first, let mode = OutputMode(rawValue: raw) else { + err("Error: .mode expects one of: \(OutputMode.allCases.map(\.rawValue).joined(separator: ", "))\n") + return + } + formatter.mode = mode + // The `.mode csv` dot-command uses CRLF row terminators; every + // other mode (and the `-csv` command-line flag) uses LF. This is + // sqlite3's one genuine flag-vs-dot-command divergence. + formatter.rowSeparator = mode == .csv ? "\r\n" : "\n" + // `.mode insert [TABLE]` carries an optional destination table. + if mode == .insert { formatter.insertTable = args.count > 1 ? args[1] : nil } + // Only `.mode column` flips the headers *setting* on; box / table / + // markdown always *display* a header (their renderers don't gate on + // it) but leave the setting untouched, which `.show` reflects. + // Matches sqlite3's `.mode` dot-command. + if !headerExplicit, mode == .column { + formatter.showHeader = true + } + + case ".headers", ".header": + guard let value = args.first else { + err("Error: .headers expects on or off\n") + return + } + formatter.showHeader = ["on", "1", "yes", "true"].contains(value.lowercased()) + headerExplicit = true + + case ".separator": + guard let value = args.first else { + err("Error: .separator expects a value\n") + return + } + formatter.separator = value + + case ".nullvalue": + formatter.nullValue = args.first ?? "" + + case ".width", ".widths": + // `.width N1 N2 …` sets per-column display widths for the + // column-family modes (negative = right-justify, 0 = auto); + // `.width` with no args clears them. Non-numeric args read as 0, + // matching sqlite3's atoi-style parse. + formatter.widths = args.map { Int($0) ?? 0 } + + case ".limit": + handleLimit(args) + + case ".echo": + if let value = Self.onOff(args.first) { echo = value } + + case ".bail": + if let value = Self.onOff(args.first) { bail = value } + + case ".changes": + if let value = Self.onOff(args.first) { changesMode = value } + + case ".eqp": + // "full" also dumps bytecode in sqlite3; we render the plan tree. + if args.first?.lowercased() == "full" { eqp = true } else if let value = Self.onOff(args.first) { eqp = value } + + case ".print": + out(args.joined(separator: " ") + "\n") + + case ".output": + finishOutput() + if let path = args.first { + guard let url = await resolveAuthorized(path) else { return } + redirect = Redirect(url: url, buffer: "", once: false) + } + + case ".once": + guard let path = args.first else { + err("Error: .once expects a filename\n") + return + } + finishOutput() + guard let url = await resolveAuthorized(path) else { return } + redirect = Redirect(url: url, buffer: "", once: true) + + case ".import": + guard args.count >= 2 else { + err("Error: .import expects FILE and TABLE\n") + return + } + guard let url = await resolveAuthorized(args[0]) else { return } + let text: String + do { + text = try String(contentsOf: url, encoding: .utf8) + } catch { + err("Error: cannot open \"\(args[0])\"\n") + exitCode = 1 + return + } + importDelimited(text, into: args[1]) + + case ".backup": + guard let (dbName, path) = Self.dbAndFile(args) else { + err("Error: .backup expects ?DB? FILE\n") + return + } + guard let url = await resolveAuthorized(path) else { return } + do { + let destination = try SQLiteDatabase(.file(url.path)) + defer { destination.close() } + try database.backup(to: destination, sourceName: dbName) + } catch let error as SQLiteError { + err("Error: \(error.message)\n") + exitCode = 1 + } catch { + err("Error: \(error)\n") + exitCode = 1 + } + + case ".restore": + guard let (dbName, path) = Self.dbAndFile(args) else { + err("Error: .restore expects ?DB? FILE\n") + return + } + guard let url = await resolveAuthorized(path) else { return } + do { + let source = try SQLiteDatabase(.file(url.path)) + defer { source.close() } + try source.backup(to: database, destinationName: dbName) + } catch let error as SQLiteError { + err("Error: \(error.message)\n") + exitCode = 1 + } catch { + err("Error: \(error)\n") + exitCode = 1 + } + + case ".show": + // Mirrors sqlite3's .show: 12 labels right-justified to width 12. + // explain / stats / rowseparator / width aren't modeled yet, so + // they show sqlite3's defaults (matches the common no-.width case). + func showEscape(_ s: String) -> String { + s.replacingOccurrences(of: "\t", with: "\\t") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + } + // sqlite3 derives the separators from the active mode: csv → , / \r\n, + // tabs → \t, ascii → \037 / \036, quote → , ; other modes use the + // configured list separator. (A `.separator` override issued *after* + // a mode change isn't tracked separately here — a rare edge.) + let colsep: String, rowsep: String + switch formatter.mode { + // csv reports its actual row terminator (CRLF via `.mode csv`, + // LF via the `-csv` flag) — see formatter.rowSeparator. + case .csv: colsep = ","; rowsep = showEscape(formatter.rowSeparator) + case .tabs: colsep = "\\t"; rowsep = "\\n" + case .ascii: colsep = "\\037"; rowsep = "\\036" + case .quote: colsep = ","; rowsep = "\\n" + default: colsep = showEscape(formatter.separator); rowsep = "\\n" + } + // sqlite3 reports `tabs` as its underlying `list` mode, and the + // column-family modes append their wrap/wordwrap/quote options. + // We don't expose those knobs yet, so they print sqlite3's + // defaults (--wrap 60 --wordwrap off --noquote). + let modeBase = formatter.mode == .tabs ? "list" : formatter.mode.rawValue + let columnFamily: Set = [.column, .box, .table, .markdown] + let modeField = columnFamily.contains(formatter.mode) + ? "\(modeBase) --wrap 60 --wordwrap off --noquote" + : modeBase + let fields: [(String, String)] = [ + ("echo", echo ? "on" : "off"), + ("eqp", eqp ? "on" : "off"), + ("explain", "auto"), + ("headers", formatter.showHeader ? "on" : "off"), + ("mode", modeField), + ("nullvalue", "\"\(showEscape(formatter.nullValue))\""), + ("output", redirect?.url.path ?? "stdout"), + ("colseparator", "\"\(colsep)\""), + ("rowseparator", "\"\(rowsep)\""), + ("stats", "off"), + // sqlite3 prints each configured width followed by a space. + ("width", formatter.widths.map { "\($0) " }.joined()), + ("filename", filename) + ] + let body = fields.map { label, value in + String(repeating: " ", count: max(0, 12 - label.count)) + label + ": " + value + }.joined(separator: "\n") + out(body + "\n") + + case ".open": + guard let path = args.first else { + err("Error: .open expects a filename\n") + return + } + // ":memory:" / an empty name opens a fresh in-memory database + // (matching the command-line argument and real sqlite3) — it is + // never resolved to a path, so `.open :memory:` can't create a + // stray `:memory:` file on disk. + let location: SQLiteDatabase.Location + if path != ":memory:", !path.isEmpty { + guard let url = await resolveAuthorized(path) else { return } + location = .file(url.path) + } else { + location = .memory + } + do { + let replacement = try SQLiteDatabase(location) + database.close() + database = replacement + if safeMode { database.enableSafeMode() } // re-arm on the new connection + filename = path // as-typed, matching sqlite3's `.show` + } catch let error as SQLiteError { + err("Error: \(error.message)\n") + } catch { + err("Error: \(error)\n") + } + + case ".read": + guard let path = args.first else { + err("Error: .read expects a filename\n") + return + } + await runScript(path: path) + + default: + err("Error: unknown command or invalid arguments: \"\(command.dropFirst())\". Enter \".help\" for help\n") + } + } + + /// Reads a SQL/dot-command script through ShellKit's sandbox gate and + /// runs it. + func runScript(path: String) async { + guard let url = await resolveAuthorized(path) else { exitCode = 1; return } + do { + let text = try String(contentsOf: url, encoding: .utf8) + _ = await process(text, context: .script) + } catch { + err("Error: cannot open \"\(path)\"\n") + exitCode = 1 + } + } + + /// Converts a user-supplied path to a sandbox URL and asks the host to + /// authorize it. Returns `nil` (after reporting) if denied. + private func resolveAuthorized(_ path: String) async -> URL? { + let url = Shell.resolve(path) + do { + try await Shell.authorize(url) + return url + } catch { + err("Error: \(error)\n") + return nil + } + } + + private func introspect(_ body: () throws -> Void) { + do { + try body() + } catch let error as SQLiteError { + err("Error: \(error.message)\n") + } catch { + err("Error: \(error)\n") + } + } + + /// SQLite run-time limits in the order `.limit` lists them, paired with + /// their stable `SQLITE_LIMIT_*` codes (0…11, part of the public API). + static let limitTable: [(name: String, code: Int32)] = [ + ("length", 0), ("sql_length", 1), ("column", 2), ("expr_depth", 3), + ("compound_select", 4), ("vdbe_op", 5), ("function_arg", 6), + ("attached", 7), ("like_pattern_length", 8), ("variable_number", 9), + ("trigger_depth", 10), ("worker_threads", 11) + ] + + /// `.limit` — list every limit, show one, or set one and show the new + /// value. sqlite3 right-justifies the name in a 20-wide field. + private func handleLimit(_ args: [String]) { + func show(_ name: String, _ code: Int32) { + let value = database.limit(code) + out(String(repeating: " ", count: max(0, 20 - name.count)) + name + " \(value)\n") + } + guard let name = args.first else { + for (name, code) in Self.limitTable { show(name, code) } + return + } + guard let entry = Self.limitTable.first(where: { $0.name == name }) else { + err("unknown limit: \"\(name)\"\n") + return + } + if args.count >= 2, let newValue = Int32(args[1]) { + database.limit(entry.code, newValue: newValue) + } + show(entry.name, entry.code) + } + + /// The `-safe` refusal message for a filesystem/shell dot-command, or + /// `nil` if it's allowed. `.open` of a real file gets its own message; + /// `:memory:` (or no argument) stays allowed. + static func safeModeBlock(_ command: String, _ args: [String]) -> String? { + switch command { + case ".open": + if let target = args.first, target != ":memory:", !target.isEmpty { + return "cannot open disk-based database files in safe mode" + } + return nil + case ".read", ".import", ".output", ".once", ".backup", ".restore": + return "cannot run \(command) in safe mode" + default: + return nil + } + } + + /// Column separator implied by the current mode (used by `.import`). + private func currentColumnSeparator() -> Character { + switch formatter.mode { + case .csv: return "," + case .tabs: return "\t" + case .ascii: return "\u{1F}" + default: return formatter.separator.first ?? "|" + } + } + + private func tableExists(_ name: String) throws -> Bool { + let sql = "SELECT 1 FROM sqlite_schema WHERE type='table' AND name='\(SQLiteDatabase.quote(name))' LIMIT 1;" + return !((try database.evaluate(sql).first?.rows.isEmpty) ?? true) + } + + /// Imports delimited rows from `text` into `table`, creating the table + /// from the header row (all TEXT columns) when it doesn't yet exist — + /// matching sqlite3's `.import`. + private func importDelimited(_ text: String, into table: String) { + let rows = Self.parseDelimited(text, separator: currentColumnSeparator()) + guard !rows.isEmpty else { return } + let ident = table.replacingOccurrences(of: "\"", with: "\"\"") + introspect { + var data = rows + if try !tableExists(table) { + data = Array(rows.dropFirst()) + let cols = rows[0] + .map { "\"\($0.replacingOccurrences(of: "\"", with: "\"\""))\" TEXT" } + .joined(separator: ", ") + // sqlite normalizes "IF NOT EXISTS" out of the stored schema, + // so .schema/.dump show `CREATE TABLE "t"(…)`. Real sqlite3's + // import retains the prefix in stored text; matching that + // would mean bypassing the normalizer — a cosmetic-only diff. + try database.evaluate("CREATE TABLE IF NOT EXISTS \"\(ident)\"(\n\(cols));") + } + for row in data { + let values = row + .map { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } + .joined(separator: ",") + try database.evaluate("INSERT INTO \"\(ident)\" VALUES(\(values));") + } + } + } + + // MARK: Small helpers + + /// Parses delimited text — CSV-style: double-quoted fields, `""` + /// escapes, embedded separators and newlines — into rows of fields. + private static func parseDelimited(_ text: String, separator: Character) -> [[String]] { + var rows: [[String]] = [] + var row: [String] = [] + var field = "" + var inQuotes = false + let chars = Array(text) + var i = 0 + while i < chars.count { + let c = chars[i] + if inQuotes { + if c == "\"" { + if i + 1 < chars.count && chars[i + 1] == "\"" { field.append("\""); i += 2; continue } + inQuotes = false + } else { + field.append(c) + } + } else { + switch c { + case "\"": inQuotes = true + case separator: row.append(field); field = "" + case "\n": row.append(field); rows.append(row); row = []; field = "" + case "\r": break + default: field.append(c) + } + } + i += 1 + } + if !field.isEmpty || !row.isEmpty { row.append(field); rows.append(row) } + return rows + } + + /// Parses an on/off argument; returns nil for an unrecognized value. + private static func onOff(_ value: String?) -> Bool? { + switch value?.lowercased() { + case "on", "1", "yes", "true": return true + case "off", "0", "no", "false": return false + default: return nil + } + } + + /// Parses a `?DB? FILE` argument list (db name defaults to "main"). + private static func dbAndFile(_ args: [String]) -> (db: String, file: String)? { + if args.count >= 2 { return (args[0], args[1]) } + if args.count == 1 { return ("main", args[0]) } + return nil + } + + /// Splits a dot-command line into tokens, honoring double quotes. + private static func tokenize(_ line: String) -> [String] { + var tokens: [String] = [] + var current = "" + var inQuote = false + var sawToken = false + for ch in line { + if ch == "\"" { + inQuote.toggle() + sawToken = true + } else if ch == " " && !inQuote { + if sawToken { tokens.append(current); current = ""; sawToken = false } + } else { + current.append(ch) + sawToken = true + } + } + if sawToken { tokens.append(current) } + return tokens + } + + /// Tiny GLOB matcher (`*` and `?`) for `.tables PATTERN`. + private static func glob(_ pattern: String, matches name: String) -> Bool { + let escaped = NSRegularExpression.escapedPattern(for: pattern) + .replacingOccurrences(of: "\\*", with: ".*") + .replacingOccurrences(of: "\\?", with: ".") + return name.range(of: "^\(escaped)$", options: .regularExpression) != nil + } + + /// Packs names into space-padded columns the way sqlite3's `.tables` + /// does: column-major order, every entry left-padded to the longest + /// name, columns separated by two spaces, 80-column budget. + private static func columnize(_ names: [String], width totalWidth: Int = 80) -> String { + guard !names.isEmpty else { return "" } + let maxLen = names.map(\.count).max() ?? 0 + let printCols = max(1, totalWidth / (maxLen + 2)) + let printRows = (names.count + printCols - 1) / printCols + var output = "" + for i in 0..2-member fixture tuples carry scoped directives below. +// swiftlint:disable file_length + +// swiftlint:disable:next type_body_length +@Suite struct Sqlite3ExecutableTests { + + /// Drives the executable with a fake stdin and captures stdout/stderr + /// via ShellKit's `OutputSink` / `InputSource` — same harness as the + /// other CLI ports. Uses an in-memory database so no filesystem (and + /// no sandbox authorization) is involved. + private func run(_ argv: [String], input: String = "") async throws + // swiftlint:disable:next large_tuple + -> (stdout: String, stderr: String, exit: Int32) { + let stdinSource: InputSource = .string(input) + let stdoutSink = OutputSink() + let stderrSink = OutputSink() + + let exit = try await Sqlite3Executable.run( + argv: argv, + stdin: stdinSource, + stdout: stdoutSink, + stderr: stderrSink) + + stdoutSink.finish() + stderrSink.finish() + return (await stdoutSink.readAllString(), await stderrSink.readAllString(), exit) + } + + @Test func inlineSelect() async throws { + let r = try await run([":memory:", "SELECT 1 + 1;"]) + #expect(r.exit == 0) + #expect(r.stdout == "2\n") + } + + @Test func crudViaStdin() async throws { + let script = """ + CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT, qty INTEGER); + INSERT INTO t(name, qty) VALUES ('apple', 3), ('banana', 5); + UPDATE t SET qty = qty + 1 WHERE name = 'apple'; + DELETE FROM t WHERE name = 'banana'; + SELECT * FROM t; + """ + let r = try await run([":memory:"], input: script) + #expect(r.exit == 0) + #expect(r.stdout == "1|apple|4\n") + } + + @Test func csvFlagWithHeader() async throws { + // The `-csv` flag emits LF row terminators — sqlite3 reserves CRLF + // for the `.mode csv` dot-command (see csvModeUsesCRLF). + let r = try await run(["-csv", "-header", ":memory:", "SELECT 1 AS a, 'x' AS b;"]) + #expect(r.exit == 0) + #expect(r.stdout == "a,b\n1,x\n") + } + + @Test func jsonFlag() async throws { + let r = try await run(["-json", ":memory:", "SELECT 1 AS a;"]) + #expect(r.stdout == "[{\"a\":1}]\n") + } + + @Test func jsonBlob() async throws { + let r = try await run(["-json", ":memory:", "SELECT x'00ff' AS b;"]) + #expect(r.stdout == "[{\"b\":\"\\u0000\\u00ff\"}]\n") + } + + @Test func dotPrint() async throws { + let r = try await run([":memory:"], input: ".print hello world\n.print \"quoted arg\"\n") + #expect(r.stdout == "hello world\nquoted arg\n") + } + + @Test func dotEcho() async throws { + let r = try await run([":memory:"], input: ".echo on\n.headers on\nSELECT 1 AS a;\n") + #expect(r.stdout == ".headers on\nSELECT 1 AS a;\na\n1\n") + } + + @Test func dotChanges() async throws { + let r = try await run([":memory:"], + input: ".changes on\nCREATE TABLE t(x);\nINSERT INTO t VALUES(1),(2),(3);\n") + #expect(r.stdout.contains("changes: 0 total_changes: 0")) + #expect(r.stdout.contains("changes: 3 total_changes: 3")) + } + + @Test func eqpShowsScanPlan() async throws { + let r = try await run([":memory:"], input: "CREATE TABLE t(x);\n.eqp on\nSELECT * FROM t;\n") + #expect(r.stdout == "QUERY PLAN\n`--SCAN t\n") + } + + @Test func eqpShowsIndexSearch() async throws { + let r = try await run([":memory:"], input: """ + CREATE TABLE t(id INTEGER, name TEXT); + CREATE INDEX ix ON t(name); + .eqp on + SELECT * FROM t WHERE name = 'bob'; + """) + #expect(r.stdout.contains("QUERY PLAN\n`--SEARCH t USING INDEX ix (name=?)")) + } + + @Test func eqpOffStopsPlans() async throws { + let r = try await run([":memory:"], input: ".eqp on\n.eqp off\nSELECT 1;\n") + #expect(r.stdout == "1\n") + } + + @Test func scriptContinuesAfterError() async throws { + // A script keeps going after an error (exit 1), matching sqlite3. + let r = try await run([":memory:"], input: "SELECT * FROM nope;\nSELECT 99;\n") + #expect(r.exit == 1) + #expect(r.stdout == "99\n") + #expect(r.stderr.contains("no such table: nope")) + } + + @Test func bailStopsAfterError() async throws { + let r = try await run([":memory:"], input: ".bail on\nSELECT * FROM nope;\nSELECT 99;\n") + #expect(r.exit == 1) + #expect(r.stdout == "") + #expect(r.stderr.contains("no such table: nope")) + } + + /// Creates a unique temp directory removed at the end of `body`. + private func withTempDir(_ body: (URL) async throws -> Void) async throws { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("sqlite3-tests-" + UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try await body(dir) + } + + @Test func dotOutputRedirectsThenReverts() async throws { + try await withTempDir { dir in + let file = dir.appendingPathComponent("out.txt") + let r = try await run([":memory:"], + input: ".output \(file.path)\nSELECT 1;\n.output\nSELECT 2;\n") + #expect(r.stdout == "2\n") + #expect(try String(contentsOf: file, encoding: .utf8) == "1\n") + } + } + + @Test func dotOnceRedirectsNextOnly() async throws { + try await withTempDir { dir in + let file = dir.appendingPathComponent("once.txt") + let r = try await run([":memory:"], + input: ".once \(file.path)\nSELECT 10;\nSELECT 20;\n") + #expect(r.stdout == "20\n") + #expect(try String(contentsOf: file, encoding: .utf8) == "10\n") + } + } + + @Test func dotImportIntoExistingTable() async throws { + try await withTempDir { dir in + let csv = dir.appendingPathComponent("data.csv") + try "1,alice\n\"x,y\",bob\n".write(to: csv, atomically: true, encoding: .utf8) + let r = try await run([":memory:"], input: """ + .mode csv + CREATE TABLE t(id,name); + .import \(csv.path) t + .mode list + SELECT id || '/' || name FROM t ORDER BY name; + """) + #expect(r.stdout == "1/alice\nx,y/bob\n") + } + } + + @Test func dotImportCreatesTableFromHeader() async throws { + try await withTempDir { dir in + let csv = dir.appendingPathComponent("data.csv") + try "id,name\n1,alice\n2,bob\n".write(to: csv, atomically: true, encoding: .utf8) + let r = try await run([":memory:"], input: """ + .mode csv + .import \(csv.path) newt + .mode list + SELECT id, name FROM newt ORDER BY id; + """) + #expect(r.stdout == "1|alice\n2|bob\n") + } + } + + @Test func dotBackupAndRestore() async throws { + try await withTempDir { dir in + let backup = dir.appendingPathComponent("bk.db") + let r1 = try await run([":memory:"], input: """ + CREATE TABLE t(id, name); + INSERT INTO t VALUES(1,'alice'),(2,'bob'); + .backup \(backup.path) + """) + #expect(r1.exit == 0) + // Restore the backup into a fresh in-memory database. + let r2 = try await run([":memory:"], input: """ + .restore \(backup.path) + SELECT id || '/' || name FROM t ORDER BY id; + """) + #expect(r2.stdout == "1/alice\n2/bob\n") + } + } + + @Test func boxFlag() async throws { + let r = try await run(["-box", ":memory:", "SELECT 1 AS a;"]) + #expect(r.stdout == "┌───┐\n│ a │\n├───┤\n│ 1 │\n└───┘\n") + } + + @Test func insertModeNamedTable() async throws { + let r = try await run([":memory:"], + input: "CREATE TABLE t(a);INSERT INTO t VALUES(1);\n.mode insert t\nSELECT * FROM t;\n") + #expect(r.stdout == "INSERT INTO t VALUES(1);\n") + } + + @Test func dumpRoundTrip() async throws { + let r = try await run([":memory:"], input: """ + CREATE TABLE t(id INTEGER, name TEXT); + INSERT INTO t VALUES (1,'alice'),(2,NULL); + .dump + """) + #expect(r.exit == 0) + #expect(r.stdout == """ + PRAGMA foreign_keys=OFF; + BEGIN TRANSACTION; + CREATE TABLE t(id INTEGER, name TEXT); + INSERT INTO t VALUES(1,'alice'); + INSERT INTO t VALUES(2,NULL); + COMMIT; + """ + "\n") + } + + @Test func dotModeAndHeadersFromStdin() async throws { + let script = """ + .mode csv + .headers on + SELECT 1 AS a, 2 AS b; + """ + let r = try await run([":memory:"], input: script) + #expect(r.stdout == "a,b\r\n1,2\r\n") + } + + @Test func dotModeColumnEnablesHeaders() async throws { + // `.mode column` turns headers on (sqlite3 behavior). + let r = try await run([":memory:"], input: ".mode column\nSELECT 1 AS a, 2 AS b;\n") + #expect(r.stdout == "a b\n- -\n1 2\n") + } + + @Test func columnFlagDoesNotEnableHeaders() async throws { + // ...but the -column flag does not. + let r = try await run(["-column", ":memory:", "SELECT 1 AS a, 2 AS b;"]) + #expect(r.stdout == "1 2\n") + } + + @Test func explicitHeadersOffBeatsColumnMode() async throws { + let r = try await run([":memory:"], input: ".headers off\n.mode column\nSELECT 1 AS a;\n") + #expect(r.stdout == "1\n") + } + + @Test func dotTablesAndSchema() async throws { + let script = """ + CREATE TABLE foo(id INTEGER); + CREATE TABLE bar(id INTEGER); + .tables + .schema foo + """ + let r = try await run([":memory:"], input: script) + #expect(r.stdout.contains("bar")) + #expect(r.stdout.contains("foo")) + #expect(r.stdout.contains("CREATE TABLE foo(id INTEGER);")) + } + + @Test func inlinePrepareError() async throws { + // Command-line SQL: "Error: in prepare, ..." and exit = SQLite code. + let r = try await run([":memory:", "SELECT * FROM missing;"]) + #expect(r.exit == 1) + #expect(r.stderr == "Error: in prepare, no such table: missing\n") + } + + @Test func inlineRuntimeError() async throws { + // Stepping failure: "Error: stepping, ... (code)" and exit = code. + let r = try await run([":memory:", + "CREATE TABLE x(a INTEGER NOT NULL); INSERT INTO x VALUES(NULL);"]) + #expect(r.exit == 19) + #expect(r.stderr == "Error: stepping, NOT NULL constraint failed: x.a (19)\n") + } + + @Test func scriptParseErrorWithCaret() async throws { + let r = try await run([":memory:"], input: "SELEC 1;\n") + #expect(r.exit == 1) + #expect(r.stderr == "Parse error near line 1: near \"SELEC\": syntax error\n SELEC 1;\n ^--- error here\n") + } + + @Test func dotReadMissingFileFailsExitCode() async throws { + let r = try await run([":memory:"], input: ".read /no/such/file\n") + #expect(r.exit == 1) + #expect(!r.stderr.isEmpty) + } + + @Test func scriptRuntimeErrorLineNumber() async throws { + let r = try await run([":memory:"], + input: "CREATE TABLE x(a INTEGER NOT NULL);\nINSERT INTO x VALUES(NULL);\n") + #expect(r.exit == 1) + #expect(r.stderr == "Runtime error near line 2: NOT NULL constraint failed: x.a (19)\n") + } + + @Test func unknownDotCommandContinues() async throws { + let r = try await run([":memory:"], input: ".bogus\nSELECT 1;\n") + #expect(r.exit == 0) + #expect(r.stderr.contains("unknown command")) + #expect(r.stdout == "1\n") + } + + @Test func quitStopsProcessing() async throws { + let script = """ + SELECT 1; + .quit + SELECT 2; + """ + let r = try await run([":memory:"], input: script) + #expect(r.stdout == "1\n") + } + + @Test func versionFlag() async throws { + let r = try await run(["-version"]) + #expect(r.exit == 0) + #expect(r.stdout.hasPrefix("3.50.4 ")) + #expect(r.stdout.hasSuffix(" (64-bit)\n")) + } + + @Test func unknownOptionFails() async throws { + let r = try await run(["-bogus", ":memory:"]) + #expect(r.exit == 1) + #expect(r.stderr.contains("unknown option")) + } + + // MARK: .dump fidelity + output parity (issue #43) + + @Test func dumpQuotesSpaceTableName() async throws { + // A table name with a space must be double-quoted in the emitted + // INSERT (the old code used the raw name → invalid, un-replayable SQL). + let r = try await run([":memory:"], input: """ + CREATE TABLE "my table"(x); + INSERT INTO "my table" VALUES(1); + .dump + """) + #expect(r.exit == 0) + #expect(r.stdout.contains("INSERT INTO \"my table\" VALUES(1);")) + } + + @Test func dumpQuotesKeywordTableName() async throws { + // `order` is a SQL keyword, so the INSERT must quote it — verified + // against the engine's own sqlite3_keyword_check, case-insensitively. + let r = try await run([":memory:"], input: """ + CREATE TABLE "order"(x); + INSERT INTO "order" VALUES(1); + .dump + """) + #expect(r.exit == 0) + #expect(r.stdout.contains("INSERT INTO \"order\" VALUES(1);")) + } + + @Test func dumpPreservesAutoincrementSequence() async throws { + // sqlite3's .dump re-emits the AUTOINCREMENT high-water mark as a + // sqlite_sequence INSERT — with no CREATE (the table is implicit) and, + // for `.dump` specifically, no `DELETE FROM sqlite_sequence` (that is + // `.recover`'s behavior; cf. shell.c dump_callback). We match it + // byte-for-byte: sqlite3's own dump doesn't dedupe the row the table's + // own inserts re-create on replay, so this is parity, not a "perfect" + // counter reload — see PR #46 review. + let r = try await run([":memory:"], input: """ + CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v); + INSERT INTO t(v) VALUES('a'),('b'),('c'); + DELETE FROM t WHERE id = 3; + .dump + """) + #expect(r.exit == 0) + #expect(r.stdout.contains("INSERT INTO sqlite_sequence VALUES('t',3);")) + #expect(!r.stdout.contains("CREATE TABLE sqlite_sequence")) + #expect(!r.stdout.contains("DELETE FROM sqlite_sequence")) // .dump ≠ .recover + } + + @Test func dumpSingleTableOmitsSequence() async throws { + // A single-table dump omits sqlite_sequence (matching sqlite3). + let r = try await run([":memory:"], input: """ + CREATE TABLE t(id INTEGER PRIMARY KEY AUTOINCREMENT, v); + INSERT INTO t(v) VALUES('a'); + .dump t + """) + #expect(r.exit == 0) + #expect(!r.stdout.contains("sqlite_sequence")) + } + + @Test func jsonPreservesDuplicateAndOrderedColumns() async throws { + // Hand-rolled JSON keeps column order and duplicate names (a dict + // would reorder/collapse them) — matching sqlite3's -json. + let r = try await run(["-json", ":memory:", "SELECT 2 AS b, 1 AS a, 3 AS a;"]) + #expect(r.stdout == "[{\"b\":2,\"a\":1,\"a\":3}]\n") + } + + @Test func dotCommandInsideStringLiteralIsData() async throws { + // A line beginning with "." inside an open string literal is data, + // not a dot-command: it must not run `.tables`. + let r = try await run([":memory:"], input: """ + CREATE TABLE marker(x); + SELECT ' + .tables + ' AS v; + """) + #expect(r.exit == 0) + #expect(r.stdout.contains(".tables")) // preserved as data + #expect(!r.stdout.contains("marker")) // .tables never executed + } + + // MARK: dot-command coverage parity (issue #43) + + @Test func databasesShowsFileAndReadWrite() async throws { + // sqlite3: ": <"" if no file> ". + let r = try await run([":memory:"], input: ".databases\n") + #expect(r.stdout == "main: \"\" r/w\n") + } + + @Test func schemaViewGetsColumnComment() async throws { + // sqlite3 appends /* view(cols) */ (with identifier quoting) to a view. + let r = try await run([":memory:"], input: """ + CREATE TABLE t(a, b); + CREATE VIEW v AS SELECT a, b + 1 AS bb FROM t; + .schema v + """) + #expect(r.stdout == "CREATE VIEW v AS SELECT a, b + 1 AS bb FROM t\n/* v(a,bb) */;\n") + } + + @Test func showListsAllTwelveSettings() async throws { + // sqlite3's .show: 12 labels right-justified to width 12. + let r = try await run([":memory:"], input: ".show\n") + let expected = [ + " echo: off", + " eqp: off", + " explain: auto", + " headers: off", + " mode: list", + " nullvalue: \"\"", + " output: stdout", + "colseparator: \"|\"", + "rowseparator: \"\\n\"", + " stats: off", + " width: ", + " filename: :memory:" + ].joined(separator: "\n") + "\n" + #expect(r.stdout == expected) + } + + @Test func schemaUnresolvableViewHasNoComment() async throws { + // A view that can't be prepared (missing table) prints just its stored + // CREATE — no bogus /* v() */ comment (matches sqlite3). [PR #48 review] + let r = try await run([":memory:"], input: """ + CREATE VIEW v AS SELECT * FROM missing; + .schema v + """) + #expect(r.stdout == "CREATE VIEW v AS SELECT * FROM missing;\n") + } + + @Test func showReportsModeDerivedSeparators() async throws { + // .mode csv changes the reported separators to , / \r\n. [PR #48 review] + let r = try await run([":memory:"], input: ".mode csv\n.show\n") + #expect(r.stdout.contains("colseparator: \",\"")) + #expect(r.stdout.contains("rowseparator: \"\\r\\n\"")) + } + + // MARK: - Parity batch (issue #43): generated cols, reals, csv, .width, + // REPL, -safe, .limit, .fullschema. Each expectation is pinned against a + // real sqlite3 used as the oracle. + + @Test func dumpExcludesGeneratedColumns() async throws { + let script = """ + CREATE TABLE t(a INT, b INT, c INT GENERATED ALWAYS AS (a+b) VIRTUAL, d INT GENERATED ALWAYS AS (a*b) STORED); + INSERT INTO t(a,b) VALUES(2,3); + .dump + """ + let r = try await run([":memory:"], input: script) + #expect(r.stdout.contains("INSERT INTO t VALUES(2,3);")) + #expect(!r.stdout.contains("VALUES(2,3,")) // generated values excluded + } + + @Test func fullPrecisionRealsInRoundTripModes() async throws { + let q = try await run([":memory:"], input: ".mode quote\nSELECT 0.1+0.2, 3.14;\n") + #expect(q.stdout == "0.3000000000000000445,3.140000000000000124\n") + let j = try await run([":memory:"], input: ".mode json\nSELECT 0.1+0.2 AS a;\n") + #expect(j.stdout == "[{\"a\":0.3000000000000000445}]\n") + } + + @Test func csvModeUsesCRLF() async throws { + let r = try await run([":memory:"], input: ".mode csv\n.headers on\nSELECT 1 AS a, 'x' AS b;\n") + #expect(r.stdout == "a,b\r\n1,x\r\n") + } + + @Test func widthWrapsAndRightJustifies() async throws { + let wrap = try await run([":memory:"], input: ".mode column\n.headers off\n.width 3\nSELECT 'abcdef';\n") + #expect(wrap.stdout == "abc\ndef\n") + let rj = try await run([":memory:"], input: ".mode column\n.headers off\n.width -5\nSELECT 'ab';\n") + #expect(rj.stdout == " ab\n") + } + + @Test func showReportsWidthAndColumnModeSuffix() async throws { + let r = try await run([":memory:"], input: ".mode box\n.width 3 5\n.show\n") + #expect(r.stdout.contains("mode: box --wrap 60 --wordwrap off --noquote")) + #expect(r.stdout.contains("width: 3 5 ")) + #expect(r.stdout.contains("filename: :memory:")) + } + + @Test func interactiveBannerAndPrompts() async throws { + let r = try await run(["-interactive", ":memory:"], input: "SELECT 1;\n.quit\n") + #expect(r.stdout == Session.banner + "sqlite> 1\nsqlite> ") + } + + @Test func interactiveContinuationPrompt() async throws { + let r = try await run(["-interactive", ":memory:"], input: "SELECT\n1;\n.quit\n") + #expect(r.stdout == Session.banner + "sqlite> ...> 1\nsqlite> ") + } + + @Test func interactiveErrorFormat() async throws { + let r = try await run(["-interactive", ":memory:"], input: "SELECT bad here;\n.quit\n") + #expect(r.stderr.contains("Parse error: ")) + #expect(r.stderr.contains("^--- error here")) + } + + @Test func safeModeBlocksFileCommandAndExits() async throws { + let r = try await run(["-safe", ":memory:", ".backup x.db"]) + #expect(r.exit == 1) + #expect(r.stderr == "line 0: cannot run .backup in safe mode\n") + } + + @Test func safeModeRejectsRealFileOpenButAllowsMemory() async throws { + let real = try await run(["-safe", ":memory:", ".open foo.db"]) + #expect(real.exit == 1) + #expect(real.stderr == "line 0: cannot open disk-based database files in safe mode\n") + let mem = try await run(["-safe", ":memory:"], input: ".open :memory:\nSELECT 7;\n") + #expect(mem.exit == 0) + #expect(mem.stdout == "7\n") + // `.open :memory:` must open a true in-memory database, never resolve + // to a real on-disk file named ":memory:" (regression guard). + #expect(!FileManager.default.fileExists(atPath: ":memory:")) + } + + @Test func safeModeBlocksAttachAndLoadExtension() async throws { + // -safe gates SQL-level filesystem reach, not just dot-commands. + let attach = try await run(["-safe", ":memory:", "ATTACH 'foo.db' AS x;"]) + #expect(attach.exit == 1) + #expect(attach.stderr == "line 0: cannot run ATTACH in safe mode\n") + let ext = try await run(["-safe", ":memory:", "SELECT load_extension('x');"]) + #expect(ext.exit == 1) + #expect(ext.stderr == "line 0: cannot use the load_extension() function in safe mode\n") + // A normal statement is unaffected; ATTACH is denied without -safe-ing SQL. + let ok = try await run([":memory:"], input: "ATTACH ':memory:' AS y;\nSELECT 'ok';\n") + #expect(ok.stdout == "ok\n") + } + + @Test func attachIsGatedByTheSandbox() async throws { + // A rooted sandbox confines ATTACH the same way it confines the db + // file / .read / .open: a path outside the root is denied, one inside + // is allowed. (No -safe here — this is the always-on sandbox gate.) + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("sqlite-attach-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + // swiftlint:disable:next large_tuple + func run(_ sql: String) async throws -> (out: String, err: String, exit: Int32) { + let shell = Shell(environment: Environment( + variables: ProcessInfo.processInfo.environment, + workingDirectory: root.path)) + shell.sandbox = .rooted(at: root) + let out = OutputSink(), err = OutputSink() + shell.stdout = out; shell.stderr = err + let exit = try await Shell.$current.withValue(shell) { + try await Sqlite3Executable.run( + argv: [":memory:", sql], stdin: .string(""), stdout: out, stderr: err) + } + out.finish(); err.finish() + return (await out.readAllString(), await err.readAllString(), exit) + } + + let outside = try await run("ATTACH '/etc/hosts' AS x;") + #expect(outside.exit == 1) + #expect(outside.err.contains("Error:")) // sandbox denial reported + + let inside = root.appendingPathComponent("inside.db").path + let allowed = try await run("ATTACH '\(inside)' AS x; SELECT 'ok';") + #expect(allowed.exit == 0) + #expect(allowed.out == "ok\n") + } + + @Test func limitListsAndSets() async throws { + let list = try await run([":memory:", ".limit"]) + #expect(list.stdout.contains(" length 1000000000")) + #expect(list.stdout.contains(" worker_threads 0")) + let set = try await run([":memory:"], input: ".limit column 5\n.limit column\n") + #expect(set.stdout == " column 5\n column 5\n") + } + + @Test func fullschemaNoStats() async throws { + let r = try await run([":memory:"], input: "CREATE TABLE x(a);\n.fullschema\n") + #expect(r.stdout == "CREATE TABLE x(a);\n/* No STAT tables available */\n") + } + + @Test func filenameShownAsTyped() async throws { + let r = try await run([":memory:", ".show"]) + #expect(r.stdout.contains("filename: :memory:")) + } +} From d6b882dc712bb70294bba51be628ebaf21e27cf4 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Tue, 9 Jun 2026 13:18:00 +0200 Subject: [PATCH 2/2] CI: build (not test) Sqlite3Shell on iOS; drop the poisoned sim test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS Simulator test of `-scheme SQLiteKit` failed with "Scheme SQLiteKit is not currently configured for the test action" / "Supported platforms ... is empty". Root cause: the shell driver pulls in ShellKit -> swift-subprocess, and swift-subprocess declares `platforms: [.macOS, .iOS("99.0")]` — the `iOS("99.0")` sentinel means "iOS unsupported". xcodebuild folds `Sqlite3ShellTests` (transitively -> ShellKit -> Subprocess) into the autogenerated `SQLiteKit` scheme's test action, so the scheme's iOS deployment floor becomes 99.0 and its supported-platform set goes empty. The SDK itself still compiles + tests fine on iOS; only the test-action scheme resolution breaks. `.swiftpm` is gitignored here, so an explicit trimmed scheme isn't the project's convention. Fix: the iOS job now *builds* both `SQLiteKit` and `Sqlite3Shell` for the iOS device SDK (proving both products compile for iOS — the BUILD action isn't poisoned) and no longer runs the simulator test. The actual SDK + shell test suites run on macOS / Linux / Windows / Android via `swift test` (all green). Also drop the now-unused `SQLiteKit` dependency + `@testable import` from `Sqlite3ShellTests` (it references no SQLiteKit symbols directly). Verified locally: both `xcodebuild build -scheme {SQLiteKit,Sqlite3Shell} -destination generic/platform=iOS` succeed; `swift test` still 107 green; swiftlint --strict clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/swift.yml | 20 +++++++++---------- Package.swift | 7 ++++--- .../Sqlite3ExecutableTests.swift | 1 - 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 1da5bcc..d5b8dfa 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -56,18 +56,18 @@ jobs: uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.0" + # Build both the SDK and the shell driver for the iOS device SDK. We + # build (not test) on iOS: the shell driver pulls in ShellKit -> + # swift-subprocess, which declares `iOS("99.0")` — i.e. iOS unsupported — + # so once `Sqlite3ShellTests` is folded into the autogenerated `SQLiteKit` + # scheme's test action the scheme's iOS platform set is empty + # ("not configured for the test action"). The actual SDK + shell test + # suites run on macOS / Linux / Windows / Android via `swift test`; here + # we just prove both products compile for iOS. - name: Build (iOS device SDK) - run: xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build - - name: Test (iOS Simulator) run: | - set -eo pipefail - # Pick the first available iPhone simulator rather than pinning a - # device name a future Xcode might drop. - UDID=$(xcrun simctl list devices available --json \ - | jq -r '[.devices[][] | select(.name|test("iPhone"))][0].udid // empty') - [ -n "$UDID" ] || { echo "::error::no iPhone simulator available"; exit 1; } - echo "Using simulator $UDID" - xcodebuild test -scheme SQLiteKit -destination "platform=iOS Simulator,id=$UDID" + xcodebuild -scheme SQLiteKit -destination 'generic/platform=iOS' build + xcodebuild -scheme Sqlite3Shell -destination 'generic/platform=iOS' build build-tvos: runs-on: macos-15 diff --git a/Package.swift b/Package.swift index cf73089..4b8faa9 100644 --- a/Package.swift +++ b/Package.swift @@ -127,13 +127,14 @@ let package = Package( ] ), // Drives `Sqlite3Executable` directly (no ArgumentParser), so it builds - // and runs on every platform in the matrix — Android included — - // exercising the shell port on the emulator in CI. + // and runs on every platform `swift test` covers — Android included — + // exercising the shell port on the emulator in CI. Depends only on + // `Sqlite3Shell` (+ ShellKit for the IO harness); it references no + // `SQLiteKit` symbols directly. .testTarget( name: "Sqlite3ShellTests", dependencies: [ "Sqlite3Shell", - "SQLiteKit", .product(name: "ShellKit", package: "ShellKit"), ] ), diff --git a/Tests/Sqlite3ShellTests/Sqlite3ExecutableTests.swift b/Tests/Sqlite3ShellTests/Sqlite3ExecutableTests.swift index aa8f505..4210dc8 100644 --- a/Tests/Sqlite3ShellTests/Sqlite3ExecutableTests.swift +++ b/Tests/Sqlite3ShellTests/Sqlite3ExecutableTests.swift @@ -2,7 +2,6 @@ import Foundation import ShellKit import Testing @testable import Sqlite3Shell -@testable import SQLiteKit // One cohesive suite over the shell port. `file_length` is disabled file-wide // (it can't be scoped to a declaration); the oversized suite type and the few