///|
/// Tesseract 4D — command line front end.
///
/// The C# original was an Avalonia desktop app. MoonBit has no comparable GUI
/// toolkit, and this toolchain's core library exposes neither a stdin reader
/// nor file I/O, so the front end is a **one-shot program driven by its
/// arguments**: each argument is either a SQL statement or a `.command`, they
/// run in order against one shared database, and results go to stdout.
///
/// ```sh
/// moon run cmd/main -- .status
/// moon run cmd/main -- .demo
/// moon run cmd/main -- "INSERT INTO tesseract (key,value) VALUES ('a','1') AT 5;" "SELECT * FROM tesseract;"
/// moon run cmd/main -- .solve .verify
/// moon run cmd/main -- .export tsr > dump.tsr
/// ```
///
/// `--side N` chooses how many cells each of the four axes has. The default is 3,
/// the 81-cell lattice every existing `.tsr` and `.db` file was written under, so
/// an invocation without the flag behaves exactly as it always has:
///
/// ```sh
/// moon run cmd/main -- --side 4 .status # 256 cells, digits 1..4
/// moon run cmd/main -- --side 4 .solve .verify
/// ```
///
/// The upper bound is 215, not the number the arithmetic alone would allow:
/// `Array` is indexed by `Int`, which is 32-bit on this toolchain, so `side^4`
/// cells have to stay addressable. `lattice_of` refuses anything larger and says
/// so rather than truncating.
///
/// A REPL is a one-line shell loop over the same commands:
///
/// ```sh
/// while read -r line; do moon run cmd/main -- "$line"; done
/// ```
///|
/// CLI-wide state: which stream chatter goes to, and whether a `.quit` was
/// asked for.
struct Cli {
mut to_stderr : Bool
mut quit : Bool
}
///|
let cli : Cli = { to_stderr: false, quit: false, }
///|
/// A line of chatter, routed by whether this run is producing a file.
///
/// A run that includes `.export` is writing a document to stdout, so the
/// banner, the echoed log and the response to any seeding statement all have to
/// go to stderr - otherwise they land inside the file, and the file's own
/// importer then rejects it as invalid JSON. The documented round trip
/// (`moon run cmd/main -- .export tsr > dump.tsr`) cannot work otherwise.
fn out(text : String) -> Unit {
if cli.to_stderr {
eprintln(text)
} else {
println(text)
}
}
///|
/// Always-diagnostic output; never part of a document.
fn warn(text : String) -> Unit {
eprintln(text)
}
///|
/// Pulls a `--side N` or `--side=N` option out of the argument list.
///
/// Returns the raw value text ("" when the option is absent), whether the option
/// appeared with no value after it, and the arguments with the option removed.
///
/// The option is allowed to sit anywhere among the commands, because a flag that
/// only works in first position is a flag people get wrong. Only the *first*
/// occurrence counts: `--side 3 --side 4` taking the last one would mean the
/// earlier flag silently did nothing.
///
/// Both spellings consume their own argument. `--side=N` once set the value and
/// left itself in the list, so it went on to be executed as SQL and the run
/// reported "Unsupported SQL statement: --side=3" after having already built the
/// database at the side that flag asked for.
///
/// The raw text is handed back rather than a side, so that "not a number" and
/// "out of range" are decided by `lattice_of` and reported the same way wherever
/// the value came from.
fn parse_side_option(args : ArrayView[String]) -> (String, Bool, Array[String]) {
let rest : Array[String] = []
let mut raw = ""
let mut missing = false
let mut i = 0
while i < args.length() {
let a = args[i]
// Recognised on every pass, not just until the first value lands: a second
// `--side` has to be *consumed*, or it and its value go on to be executed as
// SQL and the run fails on a flag it already honoured once.
let mut consumed = false
if a == "--side" {
if i + 1 < args.length() {
if raw == "" {
raw = args[i + 1]
}
i = i + 1 // also consume the value
} else if raw == "" {
missing = true
}
consumed = true
} else if @tesseract.starts_with(a, "--side=") {
if raw == "" {
raw = @tesseract.slice(a, 7, a.length())
}
consumed = true
}
if !consumed {
rest.push(a)
}
i = i + 1
}
(raw, missing, rest)
}
///|
/// Resolves `--side` into a lattice, and returns it with the remaining
/// arguments.
///
/// A bad value is refused here, before any statement runs, so `--side` can never
/// leave a half-built database behind to be mistaken for a successful one.
fn lattice_from_args(
args : ArrayView[String],
) -> Result[(@tesseract.Lattice, Array[String]), String] {
let (raw, missing, rest) = parse_side_option(args)
if missing {
return Err("--side needs a value, as --side 4 or --side=4")
}
if raw == "" {
return Ok((@tesseract.default_lattice(), rest))
}
match @tesseract.parse_int(raw) {
None => Err("--side needs an integer, got '" + raw + "'")
Some(n) =>
match @tesseract.lattice_of(n.to_int64()) {
Ok(l) => Ok((l, rest))
Err(message) => Err("--side " + message)
}
}
}
///|
fn main {
let all = @env.args()
// args[0] is the path of the executable itself; the rest are ours.
let argv = if all.length() > 0 { all[1:] } else { all }
// Everything below reads the lattice's shape off this one value, so the
// banner, the cursor range, `.solve` and `.export` all follow `--side`
// together. The default stays 3, which is the lattice every existing `.tsr`
// and `.db` file was written under.
let (lat, args) = match lattice_from_args(argv) {
Ok(pair) => pair
Err(message) => {
warn(message)
return
}
}
// The banner is a diagnostic, not output: `.export` writes a document to
// stdout and this command is documented as redirecting it into a file, so
// anything else on stdout ends up inside that file.
warn(
"Tesseract 4D - " +
@tesseract.lattice_shape_text(lat) +
" lattice, " +
@tesseract.sudoku_line_count(lat).to_string() +
" sudoku lines",
)
if args.length() == 0 {
print_help()
return
}
let logger = @tesseract.new_logger("tesseract")
// A real command line gets real timestamps. The library default is the logical
// counter, which is what keeps the tests comparable between runs; a surface
// that prints stamps for a person should say when it happened, not how many
// log lines preceded it. Set before `new_db`, which keeps its own copy.
@tesseract.TesseractLogger::set_clock(logger, @tesseract.system_clock())
let db = @tesseract.new_db("tesseract", lat, logger)
@tesseract.TesseractLogger::set_echo(
@tesseract.Tesseract::get_logger(db),
true,
)
let mut k = 0
while k < args.length() {
if @tesseract.starts_with(args[k], ".export") {
cli.to_stderr = true
}
k = k + 1
}
let mut i = 0
while i < args.length() {
// A command and its format arrive as two argv entries under ordinary shell
// word-splitting: `-- .export md` is `.export` and `md`. A bare `.export`
// matches on its own and exports the *default* format, then `md` runs as
// SQL and fails - so `> dump.md` would hold an `.tsr` document under a
// misleading name, with an error line beside it. Joining here makes the
// split form mean the same as the quoted one.
let mut arg = args[i]
if arg == ".export" &&
i + 1 < args.length() &&
args[i + 1].length() > 0 &&
args[i + 1].at(0) != '.' {
arg = arg + " " + args[i + 1]
i = i + 1
}
if cli.quit {
i = i + 1
continue
}
if arg == ".help" || arg == "-h" || arg == "--help" {
print_help()
} else if !handle_dot(db, arg) {
out(@tesseract.format_response(@tesseract.execute(db, arg)))
}
i = i + 1
}
}
///|
/// The command list, also printed when invoked with no arguments.
fn print_help() -> Unit {
out("")
out("usage: moon run cmd/main -- [--side N] ...")
out("")
out("options:")
out(" --side N cells per axis, 1..215 (default 3)")
out("")
out("commands:")
out(" .help this list")
out(" .status lattice occupancy and cursor")
out(" .grid draw the lattice view")
out(" .pos N | .pos (x,y,z,w) move the insert cursor")
out(" .neighbours N occupied cells sharing a line with N")
out(" .solve fill every cell from the closed-form solution")
out(" .verify check the digits against the sudoku rules")
out(" .demo insert/select/update/delete tour")
out(" .export tsr|md|sql write the database to stdout")
out("")
out("anything else is executed as SQL, so statements keep their own quoting.")
out(" moon run cmd/main -- " + example())
}
///|
/// The example invocation, built so the quoting survives being a literal.
fn example() -> String {
let b = StringBuilder::StringBuilder()
b.write_char('"')
b.write_string("INSERT INTO tesseract (key,value) VALUES ('a','1') AT 5;")
b.write_char('"')
b.to_string()
}
///|
/// Runs a `.command`. Returns false when the line is not a dot command.
fn handle_dot(db : @tesseract.Tesseract, line : String) -> Bool {
if line.length() == 0 || line.at(0) != '.' {
return false
}
if line == ".status" {
print_status(db)
return true
}
if line == ".grid" {
let cursor = @tesseract.Tesseract::status(db).current_position
out(@tesseract.render_lattice(db, cursor))
return true
}
if @tesseract.starts_with(line, ".pos ") {
move_cursor(
db,
@tesseract.trim_ws(@tesseract.slice(line, 5, line.length())),
)
return true
}
if @tesseract.starts_with(line, ".neighbours ") {
let arg = @tesseract.trim_ws(@tesseract.slice(line, 12, line.length()))
match @tesseract.parse_int(arg) {
Some(p) =>
out(
@tesseract.format_response(
@tesseract.Tesseract::peer_query(db, p.to_int64()),
),
)
None => out("expected a position")
}
return true
}
if line == ".solve" {
solve(db)
return true
}
if line == ".verify" {
verify(db)
return true
}
if line == ".demo" {
run_demo(db)
return true
}
if @tesseract.starts_with(line, ".export") {
let what = @tesseract.trim_ws(@tesseract.slice(line, 7, line.length()))
if what == "md" || what == "markdown" {
println(@tesseract.export_markdown(db))
} else if what == "sql" {
println(@tesseract.export_sql(db))
} else if what == "" || what == "tsr" || what == "json" {
println(@tesseract.export_tsr(db))
} else {
// Anything else used to fall through to the `.tsr` branch, so a typo
// produced a valid-looking file in a format nobody asked for.
warn("unknown export format '" + what + "'; use tsr, md or sql")
return true
}
return true
}
if line == ".quit" || line == ".exit" {
// Stop consuming arguments. It used to `return true` and carry on, so a
// script that ended with `.quit` still ran everything after it.
cli.quit = true
return true
}
if line == ".help" || line == "-h" || line == "--help" {
print_help()
return true
}
out("unknown command: " + line + " (try .help)")
true
}
///|
/// Occupancy and cursor readout.
fn print_status(db : @tesseract.Tesseract) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
let s = @tesseract.Tesseract::status(db)
let here = @tesseract.coords_of(lat, s.current_position).unwrap()
out("database: " + s.db_name)
out(
"lattice : " +
@tesseract.lattice_shape_text(lat) +
" = " +
@tesseract.sudoku_line_count(lat).to_string() +
" lines, " +
(@tesseract.DIM * lat.side()).to_string() +
" slabs",
)
out("records : " + s.current_count.to_string())
out("digits : " + s.digit_count.to_string())
out(
"cursor : " +
s.current_position.to_string() +
" " +
@tesseract.format_coords(here),
)
}
///|
/// `.pos` — accepts a position or an `(x,y,z,w)` address.
fn move_cursor(db : @tesseract.Tesseract, arg : String) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
let resolved = if @tesseract.starts_ci(arg, "(") {
match @tesseract.parse_coords(lat, arg) {
Some(c) => Some(@tesseract.position_of(lat, c))
None => None
}
} else {
match @tesseract.parse_int(arg) {
Some(p) => Some(p.to_int64())
None => None
}
}
match resolved {
Some(p) =>
if @tesseract.position_in_bounds(lat, p) {
@tesseract.Tesseract::set_position(db, p)
out(
"cursor -> " +
p.to_string() +
" " +
@tesseract.format_coords(@tesseract.coords_of(lat, p).unwrap()),
)
} else {
out(
"out of range: " +
p.to_string() +
" (expected 1.." +
lat.last_position().to_string() +
")",
)
}
None => out("expected a position or an (x,y,z,w) address")
}
}
///|
/// `.solve` — fills every cell from the closed-form solution.
fn solve(db : @tesseract.Tesseract) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
out(
"filling every cell from digit(x,y,z,w) = (x+y+z+w) mod " +
lat.side().to_string() +
" + 1",
)
let digits = @tesseract.canonical_digits(lat)
let mut p = lat.first_position()
let mut ok = 0
let mut first_failure = ""
while p <= lat.last_position() {
let b = StringBuilder::StringBuilder()
b.write_string("INSERT INTO tesseract (key, value, digit) VALUES ('cell_")
b.write_object(p)
b.write_string("', '{\"pos\": ")
b.write_object(p)
b.write_string("}', ")
b.write_object(digits[@tesseract.slot_of(p)])
b.write_string(") AT ")
b.write_object(p)
b.write_char(';')
let r = @tesseract.execute(db, b.to_string())
if r.success {
ok = ok + 1
} else if first_failure == "" {
// A closed form can only overwrite a cell whose current digit does not
// clash with its neighbours, so filling a half-finished puzzle is
// legitimately refused at some positions. Reporting only the count made
// `placed 80 digits` look like a success.
first_failure = " first refused at position " +
p.to_string() +
": " +
r.error_code +
" (" +
r.error +
")"
}
p = p + 1L
}
out("placed " + ok.to_string() + " digits" + first_failure)
print_status(db)
}
///|
/// `.verify` — checks the stored digits against the line and slab rules.
fn verify(db : @tesseract.Tesseract) -> Unit {
let lat = @tesseract.Tesseract::lattice_of_db(db)
let digits = @tesseract.empty_digits(lat)
let mut p = lat.first_position()
while p <= lat.last_position() {
match @tesseract.Tesseract::cell_at(db, p) {
Some(cell) => @tesseract.set_digit(lat, digits, p, cell.digit)
None => ()
}
p = p + 1L
}
let lv = @tesseract.line_violations(lat, digits)
let sv = @tesseract.slab_violations(lat, digits)
out("line violations : " + lv.length().to_string())
report(lv)
out("slab imbalances : " + sv.length().to_string())
report(sv)
if lv.length() == 0 && sv.length() == 0 {
out("OK - every axis-parallel line holds 1.." + lat.max_digit().to_string())
}
}
///|
/// Prints each message on its own line.
fn report(items : Array[String]) -> Unit {
let mut i = 0
while i < items.length() {
out(" " + items[i])
i = i + 1
}
}
///|
/// `.demo` — the tour the C# view model's demo button ran, at 4D scale.
fn run_demo(db : @tesseract.Tesseract) -> Unit {
out("1. clear")
ignore(@tesseract.execute(db, "DELETE FROM tesseract;"))
out("2. insert three records")
ignore(
@tesseract.execute(
db, "INSERT INTO tesseract (key, value) VALUES ('user_001', '{\"age\": 25}');",
),
)
ignore(
@tesseract.execute(
db, "INSERT INTO tesseract (key, value) VALUES ('user_002', '{\"age\": 30}');",
),
)
ignore(
@tesseract.execute(
db, "INSERT INTO tesseract (key, value) VALUES ('user_003', '{\"age\": 28}');",
),
)
out("3. select all")
out(
@tesseract.format_response(
@tesseract.execute(db, "SELECT * FROM tesseract;"),
),
)
out("4. update user_001")
ignore(
@tesseract.execute(
db, "UPDATE tesseract SET value = '{\"age\": 26}' WHERE key = 'user_001';",
),
)
out("5. read it back")
out(
@tesseract.format_response(
@tesseract.execute(db, "SELECT * FROM tesseract WHERE key = 'user_001';"),
),
)
out("6. place a sudoku digit at position 1")
ignore(
@tesseract.execute(
db, "INSERT INTO tesseract (key, value, digit) VALUES ('d1', '{\"n\": 1}', 1) AT 1;",
),
)
out("7. position 3 shares a line with it, so digit 1 is refused")
let clash = @tesseract.execute(
db, "INSERT INTO tesseract (key, value, digit) VALUES ('d2', '{\"n\": 2}', 1) AT 3;",
)
out(" -> " + clash.error_code + " (" + clash.error + ")")
out("8. occupied neighbours of position 1")
out(@tesseract.format_response(@tesseract.Tesseract::peer_query(db, 1)))
out("9. delete user_003 and report")
ignore(
@tesseract.execute(db, "DELETE FROM tesseract WHERE key = 'user_003';"),
)
print_status(db)
out("demo complete")
}