Skip to main content

syntaqlite_cli/
lib.rs

1// Copyright 2025 The syntaqlite Authors. All rights reserved.
2// Licensed under the Apache License, Version 2.0.
3
4//! Library entry point for the `syntaqlite` CLI.
5//!
6//! Downstream crates can build their own CLI binary that pre-specifies a
7//! dialect by implementing [`CliApp`] and calling [`run`]. The default
8//! `syntaqlite` binary in this crate is a thin wrapper that does exactly that
9//! with the bundled `SQLite` dialect.
10
11use std::path::PathBuf;
12
13use clap::CommandFactory;
14use syntaqlite::any::AnyDialect;
15
16mod cli;
17mod commands;
18mod config;
19mod util;
20
21use cli::{Cli, Command};
22use config::{ConfigMode, ProjectConfig};
23
24/// Configuration trait for a `syntaqlite` CLI binary.
25///
26/// Implementors describe the program name, default dialect, and which dialect
27/// override surfaces are exposed. Default methods produce the stock CLI's
28/// behaviour minus the dialect itself; downstream wrappers typically override
29/// [`CliApp::name`] and [`CliApp::default_dialect`].
30///
31/// All methods have defaults so adding new ones is non-breaking.
32pub trait CliApp {
33    /// Program name shown in `--help` and used as `argv[0]`.
34    fn name(&self) -> &'static str;
35
36    /// One-line description for `--help`.
37    fn about(&self) -> &'static str {
38        "SQL formatting and analysis tools"
39    }
40
41    /// Version string for `--version`.
42    ///
43    /// The default returns this crate's version. Wrapper crates should
44    /// override to report their own version.
45    fn version(&self) -> &'static str {
46        env!("CARGO_PKG_VERSION")
47    }
48
49    /// Dialect baked into this binary. `None` means the user must supply one
50    /// via `--dialect` (which requires [`CliApp::allow_dialect_override`]).
51    fn default_dialect(&self) -> Option<AnyDialect> {
52        None
53    }
54
55    /// Whether `--dialect` / `--dialect-name` are exposed to end users.
56    /// When the `dynload` feature is disabled the flags are absent
57    /// regardless of this setting.
58    fn allow_dialect_override(&self) -> bool {
59        false
60    }
61
62    /// Whether `--sqlite-version` / `--sqlite-cflag` are exposed.
63    /// Set to `true` only for SQLite-derived dialects where these knobs apply.
64    fn allow_sqlite_tuning(&self) -> bool {
65        false
66    }
67}
68
69/// Run the CLI with the given app configuration.
70///
71/// Reads `argv` from the process. On error, prints the message to stderr and
72/// exits with status 1. On `--help` / `--version`, exits 0.
73pub fn run<A: CliApp>(app: &A) {
74    use clap::FromArgMatches;
75
76    let cmd = build_command(app);
77    let matches = cmd
78        .try_get_matches_from(std::env::args())
79        .unwrap_or_else(|e| e.exit());
80    let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit());
81
82    if let Err(e) = enforce_visibility(app, &cli) {
83        eprintln!("error: {e}");
84        std::process::exit(2);
85    }
86
87    if let Err(e) = dispatch(cli, app.default_dialect()) {
88        eprintln!("error: {e}");
89        std::process::exit(1);
90    }
91}
92
93/// Build the [`clap::Command`] for the given app, applying runtime visibility
94/// rules from the [`CliApp`] trait.
95fn build_command<A: CliApp>(app: &A) -> clap::Command {
96    let name = app.name();
97    let cmd = Cli::command()
98        .name(name)
99        .bin_name(name)
100        .about(app.about())
101        .version(app.version());
102
103    let cmd = if app.allow_sqlite_tuning() {
104        cmd
105    } else {
106        cmd.mut_arg("sqlite_version", |a: clap::Arg| a.hide(true))
107            .mut_arg("sqlite_cflag", |a: clap::Arg| a.hide(true))
108    };
109
110    #[cfg(feature = "dynload")]
111    let cmd = if app.allow_dialect_override() {
112        cmd
113    } else {
114        cmd.mut_arg("dialect_path", |a: clap::Arg| a.hide(true))
115            .mut_arg("dialect_name", |a: clap::Arg| a.hide(true))
116    };
117
118    cmd
119}
120
121/// Reject hidden flags that the trait says aren't allowed but were still passed.
122fn enforce_visibility<A: CliApp>(app: &A, cli: &Cli) -> Result<(), String> {
123    if !app.allow_sqlite_tuning() {
124        if cli.sqlite_version.is_some() {
125            return Err("--sqlite-version is not supported by this CLI".to_owned());
126        }
127        if !cli.sqlite_cflag.is_empty() {
128            return Err("--sqlite-cflag is not supported by this CLI".to_owned());
129        }
130    }
131
132    #[cfg(feature = "dynload")]
133    if !app.allow_dialect_override() && cli.dialect_path.is_some() {
134        return Err("--dialect is not supported by this CLI".to_owned());
135    }
136
137    Ok(())
138}
139
140// ── Dispatch ──────────────────────────────────────────────────────────────
141
142fn require_dialect(dialect: Option<AnyDialect>) -> Result<AnyDialect, String> {
143    dialect.ok_or_else(|| {
144        "this command requires a dialect; rebuild with --features=bundled-sqlite-dialect \
145         or pass --dialect (with --features=dynload)"
146            .to_string()
147    })
148}
149
150/// Resolve the dialect, merge sqlite tuning from CLI + config file, then
151/// hand off to the appropriate command module.
152fn dispatch(cli: Cli, default_dialect: Option<AnyDialect>) -> Result<(), String> {
153    // Destructure upfront so the global-arg fields can be borrowed while
154    // `command` is later moved into the match.
155    let Cli {
156        config,
157        no_config,
158        #[cfg(feature = "dynload")]
159        dialect_path,
160        #[cfg(feature = "dynload")]
161        dialect_name,
162        sqlite_version,
163        sqlite_cflag,
164        command,
165    } = cli;
166
167    let config_mode = if no_config {
168        ConfigMode::Disabled
169    } else if let Some(ref path) = config {
170        ConfigMode::Explicit(path)
171    } else {
172        ConfigMode::Discover
173    };
174
175    #[cfg(feature = "dynload")]
176    let base = match dialect_path {
177        Some(path) => Some(
178            AnyDialect::load(&path, dialect_name.as_deref()).unwrap_or_else(|e| {
179                eprintln!("error: {e}");
180                std::process::exit(1);
181            }),
182        ),
183        None => default_dialect,
184    };
185    #[cfg(not(feature = "dynload"))]
186    let base = default_dialect;
187
188    // Discover config early so it can contribute sqlite-version / sqlite-cflags
189    // merging, and so commands can reuse the discovered config.
190    let project_config = config::resolve(&config_mode);
191    let configured = base
192        .map(|d| {
193            apply_sqlite_tuning(
194                d,
195                sqlite_version.as_ref(),
196                &sqlite_cflag,
197                project_config.as_ref(),
198            )
199        })
200        .transpose()?;
201
202    run_command(command, configured, &config_mode)
203}
204
205fn apply_sqlite_tuning(
206    dialect: AnyDialect,
207    cli_version: Option<&String>,
208    cli_cflags: &[String],
209    project_config: Option<&(ProjectConfig, PathBuf)>,
210) -> Result<AnyDialect, String> {
211    use syntaqlite::util::{SqliteFlag, SqliteFlags, SqliteVersion};
212
213    // CLI flags take precedence over config file values.
214    let version =
215        cli_version.or_else(|| project_config.and_then(|(c, _)| c.sqlite_version.as_ref()));
216    let cflags: &[String] = if cli_cflags.is_empty() {
217        project_config
218            .map(|(c, _)| c.sqlite_cflags.as_slice())
219            .unwrap_or_default()
220    } else {
221        cli_cflags
222    };
223
224    let mut dialect = dialect;
225    if let Some(v) = version {
226        let ver = SqliteVersion::parse_with_latest(v)
227            .map_err(|e| format!("invalid sqlite-version {v:?}: {e}"))?;
228        dialect = dialect.with_version(ver);
229    }
230    if !cflags.is_empty() {
231        let mut flags = SqliteFlags::default();
232        for name in cflags {
233            let flag = SqliteFlag::from_name(name)
234                .ok_or_else(|| format!("unknown sqlite-cflag: {name}"))?;
235            flags = flags.with(flag);
236        }
237        dialect = dialect.with_cflags(flags);
238    }
239    Ok(dialect)
240}
241
242fn run_command(
243    command: Command,
244    dialect: Option<AnyDialect>,
245    config: &ConfigMode<'_>,
246) -> Result<(), String> {
247    match command {
248        Command::Parse(args) => commands::parse::run(&require_dialect(dialect)?, &args),
249        Command::Fmt(args) => commands::fmt::run(&require_dialect(dialect)?, config, &args),
250        Command::Analyze(args) => commands::analyze::run(&require_dialect(dialect)?, config, &args),
251        Command::Lineage(args) => commands::lineage::run(&require_dialect(dialect)?, config, &args),
252        Command::Tokenize(args) => commands::tokenize::run(&require_dialect(dialect)?, &args),
253        Command::Lsp => commands::lsp::run(require_dialect(dialect)?, config),
254        #[cfg(feature = "mcp")]
255        Command::Mcp => commands::mcp::run(require_dialect(dialect)?),
256        Command::Serve { protocol } => match protocol {
257            cli::ServeProtocol::Json => commands::serve::run_json(&require_dialect(dialect)?),
258        },
259        #[cfg(feature = "codegen")]
260        Command::Dialect { command } => match command {
261            cli::DialectSubcommand::Generate(args) => commands::codegen::generate(&args),
262        },
263        #[cfg(feature = "codegen")]
264        Command::DialectTool(cmd) => commands::codegen::dispatch_tool(cmd),
265        Command::Version => {
266            println!("syntaqlite {}", env!("CARGO_PKG_VERSION"));
267            Ok(())
268        }
269    }
270}
271
272/// Stock CLI configuration: the bundled `SQLite` dialect with all override
273/// surfaces enabled.
274#[cfg(feature = "bundled-sqlite-dialect")]
275pub struct Stock;
276
277#[cfg(feature = "bundled-sqlite-dialect")]
278impl CliApp for Stock {
279    fn name(&self) -> &'static str {
280        "syntaqlite"
281    }
282
283    fn default_dialect(&self) -> Option<AnyDialect> {
284        Some(syntaqlite::sqlite_dialect().into())
285    }
286
287    fn allow_dialect_override(&self) -> bool {
288        true
289    }
290
291    fn allow_sqlite_tuning(&self) -> bool {
292        true
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    struct Locked;
301    impl CliApp for Locked {
302        fn name(&self) -> &'static str {
303            "locked"
304        }
305        fn about(&self) -> &'static str {
306            "locked-down test CLI"
307        }
308    }
309
310    struct Open;
311    impl CliApp for Open {
312        fn name(&self) -> &'static str {
313            "open"
314        }
315        fn allow_dialect_override(&self) -> bool {
316            true
317        }
318        fn allow_sqlite_tuning(&self) -> bool {
319            true
320        }
321    }
322
323    fn parse(app: &impl CliApp, argv: &[&str]) -> Result<Cli, clap::Error> {
324        use clap::FromArgMatches;
325        let matches = build_command(app).try_get_matches_from(argv)?;
326        Cli::from_arg_matches(&matches)
327    }
328
329    fn help_text(app: &impl CliApp) -> String {
330        build_command(app).render_help().to_string()
331    }
332
333    #[test]
334    fn build_command_uses_app_name_and_about() {
335        let help = help_text(&Locked);
336        assert!(help.contains("locked"), "name missing from help:\n{help}");
337        assert!(
338            help.contains("locked-down test CLI"),
339            "about missing from help:\n{help}"
340        );
341    }
342
343    #[test]
344    fn sqlite_tuning_flags_hidden_when_disallowed() {
345        let help = help_text(&Locked);
346        assert!(
347            !help.contains("--sqlite-version"),
348            "--sqlite-version should be hidden:\n{help}"
349        );
350        assert!(
351            !help.contains("--sqlite-cflag"),
352            "--sqlite-cflag should be hidden:\n{help}"
353        );
354    }
355
356    #[test]
357    fn sqlite_tuning_flags_visible_when_allowed() {
358        let help = help_text(&Open);
359        assert!(
360            help.contains("--sqlite-version"),
361            "--sqlite-version should be visible:\n{help}"
362        );
363    }
364
365    #[test]
366    fn enforce_visibility_rejects_sqlite_tuning_when_disallowed() {
367        let cli =
368            parse(&Locked, &["locked", "--sqlite-version", "3.47.0", "parse"]).expect("clap parse");
369        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
370        assert!(err.contains("--sqlite-version"), "got: {err}");
371    }
372
373    #[test]
374    fn enforce_visibility_rejects_sqlite_cflag_when_disallowed() {
375        let cli = parse(
376            &Locked,
377            &["locked", "--sqlite-cflag", "SQLITE_ENABLE_FTS5", "parse"],
378        )
379        .expect("clap parse");
380        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
381        assert!(err.contains("--sqlite-cflag"), "got: {err}");
382    }
383
384    #[test]
385    fn enforce_visibility_accepts_sqlite_tuning_when_allowed() {
386        let cli =
387            parse(&Open, &["open", "--sqlite-version", "3.47.0", "parse"]).expect("clap parse");
388        enforce_visibility(&Open, &cli).expect("should accept");
389    }
390
391    #[cfg(feature = "dynload")]
392    #[test]
393    fn dialect_override_flags_hidden_when_disallowed() {
394        let help = help_text(&Locked);
395        assert!(
396            !help.contains("--dialect "),
397            "--dialect should be hidden:\n{help}"
398        );
399    }
400
401    #[cfg(feature = "dynload")]
402    #[test]
403    fn dialect_override_flags_visible_when_allowed() {
404        let help = help_text(&Open);
405        assert!(
406            help.contains("--dialect "),
407            "--dialect should be visible:\n{help}"
408        );
409    }
410
411    #[cfg(feature = "dynload")]
412    #[test]
413    fn enforce_visibility_rejects_dialect_when_disallowed() {
414        let cli =
415            parse(&Locked, &["locked", "--dialect", "/tmp/x.so", "parse"]).expect("clap parse");
416        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
417        assert!(err.contains("--dialect"), "got: {err}");
418    }
419
420    #[cfg(feature = "dynload")]
421    #[test]
422    fn enforce_visibility_accepts_dialect_when_allowed() {
423        let cli = parse(&Open, &["open", "--dialect", "/tmp/x.so", "parse"]).expect("clap parse");
424        enforce_visibility(&Open, &cli).expect("should accept");
425    }
426
427    #[cfg(feature = "bundled-sqlite-dialect")]
428    #[test]
429    fn stock_impl_exposes_all_override_surfaces() {
430        assert!(Stock.allow_dialect_override());
431        assert!(Stock.allow_sqlite_tuning());
432        assert!(Stock.default_dialect().is_some());
433    }
434}