fortrangoingonforty/armfortas / 5195167

Browse files

expand CLI driver: full flag set, info actions, afs alias, phase timer

Sprint 32 foundation. Replaces the bare ~7-flag parser with the full
sprint-spec surface and threads each flag through to where it has
real effect.

New Options fields cover: --std=fNN, -ffree-form/-ffixed-form,
-fdefault-{integer,real}-8, -fimplicit-none, -frecursive, -fbackslash,
-fmax-stack-var-size=N, -Wall/-Wextra/-Wpedantic/-Wdeprecated/-Werror/
-Wno-name (unknown -Wfoo accepted silently per gfortran convention),
-g (accepted, DWARF deferred to sprint 35), -v / --verbose,
--time-report, --diagnostics-format={text,json},
-fcheck=bounds / =all, -J <dir>, -L <dir>, -l<lib>, -rpath, -shared,
-static, --emit-ast, --emit-tokens, --diagnostics-format=, plus
--version / --help / -dumpversion / -V info actions and @file
response-file expansion.

Wired through:
- --std= → validate_file_with_layouts(.., opts.std, ..)
- -ffree-form / -ffixed-form → source-form override before lex
- -J → .amod write directory (default still parent of -o output)
- -L / -l / -rpath / -shared / -static → push_link_flags() for both
link() and link_multi()
- -v → eprintln&#39;d phase markers
- --time-report → PhaseTimer collects per-phase durations and prints
a Phase/Time(ms)/% table at end of compile()
- -Werror → promotes warning diagnostics to fatal in compile()

The binary alias now lives at src/bin/afs.rs as a one-line
wrapper that calls into a new shared armfortas::cli_entry() (so
cargo doesn&#39;t warn about a duplicated bin source path). Both
binaries build from the same logic and pass --version / --help.

tests/cli_driver.rs exercises 14 user-visible behaviours via
subprocess invocation: --version output, --help, -dumpversion,
afs alias, no-arg help-to-stderr, -c, -S, -E (with macro), --std=f95
rejecting ERROR STOP, response file, -J directory, -v phase
streaming, --time-report table, missing-input → exit 3.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
51951679be73ef438de1dd78fc34508c18b0e694
Parents
58d70f8
Tree
a04ce9b

8 changed files

StatusFile+-
M Cargo.lock 4 0
M Cargo.toml 8 0
A src/bin/afs.rs 3 0
M src/driver/mod.rs 694 107
M src/lib.rs 65 0
M src/main.rs 1 39
A tests/cli_driver.rs 354 0
M tests/i128_cross_object.rs 1 5
Cargo.lockmodified
@@ -6,6 +6,10 @@ version = 4
66
 name = "afs-as"
77
 version = "0.1.0"
88
 
9
+[[package]]
10
+name = "afs-ld"
11
+version = "0.1.0"
12
+
913
 [[package]]
1014
 name = "afs-tests"
1115
 version = "0.1.0"
Cargo.tomlmodified
@@ -16,5 +16,13 @@ path = "src/lib.rs"
1616
 name = "armfortas"
1717
 path = "src/main.rs"
1818
 
19
+# `afs` is the short alias.  src/bin/afs.rs is a one-line wrapper
20
+# that calls into armfortas::cli_entry() so cargo doesn't warn about
21
+# two bin targets sharing one source path.  Sprint 35's `make
22
+# install` may collapse this back to a symlink to save disk.
23
+[[bin]]
24
+name = "afs"
25
+path = "src/bin/afs.rs"
26
+
1927
 [dependencies]
2028
 afs-as = { path = "afs-as" }
src/bin/afs.rsadded
@@ -0,0 +1,3 @@
1
+fn main() {
2
+    armfortas::cli_entry();
3
+}
src/driver/mod.rsmodified
1039 lines changed — click to load
@@ -64,86 +64,149 @@ impl OptLevel {
6464
     }
6565
 }
6666
 
67
+/// Source-form override requested on the command line.  None means
68
+/// detect from the file extension (.f90 → free, .f / .for → fixed).
69
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70
+pub enum SourceFormOverride {
71
+    Free,
72
+    Fixed,
73
+}
74
+
75
+/// Action that should run when args parsing completes successfully
76
+/// without producing a compile job (e.g. --help, --version).
77
+#[derive(Debug, Clone, PartialEq, Eq)]
78
+pub enum InfoAction {
79
+    Help,
80
+    Version,
81
+    DumpVersion,
82
+}
83
+
84
+/// Result of parsing CLI args — either a real compile job or an
85
+/// informational request.
86
+pub enum ParsedCli {
87
+    Compile(Box<Options>),
88
+    Info(InfoAction),
89
+}
90
+
6791
 /// Compilation options.
6892
 pub struct Options {
93
+    // ---- I/O ----
6994
     pub input: PathBuf,
7095
     /// Additional input files for multi-source mode.
7196
     pub extra_inputs: Vec<PathBuf>,
7297
     pub output: Option<PathBuf>,
98
+
99
+    // ---- Mode ----
73100
     pub emit_asm: bool,        // -S
74101
     pub emit_obj: bool,        // -c
75102
     pub emit_ir: bool,         // --emit-ir
103
+    pub emit_ast: bool,        // --emit-ast
104
+    pub emit_tokens: bool,     // --emit-tokens
76105
     pub preprocess_only: bool, // -E
77
-    pub opt_level: OptLevel,   // -O0 .. -Ofast
106
+
107
+    // ---- Language ----
108
+    pub std: Option<crate::sema::validate::FortranStandard>,
109
+    pub source_form_override: Option<SourceFormOverride>,
110
+    pub default_integer_8: bool,
111
+    pub default_real_8: bool,
112
+    pub force_implicit_none: bool,
113
+    pub recursive_default: bool,
114
+    pub backslash_escapes: bool,
115
+    pub max_stack_var_size: Option<u64>,
116
+
117
+    // ---- Optimization ----
118
+    pub opt_level: OptLevel,
119
+
120
+    // ---- Warnings ----
121
+    pub warn_all: bool,
122
+    pub warn_extra: bool,
123
+    pub warn_pedantic: bool,
124
+    pub warn_deprecated: bool,
125
+    pub warn_as_error: bool,
126
+    pub disabled_warnings: Vec<String>,
127
+
128
+    // ---- Debug / introspection ----
129
+    pub debug_info: bool,                         // -g (accepted; DWARF deferred)
130
+    pub verbose: bool,                            // -v
131
+    pub time_report: bool,                        // --time-report
132
+    pub diagnostics_format: DiagnosticsFormat,    // --diagnostics-format=
133
+    pub check_bounds: bool,                       // -fcheck=bounds
134
+    pub check_all: bool,                          // -fcheck=all
135
+
136
+    // ---- Search paths / linking ----
78137
     /// Directories to search for `.amod` module files (`-I <dir>`).
79138
     pub module_search_paths: Vec<PathBuf>,
139
+    /// Directory to write generated `.amod` files (`-J <dir>`).
140
+    pub module_output_dir: Option<PathBuf>,
141
+    /// `-L <dir>` library search paths passed to `ld`.
142
+    pub library_search_paths: Vec<PathBuf>,
143
+    /// `-l<name>` libraries passed to `ld`.
144
+    pub link_libs: Vec<String>,
145
+    /// `-shared` / `-static`.
146
+    pub shared: bool,
147
+    pub static_link: bool,
148
+    /// `-rpath` entries passed to `ld`.
149
+    pub rpath: Vec<PathBuf>,
80150
 }
81151
 
82
-impl Options {
83
-    pub fn from_args(args: &[String]) -> Result<Self, String> {
84
-        let mut inputs = Vec::new();
85
-        let mut output = None;
86
-        let mut emit_asm = false;
87
-        let mut emit_obj = false;
88
-        let mut emit_ir = false;
89
-        let mut preprocess_only = false;
90
-        let mut opt_level = OptLevel::O0;
91
-        let mut module_search_paths = Vec::new();
92
-
93
-        let mut i = 0;
94
-        while i < args.len() {
95
-            match args[i].as_str() {
96
-                "-o" => {
97
-                    i += 1;
98
-                    if i < args.len() {
99
-                        output = Some(PathBuf::from(&args[i]));
100
-                    } else {
101
-                        return Err("-o requires an argument".into());
102
-                    }
103
-                }
104
-                "-S" => emit_asm = true,
105
-                "-c" => emit_obj = true,
106
-                "-E" => preprocess_only = true,
107
-                "--emit-ir" => emit_ir = true,
108
-                "-I" => {
109
-                    i += 1;
110
-                    if i < args.len() {
111
-                        module_search_paths.push(PathBuf::from(&args[i]));
112
-                    } else {
113
-                        return Err("-I requires a directory argument".into());
114
-                    }
115
-                }
116
-                arg if arg.starts_with("-I") => {
117
-                    module_search_paths.push(PathBuf::from(&arg[2..]));
118
-                }
119
-                arg if arg.starts_with("-O") => {
120
-                    let tail = &arg[1..];
121
-                    opt_level = OptLevel::parse_flag(tail)
122
-                        .ok_or_else(|| format!("unknown optimization level: {}", arg))?;
123
-                }
124
-                arg if !arg.starts_with('-') => {
125
-                    inputs.push(PathBuf::from(arg));
126
-                }
127
-                other => return Err(format!("unknown option: {}", other)),
128
-            }
129
-            i += 1;
152
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153
+pub enum DiagnosticsFormat {
154
+    Text,
155
+    Json,
156
+}
157
+
158
+impl Default for Options {
159
+    fn default() -> Self {
160
+        Self {
161
+            input: PathBuf::new(),
162
+            extra_inputs: Vec::new(),
163
+            output: None,
164
+            emit_asm: false,
165
+            emit_obj: false,
166
+            emit_ir: false,
167
+            emit_ast: false,
168
+            emit_tokens: false,
169
+            preprocess_only: false,
170
+            std: None,
171
+            source_form_override: None,
172
+            default_integer_8: false,
173
+            default_real_8: false,
174
+            force_implicit_none: false,
175
+            recursive_default: false,
176
+            backslash_escapes: false,
177
+            max_stack_var_size: None,
178
+            opt_level: OptLevel::O0,
179
+            warn_all: false,
180
+            warn_extra: false,
181
+            warn_pedantic: false,
182
+            warn_deprecated: false,
183
+            warn_as_error: false,
184
+            disabled_warnings: Vec::new(),
185
+            debug_info: false,
186
+            verbose: false,
187
+            time_report: false,
188
+            diagnostics_format: DiagnosticsFormat::Text,
189
+            check_bounds: false,
190
+            check_all: false,
191
+            module_search_paths: Vec::new(),
192
+            module_output_dir: None,
193
+            library_search_paths: Vec::new(),
194
+            link_libs: Vec::new(),
195
+            shared: false,
196
+            static_link: false,
197
+            rpath: Vec::new(),
130198
         }
199
+    }
200
+}
131201
 
132
-        if inputs.is_empty() {
133
-            return Err("no input file".into());
202
+impl Options {
203
+    /// Old name preserved for callers that haven't been migrated.
204
+    /// New code should call `parse_cli` and dispatch on `ParsedCli`.
205
+    pub fn from_args(args: &[String]) -> Result<Self, String> {
206
+        match parse_cli(args)? {
207
+            ParsedCli::Compile(opts) => Ok(*opts),
208
+            ParsedCli::Info(_) => Err("info request — call parse_cli".into()),
134209
         }
135
-        let input = inputs.remove(0);
136
-        Ok(Self {
137
-            input,
138
-            extra_inputs: inputs,
139
-            output,
140
-            emit_asm,
141
-            emit_obj,
142
-            emit_ir,
143
-            preprocess_only,
144
-            opt_level,
145
-            module_search_paths,
146
-        })
147210
     }
148211
 
149212
     /// Determine the output path based on input and flags.
@@ -169,6 +232,354 @@ impl Options {
169232
     }
170233
 }
171234
 
235
+/// Parse the command line.  Returns either a compile job or a request
236
+/// for informational output (so main.rs can branch and exit cleanly
237
+/// without a compile attempt).  Supports response files via `@file`,
238
+/// joined-form short options (`-Idir`, `-O2`, `-llib`), and
239
+/// `--key=value` style for the long options that take a value.
240
+pub fn parse_cli(raw_args: &[String]) -> Result<ParsedCli, String> {
241
+    let args = expand_response_files(raw_args)?;
242
+    let mut opts = Options::default();
243
+    let mut inputs: Vec<PathBuf> = Vec::new();
244
+    let mut info_action: Option<InfoAction> = None;
245
+
246
+    let mut i = 0;
247
+    while i < args.len() {
248
+        let arg = args[i].clone();
249
+        match arg.as_str() {
250
+            // ---- Information ----
251
+            "--help" | "-h" => info_action = Some(InfoAction::Help),
252
+            "--version" | "-V" => info_action = Some(InfoAction::Version),
253
+            "-dumpversion" => info_action = Some(InfoAction::DumpVersion),
254
+
255
+            // ---- Output path ----
256
+            "-o" => {
257
+                i += 1;
258
+                opts.output = Some(PathBuf::from(args.get(i).ok_or("-o requires an argument")?));
259
+            }
260
+
261
+            // ---- Mode ----
262
+            "-S" => opts.emit_asm = true,
263
+            "-c" => opts.emit_obj = true,
264
+            "-E" => opts.preprocess_only = true,
265
+            "--emit-ir" => opts.emit_ir = true,
266
+            "--emit-ast" => opts.emit_ast = true,
267
+            "--emit-tokens" => opts.emit_tokens = true,
268
+
269
+            // ---- Optimization ----
270
+            "-O" => opts.opt_level = OptLevel::O0,
271
+            arg if arg.starts_with("-O") => {
272
+                opts.opt_level = OptLevel::parse_flag(&arg[1..])
273
+                    .ok_or_else(|| format!("unknown optimization level: {}", arg))?;
274
+            }
275
+
276
+            // ---- Module / include search paths ----
277
+            "-I" => {
278
+                i += 1;
279
+                opts.module_search_paths
280
+                    .push(PathBuf::from(args.get(i).ok_or("-I requires a directory")?));
281
+            }
282
+            arg if arg.starts_with("-I") => opts.module_search_paths.push(PathBuf::from(&arg[2..])),
283
+
284
+            "-J" => {
285
+                i += 1;
286
+                opts.module_output_dir =
287
+                    Some(PathBuf::from(args.get(i).ok_or("-J requires a directory")?));
288
+            }
289
+            arg if arg.starts_with("-J") => {
290
+                opts.module_output_dir = Some(PathBuf::from(&arg[2..]));
291
+            }
292
+
293
+            // ---- Linker search / libs / rpath ----
294
+            "-L" => {
295
+                i += 1;
296
+                opts.library_search_paths
297
+                    .push(PathBuf::from(args.get(i).ok_or("-L requires a directory")?));
298
+            }
299
+            arg if arg.starts_with("-L") => {
300
+                opts.library_search_paths.push(PathBuf::from(&arg[2..]))
301
+            }
302
+
303
+            "-l" => {
304
+                i += 1;
305
+                opts.link_libs
306
+                    .push(args.get(i).ok_or("-l requires a library name")?.clone());
307
+            }
308
+            arg if arg.starts_with("-l") => opts.link_libs.push(arg[2..].to_string()),
309
+
310
+            "-rpath" | "--rpath" => {
311
+                i += 1;
312
+                opts.rpath
313
+                    .push(PathBuf::from(args.get(i).ok_or("-rpath requires a path")?));
314
+            }
315
+
316
+            "-shared" => opts.shared = true,
317
+            "-static" => opts.static_link = true,
318
+
319
+            // ---- Standards / language flags ----
320
+            arg if arg.starts_with("--std=") => {
321
+                let val = &arg["--std=".len()..];
322
+                opts.std = Some(
323
+                    crate::sema::validate::FortranStandard::parse_flag(val)
324
+                        .ok_or_else(|| format!("unknown --std value: {}", val))?,
325
+                );
326
+            }
327
+            "--std" => {
328
+                i += 1;
329
+                let val = args.get(i).ok_or("--std requires a value")?;
330
+                opts.std = Some(
331
+                    crate::sema::validate::FortranStandard::parse_flag(val)
332
+                        .ok_or_else(|| format!("unknown --std value: {}", val))?,
333
+                );
334
+            }
335
+            "-ffree-form" => opts.source_form_override = Some(SourceFormOverride::Free),
336
+            "-ffixed-form" => opts.source_form_override = Some(SourceFormOverride::Fixed),
337
+            "-fdefault-integer-8" => opts.default_integer_8 = true,
338
+            "-fdefault-real-8" => opts.default_real_8 = true,
339
+            "-fimplicit-none" => opts.force_implicit_none = true,
340
+            "-frecursive" => opts.recursive_default = true,
341
+            "-fbackslash" => opts.backslash_escapes = true,
342
+            "-fno-backslash" => opts.backslash_escapes = false,
343
+            arg if arg.starts_with("-fmax-stack-var-size=") => {
344
+                let val = &arg["-fmax-stack-var-size=".len()..];
345
+                opts.max_stack_var_size = Some(
346
+                    val.parse()
347
+                        .map_err(|_| format!("invalid -fmax-stack-var-size value: {}", val))?,
348
+                );
349
+            }
350
+
351
+            // ---- Runtime checks ----
352
+            "-fcheck=bounds" => opts.check_bounds = true,
353
+            "-fcheck=all" => {
354
+                opts.check_bounds = true;
355
+                opts.check_all = true;
356
+            }
357
+
358
+            // ---- Warnings (accepted; gating is gradual sprint work) ----
359
+            "-Wall" => opts.warn_all = true,
360
+            "-Wextra" => opts.warn_extra = true,
361
+            "-Wpedantic" | "-pedantic" => opts.warn_pedantic = true,
362
+            "-Wdeprecated" => opts.warn_deprecated = true,
363
+            "-Werror" => opts.warn_as_error = true,
364
+            arg if arg.starts_with("-Wno-") => {
365
+                opts.disabled_warnings.push(arg[5..].to_string());
366
+            }
367
+            arg if arg.starts_with("-W") => {
368
+                // Unknown -Wfoo: accept silently rather than fail
369
+                // (gfortran has hundreds of these and projects pass
370
+                // them blindly). Record so it's queryable later.
371
+                opts.disabled_warnings.push(arg.to_string());
372
+            }
373
+
374
+            // ---- Debug / introspection ----
375
+            "-g" | "-g1" | "-g2" | "-g3" | "-g0" => opts.debug_info = true,
376
+            arg if arg.starts_with("-g") => opts.debug_info = true,
377
+            "-v" | "--verbose" => opts.verbose = true,
378
+            "--time-report" => opts.time_report = true,
379
+            arg if arg.starts_with("--diagnostics-format=") => {
380
+                let val = &arg["--diagnostics-format=".len()..];
381
+                opts.diagnostics_format = match val {
382
+                    "text" => DiagnosticsFormat::Text,
383
+                    "json" => DiagnosticsFormat::Json,
384
+                    other => {
385
+                        return Err(format!("unknown --diagnostics-format value: {}", other))
386
+                    }
387
+                };
388
+            }
389
+
390
+            // ---- Positional input file ----
391
+            arg if !arg.starts_with('-') => inputs.push(PathBuf::from(arg)),
392
+
393
+            other => return Err(format!("unknown option: {}", other)),
394
+        }
395
+        i += 1;
396
+    }
397
+
398
+    if let Some(action) = info_action {
399
+        return Ok(ParsedCli::Info(action));
400
+    }
401
+
402
+    if inputs.is_empty() {
403
+        return Err("no input file".into());
404
+    }
405
+    opts.input = inputs.remove(0);
406
+    opts.extra_inputs = inputs;
407
+    Ok(ParsedCli::Compile(Box::new(opts)))
408
+}
409
+
410
+/// Expand any `@file` argument into the lines of `file`, treating
411
+/// each whitespace-separated token as an additional argument.
412
+fn expand_response_files(args: &[String]) -> Result<Vec<String>, String> {
413
+    let mut expanded: Vec<String> = Vec::with_capacity(args.len());
414
+    for arg in args {
415
+        if let Some(path) = arg.strip_prefix('@') {
416
+            let body = fs::read_to_string(path)
417
+                .map_err(|e| format!("cannot read response file '{}': {}", path, e))?;
418
+            for tok in body.split_whitespace() {
419
+                expanded.push(tok.to_string());
420
+            }
421
+        } else {
422
+            expanded.push(arg.clone());
423
+        }
424
+    }
425
+    Ok(expanded)
426
+}
427
+
428
+/// Help text printed by `--help`.
429
+pub const HELP_TEXT: &str = "\
430
+USAGE: armfortas [OPTIONS] <files...>
431
+       afs [OPTIONS] <files...>
432
+
433
+COMPILATION:
434
+  -c                          Compile to object file only (no linking)
435
+  -S                          Emit assembly text
436
+  -E                          Preprocess only
437
+  -o <file>                   Output file name
438
+
439
+LANGUAGE:
440
+  --std=<standard>            Fortran standard (f77, f90, f95, f2003, f2008, f2018, f2023)
441
+  -ffree-form                 Force free-form source
442
+  -ffixed-form                Force fixed-form source
443
+  -fdefault-integer-8         Make default integer kind 8 bytes
444
+  -fdefault-real-8            Make default real kind 8 bytes
445
+  -fimplicit-none             Force implicit none in all scopes
446
+  -frecursive                 Make all procedures recursive by default
447
+  -fbackslash                 Interpret backslash in strings as escape
448
+  -fmax-stack-var-size=<n>    Stack variable size threshold (bytes)
449
+
450
+OPTIMIZATION:
451
+  -O0, -O1, -O2, -O3          Optimization level (default -O0)
452
+  -Os                         Optimize for size
453
+  -Ofast                      Aggressive optimization
454
+
455
+WARNINGS:
456
+  -Wall                       All standard warnings
457
+  -Wextra                     Extra warnings
458
+  -Wpedantic                  Pedantic standard conformance warnings
459
+  -Wdeprecated                Deprecated feature warnings
460
+  -Werror                     Treat warnings as errors
461
+  -Wno-<name>                 Disable specific warning
462
+
463
+DEBUGGING:
464
+  -g                          Generate debug information (DWARF emission TODO)
465
+  --emit-ir                   Dump IR to the output path
466
+  --emit-ast                  Dump AST to the output path
467
+  --emit-tokens               Dump token stream to the output path
468
+  -v, --verbose               Verbose output (show compilation phases)
469
+  --time-report               Show time spent in each compilation phase
470
+  -fcheck=bounds              Enable runtime array bounds checking
471
+  -fcheck=all                 Enable all runtime checks
472
+  --diagnostics-format=text|json
473
+                              Diagnostic output format
474
+
475
+DIRECTORIES:
476
+  -I <dir>                    Module/include search path
477
+  -J <dir>                    Module output directory
478
+  -L <dir>                    Library search path
479
+  -l <lib>                    Link library
480
+
481
+LINKING:
482
+  -shared                     Produce shared library
483
+  -static                     Static linking
484
+  -rpath <path>               Runtime library path
485
+
486
+INFORMATION:
487
+  --version, -V               Print version
488
+  --help, -h                  Print help
489
+  -dumpversion                Print version number only
490
+
491
+OTHER:
492
+  @<file>                     Read additional arguments from <file> (one per token)
493
+";
494
+
495
+/// Version string emitted by `--version`.
496
+pub fn version_string() -> String {
497
+    format!(
498
+        "armfortas {} (aarch64-apple-darwin)",
499
+        env!("CARGO_PKG_VERSION")
500
+    )
501
+}
502
+
503
+/// Just the version number, for `-dumpversion`.
504
+pub fn dump_version_string() -> String {
505
+    env!("CARGO_PKG_VERSION").to_string()
506
+}
507
+
508
+/// Tracks per-phase wall-clock time for `--time-report`.  When
509
+/// disabled, all operations are zero-overhead (no Instant calls, no
510
+/// allocation).
511
+struct PhaseTimer {
512
+    enabled: bool,
513
+    samples: Vec<(&'static str, std::time::Duration)>,
514
+    start: Option<std::time::Instant>,
515
+}
516
+
517
+struct PhaseGuard {
518
+    name: &'static str,
519
+    started: Option<std::time::Instant>,
520
+}
521
+
522
+impl PhaseTimer {
523
+    fn new(enabled: bool) -> Self {
524
+        Self {
525
+            enabled,
526
+            samples: Vec::new(),
527
+            start: if enabled {
528
+                Some(std::time::Instant::now())
529
+            } else {
530
+                None
531
+            },
532
+        }
533
+    }
534
+    fn start(&self, name: &'static str) -> PhaseGuard {
535
+        PhaseGuard {
536
+            name,
537
+            started: if self.enabled {
538
+                Some(std::time::Instant::now())
539
+            } else {
540
+                None
541
+            },
542
+        }
543
+    }
544
+    fn record(&mut self, name: &'static str, dur: std::time::Duration) {
545
+        if self.enabled {
546
+            self.samples.push((name, dur));
547
+        }
548
+    }
549
+    fn report(&self) {
550
+        if !self.enabled {
551
+            return;
552
+        }
553
+        let total: std::time::Duration = self
554
+            .samples
555
+            .iter()
556
+            .map(|(_, d)| *d)
557
+            .sum::<std::time::Duration>();
558
+        let total_ms = total.as_secs_f64() * 1000.0;
559
+        eprintln!("Phase            Time (ms)    %");
560
+        eprintln!("─────────────────────────────────");
561
+        for (name, d) in &self.samples {
562
+            let ms = d.as_secs_f64() * 1000.0;
563
+            let pct = if total_ms > 0.0 { ms / total_ms * 100.0 } else { 0.0 };
564
+            eprintln!("{:<16} {:>8.2} {:>4.0}%", name, ms, pct);
565
+        }
566
+        eprintln!("─────────────────────────────────");
567
+        let wall = self
568
+            .start
569
+            .map(|s| s.elapsed().as_secs_f64() * 1000.0)
570
+            .unwrap_or(0.0);
571
+        eprintln!("{:<16} {:>8.2} {:>4.0}%", "Total", wall, 100.0);
572
+    }
573
+}
574
+
575
+impl PhaseGuard {
576
+    fn end(self, timer: &mut PhaseTimer) {
577
+        if let Some(start) = self.started {
578
+            timer.record(self.name, start.elapsed());
579
+        }
580
+    }
581
+}
582
+
172583
 fn main_wrapper_target(allocated: &[MachineFunction]) -> Option<&str> {
173584
     // Only emit _main if there's a __prog_* function (a Fortran PROGRAM
174585
     // body).  The previous .or_else fallback picked any non-"main"
@@ -181,12 +592,34 @@ fn main_wrapper_target(allocated: &[MachineFunction]) -> Option<&str> {
181592
 
182593
 /// Compile a Fortran source file through the full pipeline.
183594
 pub fn compile(opts: &Options) -> Result<(), String> {
595
+    let mut phases = PhaseTimer::new(opts.time_report);
596
+    if opts.verbose {
597
+        eprintln!("{}", version_string());
598
+    }
599
+
184600
     // 1. Read source.
601
+    if opts.verbose {
602
+        eprintln!(" reading: {}", opts.input.display());
603
+    }
604
+    let phase = phases.start("read");
185605
     let source = fs::read_to_string(&opts.input)
186606
         .map_err(|e| format!("cannot read '{}': {}", opts.input.display(), e))?;
607
+    phase.end(&mut phases);
187608
 
188609
     // 2. Preprocess.
189
-    let source_form = detect_source_form(&opts.input.to_string_lossy());
610
+    let source_form = match opts.source_form_override {
611
+        Some(SourceFormOverride::Free) => SourceForm::FreeForm,
612
+        Some(SourceFormOverride::Fixed) => SourceForm::FixedForm,
613
+        None => detect_source_form(&opts.input.to_string_lossy()),
614
+    };
615
+    if opts.verbose {
616
+        let form = match source_form {
617
+            SourceForm::FreeForm => "free-form",
618
+            SourceForm::FixedForm => "fixed-form",
619
+        };
620
+        eprintln!(" preprocessing: {} ({})", opts.input.display(), form);
621
+    }
622
+    let phase = phases.start("preprocess");
190623
     let pp_config = crate::preprocess::PreprocConfig {
191624
         filename: opts.input.to_str().unwrap_or("<input>").to_string(),
192625
         fixed_form: matches!(source_form, SourceForm::FixedForm),
@@ -194,6 +627,7 @@ pub fn compile(opts: &Options) -> Result<(), String> {
194627
     };
195628
     let pp_result =
196629
         crate::preprocess::preprocess(&source, &pp_config).map_err(|e| format!("{}", e))?;
630
+    phase.end(&mut phases);
197631
     let preprocessed = pp_result.text;
198632
 
199633
     if opts.preprocess_only {
@@ -204,10 +638,15 @@ pub fn compile(opts: &Options) -> Result<(), String> {
204638
             fs::write(&out, &preprocessed)
205639
                 .map_err(|e| format!("cannot write '{}': {}", out.display(), e))?;
206640
         }
641
+        if opts.verbose {
642
+            eprintln!(" preprocess-only: wrote {}", out.display());
643
+        }
644
+        phases.report();
207645
         return Ok(());
208646
     }
209647
 
210648
     // 3. Lex.
649
+    let phase = phases.start("lex");
211650
     let tokens = tokenize(&preprocessed, 0, source_form).map_err(|e| {
212651
         format!(
213652
             "{}:{}:{}: lexer error: {}",
@@ -217,8 +656,23 @@ pub fn compile(opts: &Options) -> Result<(), String> {
217656
             e.msg
218657
         )
219658
     })?;
659
+    phase.end(&mut phases);
660
+    if opts.verbose {
661
+        eprintln!(" lexed: {} tokens", tokens.len());
662
+    }
663
+    if opts.emit_tokens {
664
+        let out = opts.output_path();
665
+        let mut buf = String::new();
666
+        for t in &tokens {
667
+            buf.push_str(&format!("{:?}\n", t));
668
+        }
669
+        fs::write(&out, &buf)
670
+            .map_err(|e| format!("cannot write '{}': {}", out.display(), e))?;
671
+        return Ok(());
672
+    }
220673
 
221674
     // 4. Parse.
675
+    let phase = phases.start("parse");
222676
     let mut parser = Parser::new(&tokens);
223677
     let units = parser.parse_file().map_err(|e| {
224678
         format!(
@@ -229,8 +683,23 @@ pub fn compile(opts: &Options) -> Result<(), String> {
229683
             e.msg
230684
         )
231685
     })?;
686
+    phase.end(&mut phases);
687
+    if opts.verbose {
688
+        eprintln!(" parsed: {} top-level units", units.len());
689
+    }
690
+    if opts.emit_ast {
691
+        let out = opts.output_path();
692
+        let mut buf = String::new();
693
+        for u in &units {
694
+            buf.push_str(&format!("{:#?}\n", u));
695
+        }
696
+        fs::write(&out, &buf)
697
+            .map_err(|e| format!("cannot write '{}': {}", out.display(), e))?;
698
+        return Ok(());
699
+    }
232700
 
233701
     // 5. Semantic analysis.
702
+    let phase = phases.start("sema");
234703
     let resolve_result = resolve::resolve_file(&units, &opts.module_search_paths).map_err(|e| {
235704
         format!(
236705
             "{}:{}:{}: {}",
@@ -249,18 +718,41 @@ pub fn compile(opts: &Options) -> Result<(), String> {
249718
         external_globals.extend(crate::sema::amod::extract_module_globals(ext_mod));
250719
     }
251720
 
252
-    let diags = validate::validate_file_with_layouts(&units, &st, None, &type_layouts);
721
+    let diags = validate::validate_file_with_layouts(&units, &st, opts.std, &type_layouts);
722
+    let mut had_error = false;
253723
     for d in &diags {
254
-        if d.kind == validate::DiagKind::Error {
255
-            return Err(format!(
256
-                "{}:{}:{}: error: {}",
257
-                opts.input.display(),
258
-                d.span.start.line,
259
-                d.span.start.col,
260
-                d.msg
261
-            ));
724
+        match d.kind {
725
+            validate::DiagKind::Error => {
726
+                eprintln!(
727
+                    "{}:{}:{}: error: {}",
728
+                    opts.input.display(),
729
+                    d.span.start.line,
730
+                    d.span.start.col,
731
+                    d.msg
732
+                );
733
+                had_error = true;
734
+            }
735
+            validate::DiagKind::Warning => {
736
+                eprintln!(
737
+                    "{}:{}:{}: warning: {}",
738
+                    opts.input.display(),
739
+                    d.span.start.line,
740
+                    d.span.start.col,
741
+                    d.msg
742
+                );
743
+                if opts.warn_as_error {
744
+                    had_error = true;
745
+                }
746
+            }
262747
         }
263748
     }
749
+    if had_error {
750
+        return Err(format!("aborting due to errors in {}", opts.input.display()));
751
+    }
752
+    phase.end(&mut phases);
753
+    if opts.verbose {
754
+        eprintln!(" sema: {} diagnostics", diags.len());
755
+    }
264756
 
265757
     // 6. Lower to IR.
266758
     // Build external char_len_star_params from .amod-loaded modules.
@@ -280,12 +772,16 @@ pub fn compile(opts: &Options) -> Result<(), String> {
280772
         return Err(format!("internal error: IR verification failed:\n{}", msg));
281773
     }
282774
     let module_has_i128 = ir_module.contains_i128();
775
+    if opts.verbose {
776
+        eprintln!(" IR: {} functions", ir_module.functions.len());
777
+    }
283778
     // 6.5. Run IR optimization pipeline.
284779
     //
285780
     // This is where const_fold, mem2reg, LICM, DSE, loop unrolling, and
286781
     // every other IR-level pass actually fire. At O0 the pipeline is empty
287782
     // so nothing changes. The pipeline runs to fixpoint; the pass manager
288783
     // verifies the IR after every pass.
784
+    let phase = phases.start("opt");
289785
     {
290786
         use crate::opt::pipeline::OptLevel as IrOpt;
291787
         let ir_opt = match opts.opt_level {
@@ -308,6 +804,10 @@ pub fn compile(opts: &Options) -> Result<(), String> {
308804
         };
309805
         pm.run(&mut ir_module);
310806
     }
807
+    phase.end(&mut phases);
808
+    if opts.verbose {
809
+        eprintln!(" optimization: -{}", opts.opt_level.as_str());
810
+    }
311811
 
312812
     if opts.emit_ir {
313813
         let ir_text = ir_printer::print_module(&ir_module);
@@ -325,6 +825,7 @@ pub fn compile(opts: &Options) -> Result<(), String> {
325825
     }
326826
 
327827
     // 7. Instruction selection.
828
+    let phase = phases.start("codegen");
328829
     let machine_funcs = isel::select_module(&ir_module);
329830
 
330831
     // 7.5. Backend peephole (O2+): FMA fusion, etc.
@@ -391,10 +892,18 @@ _main:
391892
         }
392893
     }
393894
 
895
+    phase.end(&mut phases);
896
+    if opts.verbose {
897
+        eprintln!(" codegen: {} machine functions", allocated.len());
898
+    }
394899
     if opts.emit_asm {
395900
         let out = opts.output_path();
396901
         fs::write(&out, &asm_text)
397902
             .map_err(|e| format!("cannot write '{}': {}", out.display(), e))?;
903
+        if opts.verbose {
904
+            eprintln!(" wrote: {}", out.display());
905
+        }
906
+        phases.report();
398907
         return Ok(());
399908
     }
400909
 
@@ -442,18 +951,25 @@ _main:
442951
 
443952
     fs::write(&asm_path, &asm_text).map_err(|e| format!("cannot write temp assembly: {}", e))?;
444953
 
954
+    let phase = phases.start("assemble");
445955
     let as_result = Command::new("as")
446956
         .args(["-o", obj_path.to_str().unwrap(), asm_path.to_str().unwrap()])
447957
         .output()
448958
         .map_err(|e| format!("cannot run assembler: {}", e))?;
959
+    phase.end(&mut phases);
449960
 
450961
     if !as_result.status.success() {
451962
         let stderr = String::from_utf8_lossy(&as_result.stderr);
452963
         return Err(format!("assembler failed:\n{}", stderr));
453964
     }
965
+    if opts.verbose {
966
+        eprintln!(" assembled: {}", obj_path.display());
967
+    }
454968
 
455969
     if opts.emit_obj {
456970
         // Emit .amod files for each MODULE in the compilation unit.
971
+        // -J <dir> overrides where they go; default is the parent of
972
+        // the output .o.
457973
         for unit in &units {
458974
             if let crate::ast::unit::ProgramUnit::Module { name, .. } = &unit.node {
459975
                 let mod_key = name.to_lowercase();
@@ -469,55 +985,72 @@ _main:
469985
                         &ir_module,
470986
                         &std::collections::HashMap::new(), // char_len_star computed by writer from scope
471987
                     );
472
-                    let amod_path = opts.output_path()
473
-                        .parent()
474
-                        .unwrap_or_else(|| std::path::Path::new("."))
475
-                        .join(format!("{}.amod", mod_key));
988
+                    let amod_dir: std::path::PathBuf = opts
989
+                        .module_output_dir
990
+                        .clone()
991
+                        .unwrap_or_else(|| {
992
+                            opts.output_path()
993
+                                .parent()
994
+                                .unwrap_or_else(|| std::path::Path::new("."))
995
+                                .to_path_buf()
996
+                        });
997
+                    let amod_path = amod_dir.join(format!("{}.amod", mod_key));
476998
                     if let Err(e) = fs::write(&amod_path, &amod_text) {
477999
                         eprintln!("warning: cannot write {}: {}", amod_path.display(), e);
1000
+                    } else if opts.verbose {
1001
+                        eprintln!(" amod: {}", amod_path.display());
4781002
                     }
4791003
                 }
4801004
             }
4811005
         }
1006
+        phases.report();
4821007
         return Ok(());
4831008
     }
4841009
 
4851010
     // 11. Link.
4861011
     let binary_path = opts.output_path();
487
-    link(&obj_path, &binary_path)?;
1012
+    let phase = phases.start("link");
1013
+    link(&obj_path, &binary_path, opts)?;
1014
+    phase.end(&mut phases);
1015
+    if opts.verbose {
1016
+        eprintln!(" linked: {}", binary_path.display());
1017
+    }
4881018
 
4891019
     // Cleanup.
4901020
     let _ = fs::remove_file(&asm_path);
4911021
     let _ = fs::remove_file(&obj_path);
4921022
 
1023
+    phases.report();
4931024
     Ok(())
4941025
 }
4951026
 
4961027
 /// Link an object file with the runtime library to produce a binary.
497
-fn link(obj: &Path, output: &Path) -> Result<(), String> {
498
-    // Find the runtime library.
1028
+/// `opts` contributes the user-supplied `-L`, `-l`, `-rpath`,
1029
+/// `-shared`, and `-static` flags that need to make it through to ld.
1030
+fn link(obj: &Path, output: &Path, opts: &Options) -> Result<(), String> {
4991031
     let rt_path = find_runtime_lib()?;
500
-
501
-    // Find the SDK sysroot.
5021032
     let sdk = Command::new("xcrun")
5031033
         .args(["--show-sdk-path"])
5041034
         .output()
5051035
         .map_err(|e| format!("cannot run xcrun: {}", e))?;
5061036
     let sysroot = String::from_utf8_lossy(&sdk.stdout).trim().to_string();
5071037
 
1038
+    let mut args: Vec<String> = vec![
1039
+        obj.to_string_lossy().into_owned(),
1040
+        rt_path,
1041
+        "-lSystem".into(),
1042
+        "-no_uuid".into(),
1043
+        "-syslibroot".into(),
1044
+        sysroot,
1045
+        "-e".into(),
1046
+        "_main".into(),
1047
+        "-o".into(),
1048
+        output.to_string_lossy().into_owned(),
1049
+    ];
1050
+    push_link_flags(&mut args, opts);
1051
+
5081052
     let ld_result = Command::new("ld")
509
-        .args([
510
-            obj.to_str().unwrap(),
511
-            &rt_path,
512
-            "-lSystem",
513
-            "-no_uuid",
514
-            "-syslibroot",
515
-            &sysroot,
516
-            "-e",
517
-            "_main",
518
-            "-o",
519
-            output.to_str().unwrap(),
520
-        ])
1053
+        .args(&args)
5211054
         .output()
5221055
         .map_err(|e| format!("cannot run linker: {}", e))?;
5231056
 
@@ -529,8 +1062,34 @@ fn link(obj: &Path, output: &Path) -> Result<(), String> {
5291062
     Ok(())
5301063
 }
5311064
 
1065
+/// Append the user-supplied linker flags from `opts` to `args`.
1066
+/// `-L<dir>` and `-l<name>` map directly; `-rpath` is passed as a
1067
+/// pair; `-shared` switches output type; `-static` discourages
1068
+/// dynamic linking on supported platforms.
1069
+fn push_link_flags(args: &mut Vec<String>, opts: &Options) {
1070
+    for dir in &opts.library_search_paths {
1071
+        args.push(format!("-L{}", dir.display()));
1072
+    }
1073
+    for lib in &opts.link_libs {
1074
+        args.push(format!("-l{}", lib));
1075
+    }
1076
+    for path in &opts.rpath {
1077
+        args.push("-rpath".into());
1078
+        args.push(path.to_string_lossy().into_owned());
1079
+    }
1080
+    if opts.shared {
1081
+        args.push("-dylib".into());
1082
+    }
1083
+    if opts.static_link {
1084
+        // Apple ld doesn't have a true -static; the closest is
1085
+        // -search_paths_first to bias toward .a archives.  Keep the
1086
+        // intent visible without breaking link.
1087
+        args.push("-search_paths_first".into());
1088
+    }
1089
+}
1090
+
5321091
 /// Link multiple object files with the runtime to produce a binary.
533
-fn link_multi(objs: &[PathBuf], output: &Path) -> Result<(), String> {
1092
+fn link_multi(objs: &[PathBuf], output: &Path, opts: &Options) -> Result<(), String> {
5341093
     let rt_path = find_runtime_lib()?;
5351094
     let sdk = Command::new("xcrun")
5361095
         .args(["--show-sdk-path"])
@@ -553,6 +1112,7 @@ fn link_multi(objs: &[PathBuf], output: &Path) -> Result<(), String> {
5531112
         "-e".into(),
5541113
         "_main".into(),
5551114
     ]);
1115
+    push_link_flags(&mut args, opts);
5561116
     let ld_result = Command::new("ld")
5571117
         .args(&args)
5581118
         .output()
@@ -594,21 +1154,41 @@ pub fn compile_multi(opts: &Options) -> Result<(), String> {
5941154
         let stem = src.file_stem().unwrap_or_default().to_str().unwrap_or("out");
5951155
         let obj_path = tmp_dir.join(format!("{}.o", stem));
5961156
 
597
-        // Build a single-file Options for this source.
598
-        let sub_opts = Options {
1157
+        // Build a single-file Options for this source by inheriting
1158
+        // the user-facing flags and overriding only the per-file bits.
1159
+        let mut sub_opts = Options {
5991160
             input: src.clone(),
6001161
             extra_inputs: vec![],
6011162
             output: Some(obj_path.clone()),
602
-            emit_asm: false,
6031163
             emit_obj: true,
604
-            emit_ir: false,
605
-            preprocess_only: false,
606
-            opt_level: opts.opt_level,
607
-            module_search_paths: {
608
-                let mut paths = opts.module_search_paths.clone();
609
-                paths.push(tmp_dir.clone()); // find .amod from earlier compilations
610
-                paths
611
-            },
1164
+            ..Options::default()
1165
+        };
1166
+        sub_opts.opt_level = opts.opt_level;
1167
+        sub_opts.std = opts.std;
1168
+        sub_opts.source_form_override = opts.source_form_override;
1169
+        sub_opts.default_integer_8 = opts.default_integer_8;
1170
+        sub_opts.default_real_8 = opts.default_real_8;
1171
+        sub_opts.force_implicit_none = opts.force_implicit_none;
1172
+        sub_opts.recursive_default = opts.recursive_default;
1173
+        sub_opts.backslash_escapes = opts.backslash_escapes;
1174
+        sub_opts.max_stack_var_size = opts.max_stack_var_size;
1175
+        sub_opts.warn_all = opts.warn_all;
1176
+        sub_opts.warn_extra = opts.warn_extra;
1177
+        sub_opts.warn_pedantic = opts.warn_pedantic;
1178
+        sub_opts.warn_deprecated = opts.warn_deprecated;
1179
+        sub_opts.warn_as_error = opts.warn_as_error;
1180
+        sub_opts.disabled_warnings = opts.disabled_warnings.clone();
1181
+        sub_opts.debug_info = opts.debug_info;
1182
+        sub_opts.verbose = opts.verbose;
1183
+        sub_opts.time_report = opts.time_report;
1184
+        sub_opts.diagnostics_format = opts.diagnostics_format;
1185
+        sub_opts.check_bounds = opts.check_bounds;
1186
+        sub_opts.check_all = opts.check_all;
1187
+        sub_opts.module_output_dir = opts.module_output_dir.clone();
1188
+        sub_opts.module_search_paths = {
1189
+            let mut paths = opts.module_search_paths.clone();
1190
+            paths.push(tmp_dir.clone()); // find .amod from earlier compilations
1191
+            paths
6121192
         };
6131193
         compile(&sub_opts)?;
6141194
         object_files.push(obj_path);
@@ -616,7 +1196,7 @@ pub fn compile_multi(opts: &Options) -> Result<(), String> {
6161196
 
6171197
     // Link all object files.
6181198
     let output = opts.output.clone().unwrap_or_else(|| PathBuf::from("a.out"));
619
-    link_multi(&object_files, &output)?;
1199
+    link_multi(&object_files, &output, opts)?;
6201200
 
6211201
     // Cleanup.
6221202
     let _ = fs::remove_dir_all(&tmp_dir);
@@ -780,6 +1360,7 @@ mod tests {
7801360
             opt_level: OptLevel::O0,
7811361
             extra_inputs: vec![],
7821362
             module_search_paths: vec![],
1363
+            ..Options::default()
7831364
         };
7841365
 
7851366
         compile(&opts).expect("O0 --emit-ir should support integer(16) staging");
@@ -805,6 +1386,7 @@ mod tests {
8051386
             opt_level: OptLevel::O0,
8061387
             extra_inputs: vec![],
8071388
             module_search_paths: vec![],
1389
+            ..Options::default()
8081390
         };
8091391
 
8101392
         let err = compile(&opts).expect_err("backend should reject integer(16) until i128 codegen lands");
@@ -832,6 +1414,7 @@ mod tests {
8321414
             opt_level: OptLevel::O0,
8331415
             extra_inputs: vec![],
8341416
             module_search_paths: vec![],
1417
+            ..Options::default()
8351418
         };
8361419
 
8371420
         compile(&opts).expect("simple integer(16) memory traffic should codegen at O0");
@@ -857,6 +1440,7 @@ mod tests {
8571440
             opt_level: OptLevel::O0,
8581441
             extra_inputs: vec![],
8591442
             module_search_paths: vec![],
1443
+            ..Options::default()
8601444
         };
8611445
 
8621446
         compile(&opts).expect("simple integer(16) add should codegen at O0");
@@ -882,6 +1466,7 @@ mod tests {
8821466
             opt_level: OptLevel::O0,
8831467
             extra_inputs: vec![],
8841468
             module_search_paths: vec![],
1469
+            ..Options::default()
8851470
         };
8861471
 
8871472
         compile(&opts).expect("internal integer(16) call should codegen at O0");
@@ -908,6 +1493,7 @@ mod tests {
9081493
             opt_level: OptLevel::O0,
9091494
             extra_inputs: vec![],
9101495
             module_search_paths: vec![],
1496
+            ..Options::default()
9111497
         };
9121498
 
9131499
         compile(&opts).expect("external integer(16) call should codegen at O0");
@@ -934,6 +1520,7 @@ mod tests {
9341520
             opt_level: OptLevel::O1,
9351521
             extra_inputs: vec![],
9361522
             module_search_paths: vec![],
1523
+            ..Options::default()
9371524
         };
9381525
 
9391526
         compile(&opts).expect("integer(16) multiply should codegen at O1 after const fold");
src/lib.rsmodified
@@ -15,3 +15,68 @@ pub mod preprocess;
1515
 pub mod runtime;
1616
 pub mod sema;
1717
 pub mod testing;
18
+
19
+/// CLI entry point shared by both the `armfortas` and `afs` binaries.
20
+/// Both binaries are built from the same source path; this function
21
+/// holds the actual logic so the bin files are one-liners and Cargo
22
+/// stops warning about a duplicated build target.
23
+///
24
+/// Exit codes (sprint 32):
25
+///   0 success, 1 compile error, 2 link error, 3 I/O error, 4 ICE.
26
+pub fn cli_entry() -> ! {
27
+    use std::env;
28
+    use std::process;
29
+    const EXIT_COMPILE: i32 = 1;
30
+    const EXIT_IO: i32 = 3;
31
+
32
+    let args: Vec<String> = env::args().skip(1).collect();
33
+    if args.is_empty() {
34
+        eprintln!("{}", driver::HELP_TEXT);
35
+        process::exit(EXIT_COMPILE);
36
+    }
37
+
38
+    let parsed = match driver::parse_cli(&args) {
39
+        Ok(p) => p,
40
+        Err(e) => {
41
+            eprintln!("armfortas: {}", e);
42
+            process::exit(EXIT_COMPILE);
43
+        }
44
+    };
45
+
46
+    match parsed {
47
+        driver::ParsedCli::Info(driver::InfoAction::Help) => {
48
+            print!("{}", driver::HELP_TEXT);
49
+            process::exit(0);
50
+        }
51
+        driver::ParsedCli::Info(driver::InfoAction::Version) => {
52
+            println!("{}", driver::version_string());
53
+            process::exit(0);
54
+        }
55
+        driver::ParsedCli::Info(driver::InfoAction::DumpVersion) => {
56
+            println!("{}", driver::dump_version_string());
57
+            process::exit(0);
58
+        }
59
+        driver::ParsedCli::Compile(opts) => {
60
+            let result = if opts.extra_inputs.is_empty() {
61
+                driver::compile(&opts)
62
+            } else {
63
+                driver::compile_multi(&opts)
64
+            };
65
+            if let Err(e) = result {
66
+                eprintln!("armfortas: {}", e);
67
+                // Heuristic categorisation; sprint 32 #507 tracks
68
+                // the proper structured error type.
69
+                let exit_code = if e.contains("cannot read")
70
+                    || e.contains("cannot write")
71
+                    || e.contains("No such file")
72
+                {
73
+                    EXIT_IO
74
+                } else {
75
+                    EXIT_COMPILE
76
+                };
77
+                process::exit(exit_code);
78
+            }
79
+            process::exit(0);
80
+        }
81
+    }
82
+}
src/main.rsmodified
@@ -1,41 +1,3 @@
1
-use std::env;
2
-use std::process;
3
-
4
-use armfortas::driver;
5
-
61
 fn main() {
7
-    let args: Vec<String> = env::args().skip(1).collect();
8
-    if args.is_empty() {
9
-        eprintln!("usage: armfortas [options] <file.f90>");
10
-        eprintln!("       afs [options] <file.f90>");
11
-        eprintln!();
12
-        eprintln!("options:");
13
-        eprintln!("  -o <file>    output file");
14
-        eprintln!("  -S           emit assembly");
15
-        eprintln!("  -c           compile to object file");
16
-        eprintln!("  -E           preprocess only");
17
-        eprintln!("  --emit-ir    emit IR");
18
-        eprintln!("  -O0..-O3     optimization level");
19
-        eprintln!("  -Os          optimize for code size");
20
-        eprintln!("  -Ofast       maximum optimization");
21
-        process::exit(1);
22
-    }
23
-
24
-    let opts = match driver::Options::from_args(&args) {
25
-        Ok(o) => o,
26
-        Err(e) => {
27
-            eprintln!("armfortas: {}", e);
28
-            process::exit(1);
29
-        }
30
-    };
31
-
32
-    let result = if opts.extra_inputs.is_empty() {
33
-        driver::compile(&opts)
34
-    } else {
35
-        driver::compile_multi(&opts)
36
-    };
37
-    if let Err(e) = result {
38
-        eprintln!("armfortas: {}", e);
39
-        process::exit(1);
40
-    }
2
+    armfortas::cli_entry();
413
 }
tests/cli_driver.rsadded
@@ -0,0 +1,354 @@
1
+//! Sprint 32 CLI driver tests.
2
+//!
3
+//! Each test exercises one user-visible behaviour of the `armfortas`
4
+//! / `afs` driver via subprocess invocation.  Subprocess use is
5
+//! deliberate — we want to catch wrong-exit-code, wrong-stdout-vs-
6
+//! stderr-routing, and missing-symbol-from-bin issues that an
7
+//! in-process API call wouldn't see.
8
+
9
+use std::path::PathBuf;
10
+use std::process::Command;
11
+
12
+fn compiler(name: &str) -> PathBuf {
13
+    let candidate = PathBuf::from("target/release").join(name);
14
+    if candidate.exists() {
15
+        return candidate;
16
+    }
17
+    let candidate = PathBuf::from("target/debug").join(name);
18
+    assert!(
19
+        candidate.exists(),
20
+        "compiler binary '{}' not built — run `cargo build --bins` first",
21
+        name
22
+    );
23
+    candidate
24
+}
25
+
26
+fn unique_path(stem: &str, ext: &str) -> PathBuf {
27
+    let pid = std::process::id();
28
+    let nanos = std::time::SystemTime::now()
29
+        .duration_since(std::time::UNIX_EPOCH)
30
+        .unwrap()
31
+        .as_nanos();
32
+    std::env::temp_dir().join(format!("afs_cli_{}_{}_{}.{}", stem, pid, nanos, ext))
33
+}
34
+
35
+fn write_program(text: &str, suffix: &str) -> PathBuf {
36
+    let path = unique_path("src", suffix);
37
+    std::fs::write(&path, text).expect("cannot write CLI test source");
38
+    path
39
+}
40
+
41
+#[test]
42
+fn version_flag_prints_version_string_to_stdout() {
43
+    let out = Command::new(compiler("armfortas"))
44
+        .arg("--version")
45
+        .output()
46
+        .expect("failed to spawn armfortas");
47
+    assert!(out.status.success(), "exit code: {:?}", out.status);
48
+    let stdout = String::from_utf8_lossy(&out.stdout);
49
+    assert!(
50
+        stdout.contains("armfortas") && stdout.contains("0.1.0"),
51
+        "unexpected --version output: {}",
52
+        stdout
53
+    );
54
+    // The version string belongs on stdout (not stderr) per
55
+    // gfortran/clang convention; users shell-pipe it.
56
+    assert!(out.stderr.is_empty(), "stderr should be empty: {:?}", String::from_utf8_lossy(&out.stderr));
57
+}
58
+
59
+#[test]
60
+fn help_flag_shows_usage_and_exits_zero() {
61
+    let out = Command::new(compiler("armfortas"))
62
+        .arg("--help")
63
+        .output()
64
+        .expect("failed to spawn armfortas");
65
+    assert!(out.status.success(), "--help should succeed");
66
+    let stdout = String::from_utf8_lossy(&out.stdout);
67
+    assert!(stdout.contains("USAGE"), "help missing USAGE line");
68
+    assert!(stdout.contains("--std="), "help missing --std= entry");
69
+}
70
+
71
+#[test]
72
+fn dumpversion_prints_just_the_version_number() {
73
+    let out = Command::new(compiler("armfortas"))
74
+        .arg("-dumpversion")
75
+        .output()
76
+        .expect("failed to spawn armfortas");
77
+    assert!(out.status.success());
78
+    let stdout = String::from_utf8_lossy(&out.stdout);
79
+    assert_eq!(stdout.trim(), "0.1.0");
80
+}
81
+
82
+#[test]
83
+fn afs_alias_runs_the_same_compiler() {
84
+    let out = Command::new(compiler("afs"))
85
+        .arg("--version")
86
+        .output()
87
+        .expect("failed to spawn afs alias");
88
+    assert!(out.status.success());
89
+    let stdout = String::from_utf8_lossy(&out.stdout);
90
+    // Both binaries are built from the same source so the version
91
+    // string is identical — that's the contract.
92
+    assert!(stdout.contains("armfortas"));
93
+}
94
+
95
+#[test]
96
+fn no_args_prints_help_to_stderr_and_exits_nonzero() {
97
+    let out = Command::new(compiler("armfortas"))
98
+        .output()
99
+        .expect("failed to spawn armfortas");
100
+    assert!(!out.status.success(), "no-arg invocation should fail");
101
+    let stderr = String::from_utf8_lossy(&out.stderr);
102
+    assert!(
103
+        stderr.contains("USAGE"),
104
+        "no-arg invocation should print help to stderr: {}",
105
+        stderr
106
+    );
107
+}
108
+
109
+#[test]
110
+fn dash_c_produces_object_file_only() {
111
+    let src = write_program(
112
+        "module foo\n  integer :: x = 1\nend module\n",
113
+        "f90",
114
+    );
115
+    let out = unique_path("obj", "o");
116
+    let result = Command::new(compiler("armfortas"))
117
+        .args([
118
+            "-c",
119
+            src.to_str().unwrap(),
120
+            "-o",
121
+            out.to_str().unwrap(),
122
+        ])
123
+        .output()
124
+        .expect("compile failed to spawn");
125
+    assert!(
126
+        result.status.success(),
127
+        "-c compile failed: {}",
128
+        String::from_utf8_lossy(&result.stderr)
129
+    );
130
+    assert!(out.exists(), "-c should produce an object file");
131
+    let _ = std::fs::remove_file(&out);
132
+    let _ = std::fs::remove_file(&src);
133
+}
134
+
135
+#[test]
136
+fn dash_capital_s_produces_assembly_text() {
137
+    let src = write_program(
138
+        "program p\n  print *, 1\nend program\n",
139
+        "f90",
140
+    );
141
+    let out = unique_path("asm", "s");
142
+    let result = Command::new(compiler("armfortas"))
143
+        .args([
144
+            "-S",
145
+            src.to_str().unwrap(),
146
+            "-o",
147
+            out.to_str().unwrap(),
148
+        ])
149
+        .output()
150
+        .expect("spawn failed");
151
+    assert!(
152
+        result.status.success(),
153
+        "-S compile failed: {}",
154
+        String::from_utf8_lossy(&result.stderr)
155
+    );
156
+    let asm = std::fs::read_to_string(&out).expect("missing asm output");
157
+    assert!(asm.contains("__TEXT"), ".s output should contain section directive");
158
+    let _ = std::fs::remove_file(&out);
159
+    let _ = std::fs::remove_file(&src);
160
+}
161
+
162
+#[test]
163
+fn dash_capital_e_preprocesses_only() {
164
+    let src = write_program(
165
+        "#define X 99\nprogram p\n  print *, X\nend program\n",
166
+        "F90",
167
+    );
168
+    let out = unique_path("pp", "f90");
169
+    let result = Command::new(compiler("armfortas"))
170
+        .args([
171
+            "-E",
172
+            src.to_str().unwrap(),
173
+            "-o",
174
+            out.to_str().unwrap(),
175
+        ])
176
+        .output()
177
+        .expect("spawn failed");
178
+    assert!(
179
+        result.status.success(),
180
+        "-E preprocess failed: {}",
181
+        String::from_utf8_lossy(&result.stderr)
182
+    );
183
+    let pp = std::fs::read_to_string(&out).expect("missing preprocessed output");
184
+    assert!(
185
+        pp.contains(", 99"),
186
+        "preprocessed text should expand the macro: {}",
187
+        pp
188
+    );
189
+    let _ = std::fs::remove_file(&out);
190
+    let _ = std::fs::remove_file(&src);
191
+}
192
+
193
+#[test]
194
+fn std_f95_rejects_f2008_error_stop() {
195
+    let src = write_program(
196
+        "program p\n  error stop 'oops'\nend program\n",
197
+        "f90",
198
+    );
199
+    let out = unique_path("f95", "bin");
200
+    let result = Command::new(compiler("armfortas"))
201
+        .args([
202
+            "--std=f95",
203
+            src.to_str().unwrap(),
204
+            "-o",
205
+            out.to_str().unwrap(),
206
+        ])
207
+        .output()
208
+        .expect("spawn failed");
209
+    assert!(!result.status.success(), "--std=f95 should reject ERROR STOP");
210
+    let stderr = String::from_utf8_lossy(&result.stderr);
211
+    assert!(
212
+        stderr.contains("ERROR STOP") && stderr.contains("F2008"),
213
+        "expected ERROR STOP / F2008 error: {}",
214
+        stderr
215
+    );
216
+    let _ = std::fs::remove_file(&src);
217
+}
218
+
219
+#[test]
220
+fn response_file_supplies_arguments() {
221
+    let src = write_program(
222
+        "program p\n  print *, 7\nend program\n",
223
+        "f90",
224
+    );
225
+    let out = unique_path("resp", "bin");
226
+    let resp = unique_path("flags", "txt");
227
+    std::fs::write(
228
+        &resp,
229
+        format!(
230
+            "-O1\n-o\n{}\n{}\n",
231
+            out.display(),
232
+            src.display()
233
+        ),
234
+    )
235
+    .unwrap();
236
+    let result = Command::new(compiler("armfortas"))
237
+        .arg(format!("@{}", resp.display()))
238
+        .output()
239
+        .expect("spawn failed");
240
+    assert!(
241
+        result.status.success(),
242
+        "@response-file compile failed: {}",
243
+        String::from_utf8_lossy(&result.stderr)
244
+    );
245
+    assert!(out.exists(), "binary should exist after @file compile");
246
+    let _ = std::fs::remove_file(&out);
247
+    let _ = std::fs::remove_file(&src);
248
+    let _ = std::fs::remove_file(&resp);
249
+}
250
+
251
+#[test]
252
+fn dash_j_writes_amod_to_chosen_directory() {
253
+    let src = write_program(
254
+        "module dashj_mod\n  integer :: y = 5\nend module\n",
255
+        "f90",
256
+    );
257
+    let out = unique_path("dashjobj", "o");
258
+    let amod_dir = std::env::temp_dir().join(format!(
259
+        "afs_cli_amod_{}_{}",
260
+        std::process::id(),
261
+        std::time::SystemTime::now()
262
+            .duration_since(std::time::UNIX_EPOCH)
263
+            .unwrap()
264
+            .as_nanos(),
265
+    ));
266
+    std::fs::create_dir_all(&amod_dir).unwrap();
267
+    let result = Command::new(compiler("armfortas"))
268
+        .args([
269
+            "-c",
270
+            "-J",
271
+            amod_dir.to_str().unwrap(),
272
+            src.to_str().unwrap(),
273
+            "-o",
274
+            out.to_str().unwrap(),
275
+        ])
276
+        .output()
277
+        .expect("spawn failed");
278
+    assert!(
279
+        result.status.success(),
280
+        "-J compile failed: {}",
281
+        String::from_utf8_lossy(&result.stderr)
282
+    );
283
+    let amod = amod_dir.join("dashj_mod.amod");
284
+    assert!(amod.exists(), "-J should place .amod in the requested dir");
285
+    let _ = std::fs::remove_file(&out);
286
+    let _ = std::fs::remove_file(&src);
287
+    let _ = std::fs::remove_dir_all(&amod_dir);
288
+}
289
+
290
+#[test]
291
+fn verbose_flag_streams_phase_lines_to_stderr() {
292
+    let src = write_program(
293
+        "program p\n  print *, 1\nend program\n",
294
+        "f90",
295
+    );
296
+    let out = unique_path("verbose", "bin");
297
+    let result = Command::new(compiler("armfortas"))
298
+        .args([
299
+            "-v",
300
+            src.to_str().unwrap(),
301
+            "-o",
302
+            out.to_str().unwrap(),
303
+        ])
304
+        .output()
305
+        .expect("spawn failed");
306
+    assert!(result.status.success());
307
+    let stderr = String::from_utf8_lossy(&result.stderr);
308
+    assert!(stderr.contains("preprocessing:"), "verbose missing preprocessing line: {}", stderr);
309
+    assert!(stderr.contains("codegen:"), "verbose missing codegen line: {}", stderr);
310
+    let _ = std::fs::remove_file(&out);
311
+    let _ = std::fs::remove_file(&src);
312
+}
313
+
314
+#[test]
315
+fn time_report_prints_phase_table() {
316
+    let src = write_program(
317
+        "program p\n  print *, 1\nend program\n",
318
+        "f90",
319
+    );
320
+    let out = unique_path("timer", "bin");
321
+    let result = Command::new(compiler("armfortas"))
322
+        .args([
323
+            "--time-report",
324
+            src.to_str().unwrap(),
325
+            "-o",
326
+            out.to_str().unwrap(),
327
+        ])
328
+        .output()
329
+        .expect("spawn failed");
330
+    assert!(result.status.success());
331
+    let stderr = String::from_utf8_lossy(&result.stderr);
332
+    assert!(stderr.contains("Phase"), "missing time-report header: {}", stderr);
333
+    assert!(stderr.contains("Total"), "missing time-report total: {}", stderr);
334
+    let _ = std::fs::remove_file(&out);
335
+    let _ = std::fs::remove_file(&src);
336
+}
337
+
338
+#[test]
339
+fn missing_input_file_reports_io_error() {
340
+    let result = Command::new(compiler("armfortas"))
341
+        .args(["/nonexistent/path/source.f90"])
342
+        .output()
343
+        .expect("spawn failed");
344
+    assert!(!result.status.success(), "missing input should fail");
345
+    // Per sprint 32 #6 exit-code spec: I/O errors (cannot read input)
346
+    // map to exit code 3.  The driver categorises by error message
347
+    // text today; a structured error type is sprint 32 #507.
348
+    assert_eq!(
349
+        result.status.code(),
350
+        Some(3),
351
+        "missing input should map to exit code 3 (I/O error), got: {:?}",
352
+        result.status
353
+    );
354
+}
tests/i128_cross_object.rsmodified
@@ -32,13 +32,9 @@ fn compile_fortran_object(source: &Path, output: &Path, opt_level: OptLevel) {
3232
     let opts = Options {
3333
         input: source.to_path_buf(),
3434
         output: Some(output.to_path_buf()),
35
-        emit_asm: false,
3635
         emit_obj: true,
37
-        emit_ir: false,
38
-        preprocess_only: false,
3936
         opt_level,
40
-        extra_inputs: vec![],
41
-        module_search_paths: vec![],
37
+        ..Options::default()
4238
     };
4339
     compile(&opts).unwrap_or_else(|e| {
4440
         panic!(