import filepath import gleam/int import gleam/io import gleam/list import gleam/result import gleam/string import glint import simplifile import sqlode/internal/generate import sqlode/internal/verify import sqlode/internal/version pub fn app() -> glint.Glint(Nil) { // Help-text source: glint's auto-generated USAGE / SUBCOMMANDS / // FLAGS layout for top-level `--help`, and each command's own // `glint.command_help(...)` block for per-subcommand `--help`. // Previously a manually-authored `global_help` block was layered // on top of the auto-generated one, so `--help` printed the same // information twice in two different layouts. Per #467 we keep the // auto-generated path as the single source of truth so the two // layouts cannot drift. let base = glint.new() |> glint.with_name("sqlode") let with_styling = case should_emit_color() { True -> base |> glint.pretty_help(glint.default_pretty_help()) False -> base } with_styling |> glint.add(at: ["generate"], do: generate_command()) |> glint.add(at: ["init"], do: init_command()) |> glint.add(at: ["verify"], do: verify_command()) |> glint.add(at: ["version"], do: version_command()) } /// Decide whether `--help` output should carry ANSI escape codes. /// Wraps the FFI probes for stdout's TTY status and the `NO_COLOR` /// environment variable so the policy itself is a small pure /// function that the test suite can pin without mocking the BEAM /// primitives. See `decide_color_emission` for the rule. fn should_emit_color() -> Bool { decide_color_emission(is_stdout_terminal(), no_color_env()) } /// Pure decision rule: emit colour iff stdout is connected to a /// terminal AND `NO_COLOR` is not set (or is set to the empty /// string). Public so the test suite can exercise the policy /// without touching the Erlang FFI. See /// and CLIG ("colorize when stdout is a TTY only"). (#464) @internal pub fn decide_color_emission( is_terminal: Bool, no_color_env: Result(String, Nil), ) -> Bool { let no_color_active = case no_color_env { Ok(value) -> value != "" Error(Nil) -> False } is_terminal && !no_color_active } @target(erlang) @external(erlang, "sqlode_ffi", "is_stdout_terminal") fn is_stdout_terminal() -> Bool @target(javascript) fn is_stdout_terminal() -> Bool { False } @target(erlang) @external(erlang, "sqlode_ffi", "no_color_env") fn no_color_env() -> Result(String, Nil) @target(javascript) fn no_color_env() -> Result(String, Nil) { Error(Nil) } /// Candidate config filenames searched, in order, when --config is not /// given. The first match wins; if two or more files exist the command /// fails with a message listing them so the user picks explicitly. const config_candidates = [ "sqlode.yaml", "sqlode.yml", "sqlc.yaml", "sqlc.yml", "sqlc.json", ] fn generate_command() -> glint.Command(Nil) { { use config_path <- glint.flag( glint.string_flag("config") |> glint.flag_default("") |> glint.flag_help( "Path to config file. Default: auto-discover sqlode.yaml, sqlode.yml, sqlc.yaml, sqlc.yml, or sqlc.json in the current directory.", ), ) glint.command_help( "Parse SQL schema and queries, emit Gleam code. Without --config, generate auto-discovers sqlode.yaml, sqlode.yml, sqlc.yaml, sqlc.yml, or sqlc.json in the current directory and fails if more than one candidate exists. Examples: sqlode generate sqlode generate --config=./custom.yaml", fn() { glint.command(fn(_named_args, _args, flags) { let flag_value = config_path(flags) |> result.unwrap("") run_generate(flag_value) }) }, ) } } fn init_command() -> glint.Command(Nil) { { use output_path <- glint.flag( glint.string_flag("output") |> glint.flag_default("./sqlode.yaml") |> glint.flag_help( "Output path for generated config (default: ./sqlode.yaml)", ), ) use engine_flag <- glint.flag( glint.string_flag("engine") |> glint.flag_default("postgresql") |> glint.flag_help( "Target engine: postgresql | sqlite | mysql (default: postgresql)", ), ) use runtime_flag <- glint.flag( glint.string_flag("runtime") |> glint.flag_default("raw") |> glint.flag_help("Generated runtime: raw | native (default: raw)"), ) glint.command_help( "Scaffold a sqlode.yaml plus starter db/schema.sql and db/query.sql. Examples: sqlode init sqlode init --output=./config/sqlode.yaml sqlode init --engine=sqlite --runtime=native sqlode init --engine=mysql", fn() { glint.command(fn(_named_args, _args, flags) { let path = output_path(flags) |> result.unwrap("./sqlode.yaml") let engine = engine_flag(flags) |> result.unwrap("postgresql") let runtime = runtime_flag(flags) |> result.unwrap("raw") run_init(path, engine, runtime) }) }, ) } } fn verify_command() -> glint.Command(Nil) { { use config_path <- glint.flag( glint.string_flag("config") |> glint.flag_default("") |> glint.flag_help( "Path to config file. Default: auto-discover sqlode.yaml, sqlode.yml, sqlc.yaml, sqlc.yml, or sqlc.json in the current directory.", ), ) glint.command_help( "Run static verification on the project without writing files. Loads the config, parses every schema and query the generator would use, and runs the full analyser pass. Any query-analysis error, schema warning under strict_views, or policy violation (for example `query_parameter_limit`) is collected into a single report. The command exits non-zero when at least one finding is reported — suitable for CI gates before the generation step runs. Examples: sqlode verify sqlode verify --config=./custom.yaml", fn() { glint.command(fn(_named_args, _args, flags) { let flag_value = config_path(flags) |> result.unwrap("") run_verify(flag_value) }) }, ) } } fn run_verify(flag_value: String) -> Nil { case resolve_config_path(flag_value) { Error(message) -> { io.println_error("Error: " <> message) halt(1) } Ok(config_path) -> { io.println("Verifying config: " <> config_path) case verify.run(config_path) { Error(err) -> { io.println_error("Error: " <> verify.error_to_string(err)) halt(1) } Ok(report) -> { io.println(verify.report_to_string(report)) case report.findings { [] -> Nil _ -> halt(1) } } } } } } fn version_command() -> glint.Command(Nil) { glint.command_help("Print the sqlode version and exit.", fn() { glint.command(fn(_named_args, _args, _flags) { io.println("sqlode v" <> version.version) }) }) } fn run_generate(flag_value: String) -> Nil { case resolve_config_path(flag_value) { Error(message) -> { io.println_error("Error: " <> message) halt(1) } Ok(config_path) -> { io.println("Loading config from: " <> config_path) case generate.run(config_path) { Ok(written) -> { io.println("") io.println( "Successfully generated " <> int.to_string(list.length(written)) <> " files", ) list.each(written, fn(path) { io.println(" Generated: " <> path) }) } Error(error) -> { io.println_error("Error: " <> generate.error_to_string(error)) halt(1) } } } } } fn resolve_config_path(flag_value: String) -> Result(String, String) { case flag_value { "" -> autodiscover_config() explicit -> Ok(explicit) } } fn autodiscover_config() -> Result(String, String) { let found = list.filter(config_candidates, fn(path) { case simplifile.is_file(path) { Ok(True) -> True _ -> False } }) case found { [] -> Error( "No config file found. Looked for: " <> string.join(config_candidates, ", ") <> ". Create one with `sqlode init` or pass --config=.", ) [single] -> Ok(single) multiple -> Error( "Multiple config files found: " <> string.join(multiple, ", ") <> ". Pick one explicitly with --config=.", ) } } fn run_init(path: String, engine: String, runtime: String) -> Nil { case validate_init_flags(engine, runtime) { Error(msg) -> { io.println_error("Error: " <> msg) halt(1) } Ok(Nil) -> { let template = config_template(engine, runtime) case simplifile.is_file(path) { Ok(True) -> { io.println_error("Error: " <> path <> " already exists") halt(1) } _ -> { let parent_dir = filepath.directory_name(path) case ensure_parent_directory(parent_dir) { Error(msg) -> { io.println_error("Error: " <> msg) halt(1) } Ok(Nil) -> case simplifile.write(path, template) { Ok(_) -> { io.println("Created " <> path) create_stub_files(parent_dir, engine) } Error(err) -> { io.println_error( "Error: failed to write " <> path <> ": " <> simplifile.describe_error(err), ) halt(1) } } } } } } } } fn ensure_parent_directory(dir: String) -> Result(Nil, String) { case dir { "" | "." -> Ok(Nil) _ -> case simplifile.create_directory_all(dir) { Ok(Nil) -> Ok(Nil) Error(err) -> Error( "failed to create directory " <> dir <> ": " <> simplifile.describe_error(err), ) } } } fn validate_init_flags(engine: String, runtime: String) -> Result(Nil, String) { case engine { "postgresql" | "sqlite" | "mysql" -> Ok(Nil) _ -> Error( "unsupported engine \"" <> engine <> "\"; expected postgresql, sqlite, or mysql", ) } |> result.try(fn(_) { case runtime { "raw" | "native" -> Ok(Nil) _ -> Error( "unsupported runtime \"" <> runtime <> "\"; expected raw or native", ) } }) } fn config_template(engine: String, runtime: String) -> String { "version: \"2\" sql: - schema: \"db/schema.sql\" queries: \"db/query.sql\" engine: \"" <> engine <> "\"\n" <> " gen:\n" <> " gleam:\n" <> " out: \"src/db\"\n" <> " runtime: \"" <> runtime <> "\"\n" } fn create_stub_files(base_dir: String, engine: String) -> Nil { let schema_content = starter_schema(engine) let query_content = starter_query(engine) let db_dir = filepath.join(base_dir, "db") let schema_path = filepath.join(db_dir, "schema.sql") let query_path = filepath.join(db_dir, "query.sql") let _create_dir_result = simplifile.create_directory_all(db_dir) case simplifile.is_file(schema_path) { Ok(True) -> io.println(" Skipped " <> schema_path <> " (already exists)") _ -> case simplifile.write(schema_path, schema_content) { Ok(_) -> io.println(" Created " <> schema_path) Error(_) -> io.println(" Warning: failed to create " <> schema_path) } } case simplifile.is_file(query_path) { Ok(True) -> io.println(" Skipped " <> query_path <> " (already exists)") _ -> case simplifile.write(query_path, query_content) { Ok(_) -> io.println(" Created " <> query_path) Error(_) -> io.println(" Warning: failed to create " <> query_path) } } } fn starter_schema(engine: String) -> String { case engine { "sqlite" -> "CREATE TABLE authors ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, bio TEXT, created_at TEXT NOT NULL ); " "mysql" -> "CREATE TABLE authors ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name TEXT NOT NULL, bio TEXT, created_at DATETIME NOT NULL ); " _ -> "CREATE TABLE authors ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, bio TEXT, created_at TIMESTAMP NOT NULL ); " } } fn starter_query(engine: String) -> String { let get_placeholder = case engine { "mysql" | "sqlite" -> "?" _ -> "$1" } "-- name: GetAuthor :one SELECT id, name, bio FROM authors WHERE id = " <> get_placeholder <> ";\n" <> "\n" <> "-- name: ListAuthors :many\n" <> "SELECT id, name\n" <> "FROM authors\n" <> "ORDER BY name;\n" <> "\n" <> "-- name: CreateAuthor :exec\n" <> "INSERT INTO authors (name, bio)\n" <> "VALUES (sqlode.arg(author_name), sqlode.narg(bio));\n" } @target(erlang) /// Exit the process with the given status code, flushing I/O before shutdown. /// Uses init:stop/1 which triggers a graceful OTP shutdown instead of the /// abrupt erlang:halt/1. @external(erlang, "init", "stop") fn halt(code: Int) -> Nil @target(javascript) fn halt(_code: Int) -> Nil { Nil }