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            // SAFETY: `--dialect` is an explicit user-supplied shared library
179            // path expected to export a valid syntaqlite dialect template ABI.
180            unsafe { AnyDialect::load(&path, dialect_name.as_deref()) }.unwrap_or_else(|e| {
181                eprintln!("error: {e}");
182                std::process::exit(1);
183            }),
184        ),
185        None => default_dialect,
186    };
187    #[cfg(not(feature = "dynload"))]
188    let base = default_dialect;
189
190    // Discover config early so it can contribute sqlite-version / sqlite-cflags
191    // merging, and so commands can reuse the discovered config.
192    let project_config = config::resolve(&config_mode);
193    let configured = base
194        .map(|d| {
195            apply_sqlite_tuning(
196                d,
197                sqlite_version.as_ref(),
198                &sqlite_cflag,
199                project_config.as_ref(),
200            )
201        })
202        .transpose()?;
203
204    run_command(command, configured, &config_mode)
205}
206
207fn apply_sqlite_tuning(
208    dialect: AnyDialect,
209    cli_version: Option<&String>,
210    cli_cflags: &[String],
211    project_config: Option<&(ProjectConfig, PathBuf)>,
212) -> Result<AnyDialect, String> {
213    use syntaqlite::util::{SqliteFlag, SqliteFlags, SqliteVersion};
214
215    // CLI flags take precedence over config file values.
216    let version =
217        cli_version.or_else(|| project_config.and_then(|(c, _)| c.sqlite_version.as_ref()));
218    let cflags: &[String] = if cli_cflags.is_empty() {
219        project_config
220            .map(|(c, _)| c.sqlite_cflags.as_slice())
221            .unwrap_or_default()
222    } else {
223        cli_cflags
224    };
225
226    let mut dialect = dialect;
227    if let Some(v) = version {
228        let ver = SqliteVersion::parse_with_latest(v)
229            .map_err(|e| format!("invalid sqlite-version {v:?}: {e}"))?;
230        dialect = dialect.with_version(ver);
231    }
232    if !cflags.is_empty() {
233        let mut flags = SqliteFlags::default();
234        for name in cflags {
235            let flag = SqliteFlag::from_name(name)
236                .ok_or_else(|| format!("unknown sqlite-cflag: {name}"))?;
237            flags = flags.with(flag);
238        }
239        dialect = dialect.with_cflags(flags);
240    }
241    Ok(dialect)
242}
243
244fn run_command(
245    command: Command,
246    dialect: Option<AnyDialect>,
247    config: &ConfigMode<'_>,
248) -> Result<(), String> {
249    match command {
250        Command::Parse(args) => commands::parse::run(&require_dialect(dialect)?, &args),
251        Command::Fmt(args) => commands::fmt::run(&require_dialect(dialect)?, config, &args),
252        Command::Analyze(args) => commands::analyze::run(&require_dialect(dialect)?, config, &args),
253        Command::Lineage(args) => commands::lineage::run(&require_dialect(dialect)?, config, &args),
254        Command::Tokenize(args) => commands::tokenize::run(&require_dialect(dialect)?, &args),
255        Command::Lsp => commands::lsp::run(require_dialect(dialect)?, config),
256        #[cfg(feature = "mcp")]
257        Command::Mcp => commands::mcp::run(require_dialect(dialect)?),
258        Command::Serve { protocol } => match protocol {
259            cli::ServeProtocol::Json => commands::serve::run_json(&require_dialect(dialect)?),
260        },
261        #[cfg(feature = "codegen")]
262        Command::Dialect { command } => match command {
263            cli::DialectSubcommand::Generate(args) => commands::codegen::generate(&args),
264        },
265        #[cfg(feature = "codegen")]
266        Command::DialectTool(cmd) => commands::codegen::dispatch_tool(cmd),
267        Command::Version => {
268            println!("syntaqlite {}", env!("CARGO_PKG_VERSION"));
269            Ok(())
270        }
271    }
272}
273
274/// Stock CLI configuration: the bundled `SQLite` dialect with all override
275/// surfaces enabled.
276#[cfg(feature = "bundled-sqlite-dialect")]
277pub struct Stock;
278
279#[cfg(feature = "bundled-sqlite-dialect")]
280impl CliApp for Stock {
281    fn name(&self) -> &'static str {
282        "syntaqlite"
283    }
284
285    fn default_dialect(&self) -> Option<AnyDialect> {
286        Some(syntaqlite::sqlite_dialect().into())
287    }
288
289    fn allow_dialect_override(&self) -> bool {
290        true
291    }
292
293    fn allow_sqlite_tuning(&self) -> bool {
294        true
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    struct Locked;
303    impl CliApp for Locked {
304        fn name(&self) -> &'static str {
305            "locked"
306        }
307        fn about(&self) -> &'static str {
308            "locked-down test CLI"
309        }
310    }
311
312    struct Open;
313    impl CliApp for Open {
314        fn name(&self) -> &'static str {
315            "open"
316        }
317        fn allow_dialect_override(&self) -> bool {
318            true
319        }
320        fn allow_sqlite_tuning(&self) -> bool {
321            true
322        }
323    }
324
325    fn parse(app: &impl CliApp, argv: &[&str]) -> Result<Cli, clap::Error> {
326        use clap::FromArgMatches;
327        let matches = build_command(app).try_get_matches_from(argv)?;
328        Cli::from_arg_matches(&matches)
329    }
330
331    fn help_text(app: &impl CliApp) -> String {
332        build_command(app).render_help().to_string()
333    }
334
335    #[test]
336    fn build_command_uses_app_name_and_about() {
337        let help = help_text(&Locked);
338        assert!(help.contains("locked"), "name missing from help:\n{help}");
339        assert!(
340            help.contains("locked-down test CLI"),
341            "about missing from help:\n{help}"
342        );
343    }
344
345    #[test]
346    fn sqlite_tuning_flags_hidden_when_disallowed() {
347        let help = help_text(&Locked);
348        assert!(
349            !help.contains("--sqlite-version"),
350            "--sqlite-version should be hidden:\n{help}"
351        );
352        assert!(
353            !help.contains("--sqlite-cflag"),
354            "--sqlite-cflag should be hidden:\n{help}"
355        );
356    }
357
358    #[test]
359    fn sqlite_tuning_flags_visible_when_allowed() {
360        let help = help_text(&Open);
361        assert!(
362            help.contains("--sqlite-version"),
363            "--sqlite-version should be visible:\n{help}"
364        );
365    }
366
367    #[test]
368    fn enforce_visibility_rejects_sqlite_tuning_when_disallowed() {
369        let cli =
370            parse(&Locked, &["locked", "--sqlite-version", "3.47.0", "parse"]).expect("clap parse");
371        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
372        assert!(err.contains("--sqlite-version"), "got: {err}");
373    }
374
375    #[test]
376    fn enforce_visibility_rejects_sqlite_cflag_when_disallowed() {
377        let cli = parse(
378            &Locked,
379            &["locked", "--sqlite-cflag", "SQLITE_ENABLE_FTS5", "parse"],
380        )
381        .expect("clap parse");
382        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
383        assert!(err.contains("--sqlite-cflag"), "got: {err}");
384    }
385
386    #[test]
387    fn enforce_visibility_accepts_sqlite_tuning_when_allowed() {
388        let cli =
389            parse(&Open, &["open", "--sqlite-version", "3.47.0", "parse"]).expect("clap parse");
390        enforce_visibility(&Open, &cli).expect("should accept");
391    }
392
393    #[cfg(feature = "dynload")]
394    #[test]
395    fn dialect_override_flags_hidden_when_disallowed() {
396        let help = help_text(&Locked);
397        assert!(
398            !help.contains("--dialect "),
399            "--dialect should be hidden:\n{help}"
400        );
401    }
402
403    #[cfg(feature = "dynload")]
404    #[test]
405    fn dialect_override_flags_visible_when_allowed() {
406        let help = help_text(&Open);
407        assert!(
408            help.contains("--dialect "),
409            "--dialect should be visible:\n{help}"
410        );
411    }
412
413    #[cfg(feature = "dynload")]
414    #[test]
415    fn enforce_visibility_rejects_dialect_when_disallowed() {
416        let cli =
417            parse(&Locked, &["locked", "--dialect", "/tmp/x.so", "parse"]).expect("clap parse");
418        let err = enforce_visibility(&Locked, &cli).expect_err("should reject");
419        assert!(err.contains("--dialect"), "got: {err}");
420    }
421
422    #[cfg(feature = "dynload")]
423    #[test]
424    fn enforce_visibility_accepts_dialect_when_allowed() {
425        let cli = parse(&Open, &["open", "--dialect", "/tmp/x.so", "parse"]).expect("clap parse");
426        enforce_visibility(&Open, &cli).expect("should accept");
427    }
428
429    #[cfg(feature = "bundled-sqlite-dialect")]
430    #[test]
431    fn stock_impl_exposes_all_override_surfaces() {
432        assert!(Stock.allow_dialect_override());
433        assert!(Stock.allow_sqlite_tuning());
434        assert!(Stock.default_dialect().is_some());
435    }
436}