diff --git a/crates/plotnik-cli/src/cli.rs b/crates/plotnik-cli/src/cli.rs deleted file mode 100644 index a9362e14..00000000 --- a/crates/plotnik-cli/src/cli.rs +++ /dev/null @@ -1,310 +0,0 @@ -use std::path::PathBuf; - -use clap::{Args, Parser, Subcommand, ValueEnum}; - -#[derive(Clone, Copy, Debug, Default, ValueEnum)] -pub enum ColorChoice { - #[default] - Auto, - Always, - Never, -} - -impl ColorChoice { - pub fn should_colorize(self) -> bool { - match self { - ColorChoice::Always => true, - ColorChoice::Never => false, - // Check both streams: if either is piped, disable colors. - // This handles `exec query.ptk app.js | jq` where stdout is piped - // but stderr (diagnostics) is still a TTY. - ColorChoice::Auto => { - std::io::IsTerminal::is_terminal(&std::io::stdout()) - && std::io::IsTerminal::is_terminal(&std::io::stderr()) - } - } - } -} - -#[derive(Parser)] -#[command(name = "plotnik", bin_name = "plotnik")] -#[command(about = "Query language for tree-sitter AST with type inference")] -pub struct Cli { - #[command(subcommand)] - pub command: Command, -} - -#[derive(Subcommand)] -pub enum Command { - /// Explore a source file's tree-sitter AST - #[command( - override_usage = "\ - plotnik tree - plotnik tree -s -l ", - after_help = r#"EXAMPLES: - plotnik tree app.ts # source file - plotnik tree app.ts --raw # include anonymous nodes - plotnik tree -s 'let x = 1' -l js # inline source"# - )] - Tree { - /// Source file to parse (use "-" for stdin) - #[arg(value_name = "SOURCE")] - source_path: Option, - - /// Inline source text - #[arg(short = 's', long = "source", value_name = "TEXT")] - source_text: Option, - - /// Language (inferred from extension if not specified) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - /// Include anonymous nodes (literals, punctuation) - #[arg(long)] - raw: bool, - - /// Show source positions - #[arg(long)] - spans: bool, - }, - - /// Validate a query - #[command( - override_usage = "\ - plotnik check - plotnik check -l - plotnik check -q [-l ]", - after_help = r#"EXAMPLES: - plotnik check query.ptk # validate syntax only - plotnik check query.ptk -l ts # also check against grammar - plotnik check queries.ts/ # workspace directory - plotnik check -q 'Q = ...' -l js # inline query"# - )] - Check { - /// Query file or workspace directory - #[arg(value_name = "QUERY")] - query_path: Option, - - /// Inline query text - #[arg(short = 'q', long = "query", value_name = "TEXT")] - query_text: Option, - - /// Language for grammar validation (inferred from workspace name if possible) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - /// Treat warnings as errors - #[arg(long)] - strict: bool, - - #[command(flatten)] - output: OutputArgs, - }, - - /// Show compiled bytecode - #[command( - override_usage = "\ - plotnik dump - plotnik dump -l - plotnik dump -q [-l ]", - after_help = r#"EXAMPLES: - plotnik dump query.ptk # unlinked bytecode - plotnik dump query.ptk -l ts # linked (resolved node types) - plotnik dump -q 'Q = ...' # inline query"# - )] - Dump { - /// Query file or workspace directory - #[arg(value_name = "QUERY")] - query_path: Option, - - /// Inline query text - #[arg(short = 'q', long = "query", value_name = "TEXT")] - query_text: Option, - - /// Language for linking (inferred from workspace name if possible) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - #[command(flatten)] - output: OutputArgs, - }, - - /// Generate type definitions from a query - #[command( - override_usage = "\ - plotnik infer -l - plotnik infer -q -l ", - after_help = r#"EXAMPLES: - plotnik infer query.ptk -l js # from file - plotnik infer -q 'Q = ...' -l ts # inline query - plotnik infer query.ptk -l js -o types.d.ts # write to file - -NOTE: Use --verbose-nodes to match `exec --verbose-nodes` output shape."# - )] - Infer { - /// Query file or workspace directory - #[arg(value_name = "QUERY")] - query_path: Option, - - /// Inline query text - #[arg(short = 'q', long = "query", value_name = "TEXT")] - query_text: Option, - - /// Target language (required, or inferred from workspace name) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - #[command(flatten)] - infer_output: InferOutputArgs, - - #[command(flatten)] - output: OutputArgs, - }, - - /// Execute a query against source code and output JSON - #[command( - override_usage = "\ - plotnik exec - plotnik exec -q - plotnik exec -q -s -l ", - after_help = r#"EXAMPLES: - plotnik exec query.ptk app.js # two positional files - plotnik exec -q 'Q = ...' app.js # inline query + source file - plotnik exec -q 'Q = ...' -s 'let x' -l js # all inline"# - )] - Exec { - /// Query file or workspace directory - #[arg(value_name = "QUERY")] - query_path: Option, - - /// Source file to execute against - #[arg(value_name = "SOURCE")] - source_path: Option, - - /// Inline query text - #[arg(short = 'q', long = "query", value_name = "TEXT")] - query_text: Option, - - /// Inline source text - #[arg(short = 's', long = "source", value_name = "TEXT")] - source_text: Option, - - /// Language (inferred from source extension if not specified) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - #[command(flatten)] - exec_output: ExecOutputArgs, - - #[command(flatten)] - output: OutputArgs, - }, - - /// Trace query execution for debugging - #[command( - override_usage = "\ - plotnik trace - plotnik trace -q - plotnik trace -q -s -l ", - after_help = r#"EXAMPLES: - plotnik trace query.ptk app.js # two positional files - plotnik trace -q 'Q = ...' app.js # inline query + source file - plotnik trace -q 'Q = ...' -s 'let x' -l js # all inline"# - )] - Trace { - /// Query file or workspace directory - #[arg(value_name = "QUERY")] - query_path: Option, - - /// Source file to execute against - #[arg(value_name = "SOURCE")] - source_path: Option, - - /// Inline query text - #[arg(short = 'q', long = "query", value_name = "TEXT")] - query_text: Option, - - /// Inline source text - #[arg(short = 's', long = "source", value_name = "TEXT")] - source_text: Option, - - /// Language (inferred from source extension if not specified) - #[arg(short = 'l', long, value_name = "LANG")] - lang: Option, - - /// Entry point name (definition to match from) - #[arg(long, value_name = "NAME")] - entry: Option, - - /// Verbosity level (-v for verbose, -vv for very verbose) - #[arg(short = 'v', action = clap::ArgAction::Count)] - verbose: u8, - - /// Skip materialization, show effects only - #[arg(long)] - no_result: bool, - - /// Execution fuel limit - #[arg(long, default_value = "1000000", value_name = "N")] - fuel: u32, - - #[command(flatten)] - output: OutputArgs, - }, - - /// List supported languages - Langs, -} - -#[derive(Args)] -pub struct OutputArgs { - /// Colorize output - #[arg(long, default_value = "auto", value_name = "WHEN")] - pub color: ColorChoice, -} - -#[derive(Args)] -pub struct InferOutputArgs { - /// Output format (typescript, ts) - #[arg(long, default_value = "typescript", value_name = "FORMAT")] - pub format: String, - - /// Use verbose node shape (matches exec --verbose-nodes) - #[arg(long)] - pub verbose_nodes: bool, - - /// Don't emit Node/Point type definitions - #[arg(long)] - pub no_node_type: bool, - - /// Don't export types - #[arg(long)] - pub no_export: bool, - - /// Type for void results: undefined (default) or null - #[arg(long, value_name = "TYPE")] - pub void_type: Option, - - /// Write output to file - #[arg(short = 'o', long, value_name = "FILE")] - pub output: Option, -} - -#[derive(Args)] -pub struct ExecOutputArgs { - /// Output compact JSON (default: pretty when stdout is a TTY) - #[arg(long)] - pub compact: bool, - - /// Include verbose node information (line/column positions) - #[arg(long)] - pub verbose_nodes: bool, - - /// Validate output against inferred types - #[arg(long)] - pub check: bool, - - /// Entry point name (definition to match from) - #[arg(long, value_name = "NAME")] - pub entry: Option, -} diff --git a/crates/plotnik-cli/src/cli/args.rs b/crates/plotnik-cli/src/cli/args.rs new file mode 100644 index 00000000..0fd0c335 --- /dev/null +++ b/crates/plotnik-cli/src/cli/args.rs @@ -0,0 +1,189 @@ +//! Shared argument builders for CLI commands. +//! +//! Each function returns a `clap::Arg` that can be composed into commands. +//! This allows the same arg definition to be reused across commands with +//! different visibility settings (via `.hide(true)`). + +use std::path::PathBuf; + +use clap::{Arg, ArgAction, value_parser}; + +/// Query file or workspace directory (positional). +pub fn query_path_arg() -> Arg { + Arg::new("query_path") + .value_name("QUERY") + .value_parser(value_parser!(PathBuf)) + .help("Query file or workspace directory") +} + +/// Inline query text (-q/--query). +pub fn query_text_arg() -> Arg { + Arg::new("query_text") + .short('q') + .long("query") + .value_name("TEXT") + .help("Inline query text") +} + +/// Source file to parse/execute against (positional). +pub fn source_path_arg() -> Arg { + Arg::new("source_path") + .value_name("SOURCE") + .value_parser(value_parser!(PathBuf)) + .help("Source file to parse") +} + +/// Inline source text (-s/--source). +pub fn source_text_arg() -> Arg { + Arg::new("source_text") + .short('s') + .long("source") + .value_name("TEXT") + .help("Inline source text") +} + +/// Language flag (-l/--lang). +pub fn lang_arg() -> Arg { + Arg::new("lang") + .short('l') + .long("lang") + .value_name("LANG") + .help("Language (inferred from extension if not specified)") +} + +/// Color output control (--color). +pub fn color_arg() -> Arg { + Arg::new("color") + .long("color") + .value_name("WHEN") + .default_value("auto") + .value_parser(["auto", "always", "never"]) + .help("Colorize output") +} + +/// Include anonymous nodes (--raw). +pub fn raw_arg() -> Arg { + Arg::new("raw") + .long("raw") + .action(ArgAction::SetTrue) + .help("Include anonymous nodes (literals, punctuation)") +} + +/// Show source positions (--spans). +pub fn spans_arg() -> Arg { + Arg::new("spans") + .long("spans") + .action(ArgAction::SetTrue) + .help("Show source positions") +} + +/// Treat warnings as errors (--strict). +pub fn strict_arg() -> Arg { + Arg::new("strict") + .long("strict") + .action(ArgAction::SetTrue) + .help("Treat warnings as errors") +} + +/// Output format (--format). +pub fn format_arg() -> Arg { + Arg::new("format") + .long("format") + .value_name("FORMAT") + .default_value("typescript") + .help("Output format (typescript, ts)") +} + +/// Use verbose node shape (--verbose-nodes). +/// Also used by exec command. +pub fn verbose_nodes_arg() -> Arg { + Arg::new("verbose_nodes") + .long("verbose-nodes") + .action(ArgAction::SetTrue) + .help("Include verbose node information (line/column positions)") +} + +/// Don't emit Node/Point type definitions (--no-node-type). +pub fn no_node_type_arg() -> Arg { + Arg::new("no_node_type") + .long("no-node-type") + .action(ArgAction::SetTrue) + .help("Don't emit Node/Point type definitions") +} + +/// Don't export types (--no-export). +pub fn no_export_arg() -> Arg { + Arg::new("no_export") + .long("no-export") + .action(ArgAction::SetTrue) + .help("Don't export types") +} + +/// Type for void results (--void-type). +pub fn void_type_arg() -> Arg { + Arg::new("void_type") + .long("void-type") + .value_name("TYPE") + .help("Type for void results: undefined (default) or null") +} + +/// Write output to file (-o/--output). +pub fn output_file_arg() -> Arg { + Arg::new("output") + .short('o') + .long("output") + .value_name("FILE") + .value_parser(value_parser!(PathBuf)) + .help("Write output to file") +} + +/// Output compact JSON (--compact). +pub fn compact_arg() -> Arg { + Arg::new("compact") + .long("compact") + .action(ArgAction::SetTrue) + .help("Output compact JSON (default: pretty when stdout is a TTY)") +} + +/// Validate output against inferred types (--check). +pub fn check_arg() -> Arg { + Arg::new("check") + .long("check") + .action(ArgAction::SetTrue) + .help("Validate output against inferred types") +} + +/// Entry point name (--entry). +/// Used by both exec and trace. +pub fn entry_arg() -> Arg { + Arg::new("entry") + .long("entry") + .value_name("NAME") + .help("Entry point name (definition to match from)") +} + +/// Verbosity level (-v, -vv). +pub fn verbose_arg() -> Arg { + Arg::new("verbose") + .short('v') + .action(ArgAction::Count) + .help("Verbosity level (-v for verbose, -vv for very verbose)") +} + +/// Skip materialization (--no-result). +pub fn no_result_arg() -> Arg { + Arg::new("no_result") + .long("no-result") + .action(ArgAction::SetTrue) + .help("Skip materialization, show effects only") +} + +/// Execution fuel limit (--fuel). +pub fn fuel_arg() -> Arg { + Arg::new("fuel") + .long("fuel") + .value_name("N") + .default_value("1000000") + .value_parser(value_parser!(u32)) + .help("Execution fuel limit") +} diff --git a/crates/plotnik-cli/src/cli/commands.rs b/crates/plotnik-cli/src/cli/commands.rs new file mode 100644 index 00000000..3a971cc7 --- /dev/null +++ b/crates/plotnik-cli/src/cli/commands.rs @@ -0,0 +1,240 @@ +//! Command builders for the CLI. +//! +//! Each command is built using the shared arg builders from `args.rs`. +//! The unified flags feature is implemented here: dump/exec/trace accept +//! all runtime flags, with irrelevant ones hidden from `--help`. + +use clap::Command; + +use super::args::*; + +/// Add hidden source input args (for commands that don't use source). +fn with_hidden_source_args(cmd: Command) -> Command { + cmd.arg(source_path_arg().hide(true)) + .arg(source_text_arg().hide(true)) +} + +/// Add hidden exec output args (for commands that don't produce JSON). +fn with_hidden_exec_args(cmd: Command) -> Command { + cmd.arg(entry_arg().hide(true)) + .arg(compact_arg().hide(true)) + .arg(verbose_nodes_arg().hide(true)) + .arg(check_arg().hide(true)) +} + +/// Add hidden exec output args, excluding --verbose-nodes (for infer which has it visible). +fn with_hidden_exec_args_partial(cmd: Command) -> Command { + cmd.arg(entry_arg().hide(true)) + .arg(compact_arg().hide(true)) + .arg(check_arg().hide(true)) +} + +/// Add hidden trace args (for commands that don't trace). +fn with_hidden_trace_args(cmd: Command) -> Command { + cmd.arg(verbose_arg().hide(true)) + .arg(no_result_arg().hide(true)) + .arg(fuel_arg().hide(true)) +} + +/// Build the complete CLI with all subcommands. +pub fn build_cli() -> Command { + Command::new("plotnik") + .about("Query language for tree-sitter AST with type inference") + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand(tree_command()) + .subcommand(check_command()) + .subcommand(dump_command()) + .subcommand(infer_command()) + .subcommand(exec_command()) + .subcommand(trace_command()) + .subcommand(langs_command()) +} + +/// Explore a source file's tree-sitter AST. +pub fn tree_command() -> Command { + Command::new("tree") + .about("Explore a source file's tree-sitter AST") + .override_usage( + "\ + plotnik tree + plotnik tree -s -l ", + ) + .after_help( + r#"EXAMPLES: + plotnik tree app.ts # source file + plotnik tree app.ts --raw # include anonymous nodes + plotnik tree -s 'let x = 1' -l js # inline source"#, + ) + .arg(source_path_arg()) + .arg(source_text_arg()) + .arg(lang_arg()) + .arg(raw_arg()) + .arg(spans_arg()) +} + +/// Validate a query. +/// +/// Accepts all runtime flags for unified CLI experience, but only uses +/// query/lang/strict/color. +pub fn check_command() -> Command { + let cmd = Command::new("check") + .about("Validate a query") + .override_usage( + "\ + plotnik check + plotnik check -l + plotnik check -q [-l ]", + ) + .after_help( + r#"EXAMPLES: + plotnik check query.ptk # validate syntax only + plotnik check query.ptk -l ts # also check against grammar + plotnik check queries.ts/ # workspace directory + plotnik check -q 'Q = ...' -l js # inline query"#, + ) + .arg(query_path_arg()) + .arg(query_text_arg()) + .arg(lang_arg()) + .arg(strict_arg()) + .arg(color_arg()); + + // Hidden unified flags + with_hidden_trace_args(with_hidden_exec_args(with_hidden_source_args(cmd))) +} + +/// Show compiled bytecode. +/// +/// Accepts all runtime flags for unified CLI experience, but only uses +/// query/lang/color. Source and execution flags are hidden and ignored. +pub fn dump_command() -> Command { + let cmd = Command::new("dump") + .about("Show compiled bytecode") + .override_usage( + "\ + plotnik dump + plotnik dump -l + plotnik dump -q [-l ]", + ) + .after_help( + r#"EXAMPLES: + plotnik dump query.ptk # unlinked bytecode + plotnik dump query.ptk -l ts # linked (resolved node types) + plotnik dump -q 'Q = ...' # inline query"#, + ) + .arg(query_path_arg()) + .arg(query_text_arg()) + .arg(lang_arg()) + .arg(color_arg()); + + // Hidden unified flags + with_hidden_trace_args(with_hidden_exec_args(with_hidden_source_args(cmd))) +} + +/// Generate type definitions from a query. +/// +/// Accepts all runtime flags for unified CLI experience, but only uses +/// query/lang and infer-specific options. +pub fn infer_command() -> Command { + let cmd = Command::new("infer") + .about("Generate type definitions from a query") + .override_usage( + "\ + plotnik infer -l + plotnik infer -q -l ", + ) + .after_help( + r#"EXAMPLES: + plotnik infer query.ptk -l js # from file + plotnik infer -q 'Q = ...' -l ts # inline query + plotnik infer query.ptk -l js -o types.d.ts # write to file + +NOTE: Use --verbose-nodes to match `exec --verbose-nodes` output shape."#, + ) + .arg(query_path_arg()) + .arg(query_text_arg()) + .arg(lang_arg()) + .arg(format_arg()) + .arg(verbose_nodes_arg()) + .arg(no_node_type_arg()) + .arg(no_export_arg()) + .arg(void_type_arg()) + .arg(output_file_arg()) + .arg(color_arg()); + + // Hidden unified flags (use partial exec args since --verbose-nodes is visible) + with_hidden_trace_args(with_hidden_exec_args_partial(with_hidden_source_args(cmd))) +} + +/// Execute a query against source code and output JSON. +/// +/// Accepts trace flags for unified CLI experience, but ignores them. +pub fn exec_command() -> Command { + let cmd = Command::new("exec") + .about("Execute a query against source code and output JSON") + .override_usage( + "\ + plotnik exec + plotnik exec -q + plotnik exec -q -s -l ", + ) + .after_help( + r#"EXAMPLES: + plotnik exec query.ptk app.js # two positional files + plotnik exec -q 'Q = ...' app.js # inline query + source file + plotnik exec -q 'Q = ...' -s 'let x' -l js # all inline"#, + ) + .arg(query_path_arg()) + .arg(source_path_arg()) + .arg(query_text_arg()) + .arg(source_text_arg()) + .arg(lang_arg()) + .arg(color_arg()) + .arg(compact_arg()) + .arg(verbose_nodes_arg()) + .arg(check_arg()) + .arg(entry_arg()); + + // Hidden unified flags + with_hidden_trace_args(cmd) +} + +/// Trace query execution for debugging. +/// +/// Accepts exec output flags for unified CLI experience, but ignores them. +pub fn trace_command() -> Command { + let cmd = Command::new("trace") + .about("Trace query execution for debugging") + .override_usage( + "\ + plotnik trace + plotnik trace -q + plotnik trace -q -s -l ", + ) + .after_help( + r#"EXAMPLES: + plotnik trace query.ptk app.js # two positional files + plotnik trace -q 'Q = ...' app.js # inline query + source file + plotnik trace -q 'Q = ...' -s 'let x' -l js # all inline"#, + ) + .arg(query_path_arg()) + .arg(source_path_arg()) + .arg(query_text_arg()) + .arg(source_text_arg()) + .arg(lang_arg()) + .arg(color_arg()) + .arg(entry_arg()) + .arg(verbose_arg()) + .arg(no_result_arg()) + .arg(fuel_arg()); + + // Hidden unified flags (exec output flags only - entry is visible for trace) + cmd.arg(compact_arg().hide(true)) + .arg(verbose_nodes_arg().hide(true)) + .arg(check_arg().hide(true)) +} + +/// List supported languages. +pub fn langs_command() -> Command { + Command::new("langs").about("List supported languages") +} diff --git a/crates/plotnik-cli/src/cli/dispatch.rs b/crates/plotnik-cli/src/cli/dispatch.rs new file mode 100644 index 00000000..4fdceeec --- /dev/null +++ b/crates/plotnik-cli/src/cli/dispatch.rs @@ -0,0 +1,327 @@ +//! Dispatch logic: extract params from ArgMatches and convert to command args. +//! +//! This module contains: +//! - `*Params` structs that mirror command `*Args` but are populated from clap +//! - `from_matches()` extractors that pull relevant fields (ignoring hidden ones) +//! - `Into<*Args>` impls to bridge dispatch → command handlers +//! - Positional shifting logic for exec/trace (`-q` shifts first positional to source) + +use std::path::PathBuf; + +use clap::ArgMatches; + +use super::ColorChoice; +use crate::commands::check::CheckArgs; +use crate::commands::dump::DumpArgs; +use crate::commands::exec::ExecArgs; +use crate::commands::infer::InferArgs; +use crate::commands::trace::TraceArgs; +use crate::commands::tree::TreeArgs; + +pub struct TreeParams { + pub source_path: Option, + pub source_text: Option, + pub lang: Option, + pub raw: bool, + pub spans: bool, +} + +impl TreeParams { + pub fn from_matches(m: &ArgMatches) -> Self { + Self { + source_path: m.get_one::("source_path").cloned(), + source_text: m.get_one::("source_text").cloned(), + lang: m.get_one::("lang").cloned(), + raw: m.get_flag("raw"), + spans: m.get_flag("spans"), + } + } +} + +impl From for TreeArgs { + fn from(p: TreeParams) -> Self { + Self { + source_path: p.source_path, + source_text: p.source_text, + lang: p.lang, + raw: p.raw, + spans: p.spans, + } + } +} + +pub struct CheckParams { + pub query_path: Option, + pub query_text: Option, + pub lang: Option, + pub strict: bool, + pub color: ColorChoice, +} + +impl CheckParams { + pub fn from_matches(m: &ArgMatches) -> Self { + Self { + query_path: m.get_one::("query_path").cloned(), + query_text: m.get_one::("query_text").cloned(), + lang: m.get_one::("lang").cloned(), + strict: m.get_flag("strict"), + color: parse_color(m), + } + } +} + +impl From for CheckArgs { + fn from(p: CheckParams) -> Self { + Self { + query_path: p.query_path, + query_text: p.query_text, + lang: p.lang, + strict: p.strict, + color: p.color.should_colorize(), + } + } +} + +pub struct DumpParams { + pub query_path: Option, + pub query_text: Option, + pub lang: Option, + pub color: ColorChoice, + // Note: source_path, source_text, entry, compact, verbose_nodes, check, + // verbose, no_result, fuel are parsed but not extracted (unified flags) +} + +impl DumpParams { + pub fn from_matches(m: &ArgMatches) -> Self { + Self { + query_path: m.get_one::("query_path").cloned(), + query_text: m.get_one::("query_text").cloned(), + lang: m.get_one::("lang").cloned(), + color: parse_color(m), + } + } +} + +impl From for DumpArgs { + fn from(p: DumpParams) -> Self { + Self { + query_path: p.query_path, + query_text: p.query_text, + lang: p.lang, + color: p.color.should_colorize(), + } + } +} + +pub struct InferParams { + pub query_path: Option, + pub query_text: Option, + pub lang: Option, + pub format: String, + pub verbose_nodes: bool, + pub no_node_type: bool, + pub no_export: bool, + pub void_type: Option, + pub output: Option, + pub color: ColorChoice, +} + +impl InferParams { + pub fn from_matches(m: &ArgMatches) -> Self { + Self { + // Query input + query_path: m.get_one::("query_path").cloned(), + query_text: m.get_one::("query_text").cloned(), + lang: m.get_one::("lang").cloned(), + + // Format options + format: m + .get_one::("format") + .cloned() + .unwrap_or_else(|| "typescript".to_string()), + verbose_nodes: m.get_flag("verbose_nodes"), + no_node_type: m.get_flag("no_node_type"), + no_export: m.get_flag("no_export"), + void_type: m.get_one::("void_type").cloned(), + + // Output + output: m.get_one::("output").cloned(), + color: parse_color(m), + } + } +} + +impl From for InferArgs { + fn from(p: InferParams) -> Self { + Self { + query_path: p.query_path, + query_text: p.query_text, + lang: p.lang, + format: p.format, + verbose_nodes: p.verbose_nodes, + no_node_type: p.no_node_type, + export: !p.no_export, + output: p.output, + color: p.color.should_colorize(), + void_type: p.void_type, + } + } +} + +pub struct ExecParams { + pub query_path: Option, + pub query_text: Option, + pub source_path: Option, + pub source_text: Option, + pub lang: Option, + pub compact: bool, + pub entry: Option, + pub color: ColorChoice, + // Note: verbose_nodes, check, verbose, no_result, fuel are parsed but not + // extracted. verbose_nodes and check are visible exec flags that aren't + // implemented yet. The others are unified flags from trace. +} + +impl ExecParams { + pub fn from_matches(m: &ArgMatches) -> Self { + let query_path = m.get_one::("query_path").cloned(); + let query_text = m.get_one::("query_text").cloned(); + let source_path = m.get_one::("source_path").cloned(); + + // Positional shifting: when -q is used with a single positional, + // shift it from query_path to source_path. + let (query_path, source_path) = + shift_positional_to_source(query_text.is_some(), query_path, source_path); + + Self { + // Input (with positional shifting applied) + query_path, + query_text, + source_path, + source_text: m.get_one::("source_text").cloned(), + lang: m.get_one::("lang").cloned(), + + // Output options + compact: m.get_flag("compact"), + entry: m.get_one::("entry").cloned(), + color: parse_color(m), + } + } +} + +impl From for ExecArgs { + fn from(p: ExecParams) -> Self { + // Pretty by default when stdout is a TTY, unless --compact is passed + let pretty = !p.compact && std::io::IsTerminal::is_terminal(&std::io::stdout()); + + Self { + query_path: p.query_path, + query_text: p.query_text, + source_path: p.source_path, + source_text: p.source_text, + lang: p.lang, + pretty, + entry: p.entry, + color: p.color.should_colorize(), + } + } +} + +pub struct TraceParams { + pub query_path: Option, + pub query_text: Option, + pub source_path: Option, + pub source_text: Option, + pub lang: Option, + pub entry: Option, + pub verbose: u8, + pub no_result: bool, + pub fuel: u32, + pub color: ColorChoice, + // Note: compact, verbose_nodes, check are parsed but not extracted (unified flags) +} + +impl TraceParams { + pub fn from_matches(m: &ArgMatches) -> Self { + let query_path = m.get_one::("query_path").cloned(); + let query_text = m.get_one::("query_text").cloned(); + let source_path = m.get_one::("source_path").cloned(); + + // Positional shifting: when -q is used with a single positional, + // shift it from query_path to source_path. + let (query_path, source_path) = + shift_positional_to_source(query_text.is_some(), query_path, source_path); + + Self { + // Input (with positional shifting applied) + query_path, + query_text, + source_path, + source_text: m.get_one::("source_text").cloned(), + lang: m.get_one::("lang").cloned(), + + // Trace options + entry: m.get_one::("entry").cloned(), + verbose: m.get_count("verbose"), + no_result: m.get_flag("no_result"), + fuel: m.get_one::("fuel").copied().unwrap_or(1_000_000), + color: parse_color(m), + } + } +} + +impl From for TraceArgs { + fn from(p: TraceParams) -> Self { + use plotnik_lib::engine::Verbosity; + + let verbosity = match p.verbose { + 0 => Verbosity::Default, + 1 => Verbosity::Verbose, + _ => Verbosity::VeryVerbose, + }; + + Self { + query_path: p.query_path, + query_text: p.query_text, + source_path: p.source_path, + source_text: p.source_text, + lang: p.lang, + entry: p.entry, + verbosity, + no_result: p.no_result, + fuel: p.fuel, + color: p.color.should_colorize(), + } + } +} + +pub struct LangsParams; + +impl LangsParams { + pub fn from_matches(_m: &ArgMatches) -> Self { + Self + } +} + +/// Parse --color flag into ColorChoice. +fn parse_color(m: &ArgMatches) -> ColorChoice { + match m.get_one::("color").map(|s| s.as_str()) { + Some("always") => ColorChoice::Always, + Some("never") => ColorChoice::Never, + _ => ColorChoice::Auto, + } +} + +/// When -q is used with a single positional arg, shift it from query to source. +/// This enables: `plotnik exec -q 'query' source.js` +fn shift_positional_to_source( + has_query_text: bool, + query_path: Option, + source_path: Option, +) -> (Option, Option) { + if has_query_text && query_path.is_some() && source_path.is_none() { + (None, query_path) + } else { + (query_path, source_path) + } +} diff --git a/crates/plotnik-cli/src/cli/dispatch_tests.rs b/crates/plotnik-cli/src/cli/dispatch_tests.rs new file mode 100644 index 00000000..a1e07569 --- /dev/null +++ b/crates/plotnik-cli/src/cli/dispatch_tests.rs @@ -0,0 +1,520 @@ +//! Tests for CLI dispatch logic. +//! +//! These tests verify: +//! 1. Unified flags: dump/exec/trace accept each other's flags without error +//! 2. Help visibility: hidden flags don't appear in --help +//! 3. Positional shifting: -q shifts first positional to source +//! 4. Params extraction: correct fields are extracted from ArgMatches + +use std::path::PathBuf; + +use super::*; +use crate::cli::commands::{ + check_command, dump_command, exec_command, infer_command, trace_command, +}; + +#[test] +fn dump_accepts_trace_flags() { + let cmd = dump_command(); + let result = cmd.try_get_matches_from(["dump", "query.ptk", "--fuel", "500", "-vv"]); + assert!( + result.is_ok(), + "dump should accept trace flags: {:?}", + result.err() + ); + + let m = result.unwrap(); + let params = DumpParams::from_matches(&m); + + // Query path is extracted + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + // fuel and verbose are parsed but not in DumpParams (that's the point) +} + +#[test] +fn dump_accepts_exec_flags() { + let cmd = dump_command(); + let result = cmd.try_get_matches_from([ + "dump", + "query.ptk", + "--compact", + "--check", + "--verbose-nodes", + "--entry", + "Foo", + ]); + assert!( + result.is_ok(), + "dump should accept exec flags: {:?}", + result.err() + ); + + let m = result.unwrap(); + let params = DumpParams::from_matches(&m); + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); +} + +#[test] +fn dump_accepts_source_positional() { + let cmd = dump_command(); + let result = cmd.try_get_matches_from(["dump", "query.ptk", "app.js"]); + assert!( + result.is_ok(), + "dump should accept source positional: {:?}", + result.err() + ); + + let m = result.unwrap(); + let params = DumpParams::from_matches(&m); + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + // source_path is parsed but not in DumpParams +} + +#[test] +fn dump_accepts_source_flag() { + let cmd = dump_command(); + let result = cmd.try_get_matches_from(["dump", "query.ptk", "-s", "let x = 1"]); + assert!( + result.is_ok(), + "dump should accept -s flag: {:?}", + result.err() + ); +} + +#[test] +fn exec_accepts_trace_flags() { + let cmd = exec_command(); + let result = cmd.try_get_matches_from([ + "exec", + "query.ptk", + "app.js", + "--fuel", + "500", + "-vv", + "--no-result", + ]); + assert!( + result.is_ok(), + "exec should accept trace flags: {:?}", + result.err() + ); + + let m = result.unwrap(); + let params = ExecParams::from_matches(&m); + + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); + // fuel, verbose, no_result are parsed but not in ExecParams +} + +#[test] +fn trace_accepts_exec_flags() { + let cmd = trace_command(); + let result = cmd.try_get_matches_from([ + "trace", + "query.ptk", + "app.js", + "--compact", + "--verbose-nodes", + "--check", + ]); + assert!( + result.is_ok(), + "trace should accept exec flags: {:?}", + result.err() + ); + + let m = result.unwrap(); + let params = TraceParams::from_matches(&m); + + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); + // compact, verbose_nodes, check are parsed but not in TraceParams +} + +#[test] +fn check_accepts_source_args() { + let cmd = check_command(); + let result = cmd.try_get_matches_from(["check", "query.ptk", "app.js", "-s", "let x"]); + assert!( + result.is_ok(), + "check should accept source args: {:?}", + result.err() + ); +} + +#[test] +fn check_accepts_exec_flags() { + let cmd = check_command(); + let result = cmd.try_get_matches_from([ + "check", + "query.ptk", + "--compact", + "--verbose-nodes", + "--check", + "--entry", + "Foo", + ]); + assert!( + result.is_ok(), + "check should accept exec flags: {:?}", + result.err() + ); +} + +#[test] +fn check_accepts_trace_flags() { + let cmd = check_command(); + let result = + cmd.try_get_matches_from(["check", "query.ptk", "--fuel", "500", "-vv", "--no-result"]); + assert!( + result.is_ok(), + "check should accept trace flags: {:?}", + result.err() + ); +} + +#[test] +fn infer_accepts_source_args() { + let cmd = infer_command(); + let result = cmd.try_get_matches_from(["infer", "query.ptk", "app.js", "-s", "let x"]); + assert!( + result.is_ok(), + "infer should accept source args: {:?}", + result.err() + ); +} + +#[test] +fn infer_accepts_exec_flags() { + let cmd = infer_command(); + let result = cmd.try_get_matches_from([ + "infer", + "query.ptk", + "--compact", + "--check", + "--entry", + "Foo", + ]); + assert!( + result.is_ok(), + "infer should accept exec flags: {:?}", + result.err() + ); +} + +#[test] +fn infer_accepts_trace_flags() { + let cmd = infer_command(); + let result = + cmd.try_get_matches_from(["infer", "query.ptk", "--fuel", "500", "-vv", "--no-result"]); + assert!( + result.is_ok(), + "infer should accept trace flags: {:?}", + result.err() + ); +} + +#[test] +fn dump_help_hides_trace_flags() { + let mut cmd = dump_command(); + let help = cmd.render_help().to_string(); + + assert!(!help.contains("--fuel"), "dump help should not show --fuel"); + assert!( + !help.contains("--no-result"), + "dump help should not show --no-result" + ); + assert!( + !help.contains("Verbosity level"), + "dump help should not show -v description" + ); +} + +#[test] +fn dump_help_hides_exec_flags() { + let mut cmd = dump_command(); + let help = cmd.render_help().to_string(); + + assert!( + !help.contains("--compact"), + "dump help should not show --compact" + ); + assert!( + !help.contains("--verbose-nodes"), + "dump help should not show --verbose-nodes" + ); + assert!( + !help.contains("--check"), + "dump help should not show --check" + ); + assert!( + !help.contains("--entry"), + "dump help should not show --entry" + ); +} + +#[test] +fn dump_help_hides_source_args() { + let mut cmd = dump_command(); + let help = cmd.render_help().to_string(); + + // SOURCE positional should be hidden + assert!( + !help.contains("[SOURCE]"), + "dump help should not show SOURCE positional" + ); + // -s/--source flag should be hidden + assert!( + !help.contains("Inline source text"), + "dump help should not show -s description" + ); +} + +#[test] +fn exec_help_hides_trace_flags() { + let mut cmd = exec_command(); + let help = cmd.render_help().to_string(); + + assert!(!help.contains("--fuel"), "exec help should not show --fuel"); + assert!( + !help.contains("--no-result"), + "exec help should not show --no-result" + ); +} + +#[test] +fn trace_help_hides_exec_output_flags() { + let mut cmd = trace_command(); + let help = cmd.render_help().to_string(); + + assert!( + !help.contains("--compact"), + "trace help should not show --compact" + ); + assert!( + !help.contains("--verbose-nodes"), + "trace help should not show --verbose-nodes" + ); + assert!( + !help.contains("Validate output"), + "trace help should not show --check" + ); +} + +#[test] +fn check_help_hides_unified_flags() { + let mut cmd = check_command(); + let help = cmd.render_help().to_string(); + + // Source args should be hidden + assert!( + !help.contains("[SOURCE]"), + "check help should not show SOURCE" + ); + assert!( + !help.contains("Inline source text"), + "check help should not show -s" + ); + + // Exec flags should be hidden + assert!( + !help.contains("--compact"), + "check help should not show --compact" + ); + assert!( + !help.contains("--verbose-nodes"), + "check help should not show --verbose-nodes" + ); + assert!( + !help.contains("--entry"), + "check help should not show --entry" + ); + + // Trace flags should be hidden + assert!( + !help.contains("--fuel"), + "check help should not show --fuel" + ); + assert!( + !help.contains("--no-result"), + "check help should not show --no-result" + ); +} + +#[test] +fn infer_help_hides_unified_flags() { + let mut cmd = infer_command(); + let help = cmd.render_help().to_string(); + + // Source args should be hidden + assert!( + !help.contains("[SOURCE]"), + "infer help should not show SOURCE" + ); + assert!( + !help.contains("Inline source text"), + "infer help should not show -s" + ); + + // Exec flags (except --verbose-nodes which is visible) should be hidden + assert!( + !help.contains("--compact"), + "infer help should not show --compact" + ); + assert!( + !help.contains("--entry"), + "infer help should not show --entry" + ); + + // Trace flags should be hidden + assert!( + !help.contains("--fuel"), + "infer help should not show --fuel" + ); + assert!( + !help.contains("--no-result"), + "infer help should not show --no-result" + ); + + // --verbose-nodes SHOULD be visible for infer + assert!( + help.contains("--verbose-nodes"), + "infer help SHOULD show --verbose-nodes" + ); +} + +#[test] +fn exec_shifts_positional_with_inline_query() { + let cmd = exec_command(); + let result = cmd.try_get_matches_from(["exec", "-q", "(identifier) @id", "app.js"]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = ExecParams::from_matches(&m); + + // With -q, the single positional should become source_path, not query_path + assert_eq!(params.query_path, None); + assert_eq!(params.query_text, Some("(identifier) @id".to_string())); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); +} + +#[test] +fn exec_no_shift_with_both_positionals() { + let cmd = exec_command(); + let result = cmd.try_get_matches_from(["exec", "query.ptk", "app.js"]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = ExecParams::from_matches(&m); + + // Without -q, both positionals are used as-is + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); +} + +#[test] +fn trace_shifts_positional_with_inline_query() { + let cmd = trace_command(); + let result = cmd.try_get_matches_from(["trace", "-q", "(identifier) @id", "app.js"]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = TraceParams::from_matches(&m); + + assert_eq!(params.query_path, None); + assert_eq!(params.query_text, Some("(identifier) @id".to_string())); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); +} + +#[test] +fn trace_params_extracts_all_fields() { + let cmd = trace_command(); + let result = cmd.try_get_matches_from([ + "trace", + "query.ptk", + "app.js", + "-l", + "typescript", + "--entry", + "Main", + "-vv", + "--no-result", + "--fuel", + "500", + "--color", + "always", + ]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = TraceParams::from_matches(&m); + + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); + assert_eq!(params.lang, Some("typescript".to_string())); + assert_eq!(params.entry, Some("Main".to_string())); + assert_eq!(params.verbose, 2); + assert!(params.no_result); + assert_eq!(params.fuel, 500); + assert!(matches!(params.color, ColorChoice::Always)); +} + +#[test] +fn exec_params_extracts_all_fields() { + let cmd = exec_command(); + let result = cmd.try_get_matches_from([ + "exec", + "query.ptk", + "app.js", + "-l", + "javascript", + "--compact", + "--entry", + "Query", + "--color", + "never", + // These are parsed but not extracted (visible but unimplemented flags) + "--verbose-nodes", + "--check", + ]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = ExecParams::from_matches(&m); + + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.source_path, Some(PathBuf::from("app.js"))); + assert_eq!(params.lang, Some("javascript".to_string())); + assert!(params.compact); + assert_eq!(params.entry, Some("Query".to_string())); + assert!(matches!(params.color, ColorChoice::Never)); + // verbose_nodes and check are parsed but not in ExecParams (unimplemented) +} + +#[test] +fn dump_params_extracts_only_relevant_fields() { + let cmd = dump_command(); + let result = cmd.try_get_matches_from([ + "dump", + "query.ptk", + "-l", + "rust", + "--color", + "auto", + // All these are accepted but ignored + "app.rs", + "--fuel", + "100", + "--compact", + ]); + assert!(result.is_ok()); + + let m = result.unwrap(); + let params = DumpParams::from_matches(&m); + + assert_eq!(params.query_path, Some(PathBuf::from("query.ptk"))); + assert_eq!(params.lang, Some("rust".to_string())); + assert!(matches!(params.color, ColorChoice::Auto)); + // No source_path, fuel, compact fields in DumpParams +} diff --git a/crates/plotnik-cli/src/cli/mod.rs b/crates/plotnik-cli/src/cli/mod.rs new file mode 100644 index 00000000..7806a132 --- /dev/null +++ b/crates/plotnik-cli/src/cli/mod.rs @@ -0,0 +1,36 @@ +mod args; +mod commands; +mod dispatch; + +#[cfg(test)] +mod dispatch_tests; + +pub use commands::build_cli; +pub use dispatch::{ + CheckParams, DumpParams, ExecParams, InferParams, LangsParams, TraceParams, TreeParams, +}; + +/// Color output mode for CLI commands. +#[derive(Clone, Copy, Debug, Default)] +pub enum ColorChoice { + #[default] + Auto, + Always, + Never, +} + +impl ColorChoice { + pub fn should_colorize(self) -> bool { + match self { + ColorChoice::Always => true, + ColorChoice::Never => false, + // Check both streams: if either is piped, disable colors. + // This handles `exec query.ptk app.js | jq` where stdout is piped + // but stderr (diagnostics) is still a TTY. + ColorChoice::Auto => { + std::io::IsTerminal::is_terminal(&std::io::stdout()) + && std::io::IsTerminal::is_terminal(&std::io::stderr()) + } + } + } +} diff --git a/crates/plotnik-cli/src/main.rs b/crates/plotnik-cli/src/main.rs index 149f0a82..acec48af 100644 --- a/crates/plotnik-cli/src/main.rs +++ b/crates/plotnik-cli/src/main.rs @@ -1,168 +1,43 @@ mod cli; mod commands; -use std::path::PathBuf; - -use cli::{Cli, Command}; -use commands::check::CheckArgs; -use commands::dump::DumpArgs; -use commands::exec::ExecArgs; -use commands::infer::InferArgs; -use commands::trace::TraceArgs; -use commands::tree::TreeArgs; - -/// When -q is used with a single positional arg, shift it from query to source. -/// This enables: `plotnik exec -q 'query' source.js` -fn shift_positional_to_source( - has_query_text: bool, - query_path: Option, - source_path: Option, -) -> (Option, Option) { - if has_query_text && query_path.is_some() && source_path.is_none() { - (None, query_path) - } else { - (query_path, source_path) - } -} +use cli::{ + CheckParams, DumpParams, ExecParams, InferParams, LangsParams, TraceParams, TreeParams, + build_cli, +}; fn main() { - let cli = ::parse(); + let matches = build_cli().get_matches(); - match cli.command { - Command::Tree { - source_path, - source_text, - lang, - raw, - spans, - } => { - commands::tree::run(TreeArgs { - source_path, - source_text, - lang, - raw, - spans, - }); + match matches.subcommand() { + Some(("tree", m)) => { + let params = TreeParams::from_matches(m); + commands::tree::run(params.into()); } - Command::Check { - query_path, - query_text, - lang, - strict, - output, - } => { - commands::check::run(CheckArgs { - query_path, - query_text, - lang, - strict, - color: output.color.should_colorize(), - }); + Some(("check", m)) => { + let params = CheckParams::from_matches(m); + commands::check::run(params.into()); } - Command::Dump { - query_path, - query_text, - lang, - output, - } => { - commands::dump::run(DumpArgs { - query_path, - query_text, - lang, - color: output.color.should_colorize(), - }); + Some(("dump", m)) => { + let params = DumpParams::from_matches(m); + commands::dump::run(params.into()); } - Command::Infer { - query_path, - query_text, - lang, - infer_output, - output, - } => { - commands::infer::run(InferArgs { - query_path, - query_text, - lang, - format: infer_output.format, - verbose_nodes: infer_output.verbose_nodes, - no_node_type: infer_output.no_node_type, - export: !infer_output.no_export, - output: infer_output.output, - color: output.color.should_colorize(), - void_type: infer_output.void_type, - }); + Some(("infer", m)) => { + let params = InferParams::from_matches(m); + commands::infer::run(params.into()); } - Command::Exec { - query_path, - source_path, - query_text, - source_text, - lang, - exec_output, - output, - } => { - // When -q is used with a single positional, shift it to source - let (query_path, source_path) = shift_positional_to_source( - query_text.is_some(), - query_path, - source_path, - ); - - // Pretty by default when stdout is a TTY, unless --compact is passed - let pretty = - !exec_output.compact && std::io::IsTerminal::is_terminal(&std::io::stdout()); - commands::exec::run(ExecArgs { - query_path, - query_text, - source_path, - source_text, - lang, - pretty, - entry: exec_output.entry, - color: output.color.should_colorize(), - }); + Some(("exec", m)) => { + let params = ExecParams::from_matches(m); + commands::exec::run(params.into()); } - Command::Trace { - query_path, - source_path, - query_text, - source_text, - lang, - entry, - verbose, - no_result, - fuel, - output, - } => { - // When -q is used with a single positional, shift it to source - let (query_path, source_path) = shift_positional_to_source( - query_text.is_some(), - query_path, - source_path, - ); - - use plotnik_lib::engine::Verbosity; - - let verbosity = match verbose { - 0 => Verbosity::Default, - 1 => Verbosity::Verbose, - _ => Verbosity::VeryVerbose, - }; - commands::trace::run(TraceArgs { - query_path, - query_text, - source_path, - source_text, - lang, - entry, - verbosity, - no_result, - fuel, - color: output.color.should_colorize(), - }); + Some(("trace", m)) => { + let params = TraceParams::from_matches(m); + commands::trace::run(params.into()); } - Command::Langs => { + Some(("langs", m)) => { + let _params = LangsParams::from_matches(m); commands::langs::run(); } + _ => unreachable!("clap should have caught this"), } }