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/.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..4b8faa9 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,29 @@ 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 `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",
+ .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:"))
+ }
+}