rukalang/
cli.rs

1use std::ffi::{OsStr, OsString};
2use std::path::PathBuf;
3
4use pico_args::Arguments;
5use thiserror::Error;
6
7/// Parsed CLI configuration for one compiler invocation.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct CliConfig {
10    /// Input source file path.
11    pub input_path: Option<PathBuf>,
12    /// Output path for emitted Rust source.
13    pub emit_rust_path: Option<PathBuf>,
14    /// Output path for emitted WAT text.
15    pub emit_wat_path: Option<PathBuf>,
16    /// Output path for emitted WASM bytes.
17    pub emit_wasm_path: Option<PathBuf>,
18    /// Whether to compile and run generated Rust.
19    pub run: bool,
20    /// Whether to print the parsed AST.
21    pub dump_ast: bool,
22    /// Whether to print lowered HIR.
23    pub dump_hir: bool,
24    /// Whether to print lexer tokens.
25    pub dump_tokens: bool,
26    /// Whether to print pass timing output.
27    pub dump_pass_timings: bool,
28    /// Whether to print pass snapshot output.
29    pub dump_pass_snapshots: bool,
30    /// Whether to print pass snapshot output as JSON lines.
31    pub dump_pass_snapshots_json: bool,
32    /// Whether to print CLI help text.
33    pub show_help: bool,
34    /// Whether to print CLI version text.
35    pub show_version: bool,
36}
37
38/// CLI argument parsing error.
39#[derive(Debug, Clone, Error, PartialEq, Eq)]
40pub enum CliError {
41    /// An unknown CLI option was provided.
42    #[error("unknown option: {0}")]
43    UnknownOption(String),
44    /// No input file path was provided.
45    #[error("missing input file path")]
46    MissingInputPath,
47    /// More than one input file path was provided.
48    #[error("too many input file paths provided")]
49    TooManyInputPaths,
50    /// `--emit-rust` was passed without a value.
51    #[error("missing value for --emit-rust=<path>")]
52    MissingEmitRustPath,
53    /// `--emit-wat` was passed without a value.
54    #[error("missing value for --emit-wat=<path>")]
55    MissingEmitWatPath,
56    /// `--emit-wasm` was passed without a value.
57    #[error("missing value for --emit-wasm=<path>")]
58    MissingEmitWasmPath,
59}
60
61/// Parse CLI arguments from the current process environment.
62pub fn parse_env() -> Result<CliConfig, CliError> {
63    parse_from(std::env::args_os().skip(1).collect())
64}
65
66/// Parse CLI arguments from an explicit vector.
67///
68/// ```
69/// use std::ffi::OsString;
70/// use std::path::PathBuf;
71///
72/// let args = vec![OsString::from("--run"), OsString::from("examples/basics.rk")];
73/// let cfg = rukalang::cli::parse_from(args).expect("arguments should parse");
74///
75/// assert!(cfg.run);
76/// assert_eq!(cfg.input_path, Some(PathBuf::from("examples/basics.rk")));
77/// ```
78pub fn parse_from(args: Vec<OsString>) -> Result<CliConfig, CliError> {
79    let emit_config = parse_emit_flags(args)?;
80    let mut pargs = Arguments::from_vec(emit_config.remaining_args);
81
82    let show_help = pargs.contains(["-h", "--help"]);
83    let show_version = pargs.contains(["-V", "--version"]);
84    let dump_ast = pargs.contains("--dump-ast");
85    let dump_hir = pargs.contains("--dump-hir");
86    let dump_tokens = pargs.contains("--dump-tokens");
87    let dump_pass_timings = pargs.contains("--dump-pass-timings");
88    let dump_pass_snapshots = pargs.contains("--dump-pass-snapshots");
89    let dump_pass_snapshots_json = pargs.contains("--dump-pass-snapshots-json");
90    let run = pargs.contains("--run");
91
92    let remaining = pargs.finish();
93
94    if let Some(option) = remaining.iter().find(|arg| is_option_like(arg)) {
95        return Err(CliError::UnknownOption(
96            option.to_string_lossy().into_owned(),
97        ));
98    }
99
100    let mut paths = remaining
101        .into_iter()
102        .map(PathBuf::from)
103        .collect::<Vec<PathBuf>>();
104
105    let input_path = if show_help || show_version {
106        None
107    } else {
108        match paths.len() {
109            0 => return Err(CliError::MissingInputPath),
110            1 => paths.pop(),
111            _ => return Err(CliError::TooManyInputPaths),
112        }
113    };
114
115    Ok(CliConfig {
116        input_path,
117        emit_rust_path: emit_config.emit_rust_path,
118        emit_wat_path: emit_config.emit_wat_path,
119        emit_wasm_path: emit_config.emit_wasm_path,
120        run,
121        dump_ast,
122        dump_hir,
123        dump_tokens,
124        dump_pass_timings,
125        dump_pass_snapshots,
126        dump_pass_snapshots_json,
127        show_help,
128        show_version,
129    })
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133struct EmitConfig {
134    remaining_args: Vec<OsString>,
135    emit_rust_path: Option<PathBuf>,
136    emit_wat_path: Option<PathBuf>,
137    emit_wasm_path: Option<PathBuf>,
138}
139
140/// Parse CLI emit-path flags before delegating to `pico_args`.
141fn parse_emit_flags(args: Vec<OsString>) -> Result<EmitConfig, CliError> {
142    let mut remaining_args = Vec::new();
143    let mut emit_rust_path = None;
144    let mut emit_wat_path = None;
145    let mut emit_wasm_path = None;
146
147    for arg in args {
148        let text = arg.to_string_lossy();
149
150        if text == "--emit-rust" {
151            return Err(CliError::MissingEmitRustPath);
152        }
153
154        if let Some(value) = text.strip_prefix("--emit-rust=") {
155            if value.is_empty() {
156                return Err(CliError::MissingEmitRustPath);
157            }
158            emit_rust_path = Some(PathBuf::from(value));
159            continue;
160        }
161
162        if text == "--emit-wat" {
163            return Err(CliError::MissingEmitWatPath);
164        }
165
166        if let Some(value) = text.strip_prefix("--emit-wat=") {
167            if value.is_empty() {
168                return Err(CliError::MissingEmitWatPath);
169            }
170            emit_wat_path = Some(PathBuf::from(value));
171            continue;
172        }
173
174        if text == "--emit-wasm" {
175            return Err(CliError::MissingEmitWasmPath);
176        }
177
178        if let Some(value) = text.strip_prefix("--emit-wasm=") {
179            if value.is_empty() {
180                return Err(CliError::MissingEmitWasmPath);
181            }
182            emit_wasm_path = Some(PathBuf::from(value));
183            continue;
184        }
185
186        remaining_args.push(arg);
187    }
188
189    Ok(EmitConfig {
190        remaining_args,
191        emit_rust_path,
192        emit_wat_path,
193        emit_wasm_path,
194    })
195}
196
197/// Return whether an argument still looks like an option after parsing.
198fn is_option_like(arg: &OsStr) -> bool {
199    arg.to_string_lossy().starts_with('-')
200}
201
202/// Render the CLI help text.
203pub fn help_text() -> String {
204    format!(
205        "Usage:\n  rukalang [OPTIONS] <file>\n\nOptions:\n  --dump-ast                 Print AST output\n  --dump-hir                 Print HIR output\n  --dump-tokens              Print lexer token output\n  --dump-pass-timings        Print per-pass timing output\n  --dump-pass-snapshots      Print per-pass snapshot output\n  --dump-pass-snapshots-json Print per-pass snapshots as JSON lines\n  --emit-rust=<path>         Emit Rust source to path\n  --emit-wat=<path>          Emit WAT source to path\n  --emit-wasm=<path>         Emit WASM binary to path\n  --run                      Compile and run emitted Rust source\n  -h, --help                 Print help\n  -V, --version              Print version\n"
206    )
207}
208
209/// Return a short version string used by the CLI.
210///
211/// ```
212/// let version = rukalang::cli::version_text();
213/// assert!(version.starts_with("rukalang "));
214/// ```
215pub fn version_text() -> String {
216    format!("rukalang {}", env!("CARGO_PKG_VERSION"))
217}
218
219#[cfg(test)]
220mod tests;