1use 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
24pub trait CliApp {
33 fn name(&self) -> &'static str;
35
36 fn about(&self) -> &'static str {
38 "SQL formatting and analysis tools"
39 }
40
41 fn version(&self) -> &'static str {
46 env!("CARGO_PKG_VERSION")
47 }
48
49 fn default_dialect(&self) -> Option<AnyDialect> {
52 None
53 }
54
55 fn allow_dialect_override(&self) -> bool {
59 false
60 }
61
62 fn allow_sqlite_tuning(&self) -> bool {
65 false
66 }
67}
68
69pub 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
93fn 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
121fn 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
140fn 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
150fn dispatch(cli: Cli, default_dialect: Option<AnyDialect>) -> Result<(), String> {
153 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 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 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 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#[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}