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 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 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 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#[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}