Skip to content

Commit be0630f

Browse files
odrobnikclaude
andcommitted
Add the Sqlite3Shell CLI driver as a 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` — 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) <noreply@anthropic.com>
1 parent 1686168 commit be0630f

6 files changed

Lines changed: 1894 additions & 4 deletions

File tree

.swiftlint.yml

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
# local pre-commit hook, mirroring the Cocoanetics convention (ShellKit).
33
#
44
# A handful of default rules are tuned for what this package is: a thin SQLite
5-
# C-interop SDK. Short names (`rc`, `db`, `op`, `i`) are the idiomatic spelling
6-
# at the C boundary, and the cohesive `SQLiteDatabase` / `ResultFormatter` types
7-
# read better whole than carved up to satisfy a line budget. Everything else is
8-
# stock SwiftLint.
5+
# C-interop SDK plus the `Sqlite3Shell` CLI port. Short names (`rc`, `db`, `op`,
6+
# `i`) are the idiomatic spelling at the C boundary, and the cohesive
7+
# `SQLiteDatabase` / `ResultFormatter` types read better whole than carved up to
8+
# satisfy a line budget. The shell port (`Sources/Sqlite3Shell`) is a faithful
9+
# 1:1 reproduction of the sqlite3 CLI — a long dispatch switch and one REPL type
10+
# — so it carries scoped `swiftlint:disable:next` directives at the few oversized
11+
# declarations. Everything else is stock SwiftLint.
912
included:
1013
- Sources
1114
- Tests
@@ -14,6 +17,13 @@ excluded:
1417
- .build
1518
- .swiftpm
1619

20+
blanket_disable_command:
21+
# `file_length` is reported at end-of-file, so it cannot be scoped with
22+
# `disable:next` like the other size rules. The shell port and its test suite
23+
# disable it file-wide on purpose; allow that one rule to be blanket-disabled.
24+
allowed_rules:
25+
- file_length
26+
1727
identifier_name:
1828
# Allow the short, idiomatic names used at the C-interop boundary.
1929
min_length:

Package.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ let package = Package(
1414
// The SDK: a thin, pure-Swift wrapper over the vendored SQLite
1515
// amalgamation. FTS5 and sqlite-vec ride along behind opt-in traits.
1616
.library(name: "SQLiteKit", targets: ["SQLiteKit"]),
17+
// The `sqlite3` shell driver — the argv parser plus the dot-command /
18+
// REPL engine that reproduces the sqlite3 CLI. ArgumentParser-free and
19+
// IO-agnostic (it reads / writes / authorizes paths through
20+
// `ShellKit.Shell`), so a host can drive it in-process on any platform,
21+
// Android included. The SwiftPorts `sqlite3` executable wraps it in an
22+
// ArgumentParser command; SwiftBash registers it as a native builtin.
23+
// Pulls in ShellKit — SDK-only consumers that depend on the `SQLiteKit`
24+
// product never build this target (or ShellKit) into their link.
25+
.library(name: "Sqlite3Shell", targets: ["Sqlite3Shell"]),
1726
],
1827
// Opt-in, build-time engine toggles. Both off by default.
1928
// • depending on this package: .package(url: …, traits: ["FTS5", "SQLiteVec"])
@@ -35,6 +44,14 @@ let package = Package(
3544
.package(url: "https://github.com/stephencelis/CSQLite",
3645
exact: "3.50.4",
3746
traits: [.trait(name: "FTS5", condition: .when(traits: ["FTS5"]))]),
47+
// Host runtime context for the `Sqlite3Shell` driver: the IO sinks it
48+
// reads / writes through and the sandbox gate (`Shell.resolve` /
49+
// `Shell.authorize`) every file-touching dot-command passes. Only the
50+
// ArgumentParser-free `ShellKit` core product is used, so no
51+
// ArgumentParser enters this package's graph. Pinned to `main` until
52+
// ShellKit ships a tagged release.
53+
.package(url: "https://github.com/Cocoanetics/ShellKit",
54+
branch: "main"),
3855
],
3956
targets: [
4057
// Typed C wrappers for SQLite's variadic printf (`sqlite3_mprintf`),
@@ -97,5 +114,28 @@ let package = Package(
97114
name: "SQLiteKitTests",
98115
dependencies: ["SQLiteKit"]
99116
),
117+
// The `sqlite3` shell driver: `Parser` (SQLite's single-dash long
118+
// options, which ArgumentParser can't express) plus `Sqlite3Executable`
119+
// / `Session` (the dot-command and REPL engine). Depends on the SDK and
120+
// on ShellKit's core for IO + the sandbox gate; carries no
121+
// ArgumentParser, so it builds on every platform (Android included).
122+
.target(
123+
name: "Sqlite3Shell",
124+
dependencies: [
125+
"SQLiteKit",
126+
.product(name: "ShellKit", package: "ShellKit"),
127+
]
128+
),
129+
// Drives `Sqlite3Executable` directly (no ArgumentParser), so it builds
130+
// and runs on every platform in the matrix — Android included —
131+
// exercising the shell port on the emulator in CI.
132+
.testTarget(
133+
name: "Sqlite3ShellTests",
134+
dependencies: [
135+
"Sqlite3Shell",
136+
"SQLiteKit",
137+
.product(name: "ShellKit", package: "ShellKit"),
138+
]
139+
),
100140
]
101141
)

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,36 @@ recompiles the engine, so they are opt-in.
201201
- **`SQLiteRow` / `ResultSet`** — typed rows, subscriptable by index and column name.
202202
- **`ResultFormatter`** — sqlite3-compatible rendering.
203203

204+
## The `sqlite3` shell driver
205+
206+
The package also vends a **`Sqlite3Shell`** product — the argv parser plus the
207+
dot-command / REPL engine that reproduces the `sqlite3` command-line shell
208+
(`.tables`, `.schema`, `.dump`, `.import`, `.mode`, `-safe`, …). It is
209+
ArgumentParser-free and IO-agnostic: it reads, writes, and authorizes file paths
210+
through [`ShellKit.Shell`](https://github.com/Cocoanetics/ShellKit), so a host
211+
can drive it in-process on any platform — Android included — and confine it to a
212+
sandbox. SDK-only consumers that depend on the `SQLiteKit` product never build
213+
this target (or ShellKit) into their link.
214+
215+
```swift
216+
.product(name: "Sqlite3Shell", package: "SQLiteKit"),
217+
```
218+
219+
```swift
220+
import Sqlite3Shell
221+
import ShellKit
222+
223+
let code = try await Sqlite3Executable.run(
224+
argv: ["mydb.sqlite", "SELECT * FROM users;"],
225+
stdin: Shell.current.stdin,
226+
stdout: Shell.current.stdout,
227+
stderr: Shell.current.stderr)
228+
```
229+
230+
[Cocoanetics/SwiftPorts](https://github.com/Cocoanetics/SwiftPorts) wraps this
231+
driver in an ArgumentParser command to ship the `sqlite3` executable, and
232+
SwiftBash registers it as a native builtin.
233+
204234
## Platforms
205235

206236
macOS 13+, iOS 16+, tvOS 16+, watchOS 9+, visionOS 1+, Linux, Android, Windows.

Sources/Sqlite3Shell/Parser.swift

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import Foundation
2+
import SQLiteKit
3+
4+
/// Hand-rolled argv parser for the `sqlite3` CLI. SQLite uses single-dash
5+
/// long options (`-csv`, `-header`, `-separator X`), which ArgumentParser
6+
/// can't express, so — like the `rg` / `fd` ports — we parse argv directly.
7+
enum Parser {
8+
struct Options {
9+
var databasePath: String?
10+
var sql: [String] = []
11+
var mode: OutputMode = .list
12+
var showHeader = false
13+
var headerExplicit = false
14+
var separator = "|"
15+
var nullValue = ""
16+
var readonly = false
17+
var interactive = false
18+
var echo = false
19+
var bail = false
20+
var safe = false
21+
var initFile: String?
22+
var commands: [String] = []
23+
var special: Special = .none
24+
}
25+
26+
enum Special { case none, help, version }
27+
28+
struct ArgError: Error { let message: String }
29+
30+
// SQLite's single-dash long options are parsed by one flat switch over argv,
31+
// inherently a long, high-branch function.
32+
// swiftlint:disable:next cyclomatic_complexity function_body_length
33+
static func parse(_ argv: [String]) throws -> Options {
34+
var options = Options()
35+
var positionals: [String] = []
36+
var i = 0
37+
38+
func value(for flag: String) throws -> String {
39+
guard i + 1 < argv.count else {
40+
throw ArgError(message: "option requires an argument: \(flag)")
41+
}
42+
i += 1
43+
return argv[i]
44+
}
45+
46+
while i < argv.count {
47+
let arg = argv[i]
48+
switch arg {
49+
case "-help", "--help", "-?": options.special = .help
50+
case "-version", "--version": options.special = .version
51+
case "-csv": options.mode = .csv
52+
case "-json": options.mode = .json
53+
case "-line": options.mode = .line
54+
case "-column": options.mode = .column
55+
case "-list": options.mode = .list
56+
case "-tabs": options.mode = .tabs
57+
case "-ascii": options.mode = .ascii
58+
case "-html": options.mode = .html
59+
case "-markdown": options.mode = .markdown
60+
case "-table": options.mode = .table
61+
case "-box": options.mode = .box
62+
case "-quote": options.mode = .quote
63+
case "-header", "-headers": options.showHeader = true; options.headerExplicit = true
64+
case "-noheader", "-noheaders": options.showHeader = false; options.headerExplicit = true
65+
case "-readonly": options.readonly = true
66+
case "-batch": options.interactive = false
67+
case "-interactive": options.interactive = true
68+
case "-echo": options.echo = true
69+
case "-bail": options.bail = true
70+
case "-safe": options.safe = true
71+
case "-separator": options.separator = try value(for: arg)
72+
case "-nullvalue": options.nullValue = try value(for: arg)
73+
case "-init": options.initFile = try value(for: arg)
74+
case "-cmd": options.commands.append(try value(for: arg))
75+
default:
76+
if arg.hasPrefix("-") && arg.count > 1 {
77+
throw ArgError(message: "unknown option: \(arg)")
78+
}
79+
positionals.append(arg)
80+
}
81+
i += 1
82+
}
83+
84+
if let first = positionals.first {
85+
options.databasePath = first
86+
options.sql = Array(positionals.dropFirst())
87+
}
88+
return options
89+
}
90+
91+
static let helpText = """
92+
Usage: sqlite3 [OPTIONS] FILENAME [SQL]
93+
94+
FILENAME is the SQLite database to open. Omit it (or use ":memory:")
95+
for a transient in-memory database. A trailing SQL argument runs and
96+
then exits; otherwise SQL is read from standard input.
97+
98+
OPTIONS:
99+
-version show the SQLite library version and exit
100+
-help show this message and exit
101+
-readonly open the database read-only
102+
-init FILE run FILE before reading the main input
103+
-cmd COMMAND run COMMAND before reading the main input
104+
-echo print each statement before running it
105+
-bail stop after the first error
106+
-batch non-interactive mode
107+
-interactive interactive mode (prompts; SQL run line-by-line)
108+
-safe refuse dot-commands that touch the filesystem/shell
109+
110+
-list values separated by .separator (default)
111+
-csv comma-separated values
112+
-tabs tab-separated values
113+
-ascii 0x1F/0x1E separated values
114+
-column left-aligned columns
115+
-markdown Markdown table
116+
-table ASCII-art table
117+
-box Unicode box-drawing table
118+
-line one value per line
119+
-json JSON array of objects
120+
-html HTML <TR>/<TD> rows
121+
-quote SQL-literal values
122+
-header / -noheader show or hide column headers
123+
-separator SEP field separator for -list mode (default "|")
124+
-nullvalue STR text to print for NULL values (default "")
125+
126+
Dot-commands (at a statement boundary):
127+
.tables [PATTERN] list tables and views
128+
.schema [TABLE] show CREATE statements
129+
.databases list attached databases
130+
.indexes [TABLE] list indexes
131+
.mode MODE [TABLE] set output mode (list/csv/tabs/ascii/column/
132+
markdown/table/box/line/json/html/quote/insert)
133+
.headers on|off show or hide headers
134+
.separator SEP set the -list separator
135+
.nullvalue STR set the NULL placeholder
136+
.width N1 N2 ... set column widths (negative right-justifies, 0 auto)
137+
.limit [NAME [VAL]] show or set run-time limits
138+
.dump [TABLE] dump the database (or one table) as SQL
139+
.fullschema show the schema plus the ANALYZE (stat) tables
140+
.echo on|off echo each statement before running it
141+
.bail on|off stop after an error
142+
.changes on|off report changed-row counts after each statement
143+
.eqp on|off print the query plan before each statement
144+
.print TEXT... print TEXT
145+
.import FILE TABLE import delimited FILE into TABLE
146+
.output [FILE] send output to FILE (stdout if omitted)
147+
.once FILE send the next command's output to FILE
148+
.read FILE run SQL from FILE
149+
.open FILE close the current database and open FILE
150+
.backup [DB] FILE back up the database to FILE
151+
.restore [DB] FILE restore the database from FILE
152+
.show show current settings
153+
.help show this message
154+
.quit / .exit exit
155+
156+
"""
157+
}

0 commit comments

Comments
 (0)