vox-lang 0.3.5

A systems level compiler for Vox (sentence based code)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
mod lexer;
mod parser;
mod analyzer;
mod codegen;
mod elf;
mod errors;
mod lib_file;
#[cfg(test)]
mod compile_fail_tests;

use std::collections::HashSet;
use std::env;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;

use lexer::Lexer;
use parser::Parser;
use parser::ast::{Program, Statement};
use analyzer::Analyzer;
use codegen::{CodeGenerator, format_lib_name, mangle_library_symbol, render_lib_file};

/// Resolve the core-path environment override purely from its two inputs, so
/// the precedence rule can be unit-tested without touching process-global env.
///
/// Returns the chosen path (if any env var was set to a non-empty value) and a
/// flag that is true when only the deprecated `EC_CORE_PATH` was the source —
/// the caller uses that to emit a one-line deprecation note. `VOX_CORE_PATH`
/// wins when both are set; an empty value is treated as unset.
fn resolve_core_env_override(
    vox_core_path: Option<&str>,
    ec_core_path: Option<&str>,
) -> (Option<PathBuf>, bool) {
    let vox = vox_core_path.filter(|s| !s.is_empty());
    let ec = ec_core_path.filter(|s| !s.is_empty());
    let deprecated_only = vox.is_none() && ec.is_some();
    (vox.or(ec).map(PathBuf::from), deprecated_only)
}

/// Resolve which XDG config file to read, purely from the two candidates, so
/// the precedence rule is unit-testable without touching the filesystem. Each
/// argument is `Some(path)` when that file exists and `None` when it does not —
/// the caller computes that with `exists()`.
///
/// `~/.config/vox/config` is the documented path; `~/.config/ec/config` is the
/// pre-rename fallback (the `ec` → `vox` rename left existing installs with the
/// old file, and a hard rename would silently drop their config). When both
/// exist, `vox` wins. The returned flag is true when only the deprecated file
/// was the source, so the caller can emit a one-line note — consistent with
/// `resolve_core_env_override`; these two read as one decision.
fn resolve_config_file_path(
    vox_config: Option<PathBuf>,
    ec_config: Option<PathBuf>,
) -> (Option<PathBuf>, bool) {
    let deprecated_only = vox_config.is_none() && ec_config.is_some();
    (vox_config.or(ec_config), deprecated_only)
}

/// Find the coreasm library directory using industry-standard resolution order:
/// 1. VOX_CORE_PATH environment variable (user override; the documented name),
///    with EC_CORE_PATH accepted as a deprecated alias (see below)
/// 2. XDG config file (~/.config/vox/config; ~/.config/ec/config is a
///    deprecated alias — see `get_config_lib_path`)
/// 3. System paths (/usr/local/share/vox, /usr/share/vox)
/// 4. Executable-relative paths (for portable installs)
/// 5. Current working directory fallback (for development)
///
/// The `ec` → `vox` rename left `EC_CORE_PATH` in shell profiles and CI
/// pipelines; a hard rename would break those silently, surfacing as an
/// inscrutable "coreasm not found" far from the cause. So `VOX_CORE_PATH` is
/// the documented name and `EC_CORE_PATH` keeps working as a fallback alias.
/// When both are set, `VOX_CORE_PATH` wins. When only the deprecated name is
/// set, a one-line note points the author at the new name so they can migrate
/// without being nagged mid-build (one line, once, at compile start).
fn find_coreasm_path() -> Option<PathBuf> {
    // 1. Environment variable - highest priority. VOX_CORE_PATH is the
    //    documented name; EC_CORE_PATH is the pre-rename alias kept working.
    let (env_path, deprecate) = resolve_core_env_override(
        env::var("VOX_CORE_PATH").ok().as_deref(),
        env::var("EC_CORE_PATH").ok().as_deref(),
    );
    if deprecate {
        eprintln!(
            "note: EC_CORE_PATH is deprecated; set VOX_CORE_PATH instead \
             (still read as a fallback for now)."
        );
    }
    if let Some(core_path) = env_path {
        let path = PathBuf::from(&core_path);
        if path.exists() {
            return Some(path);
        }
        // Also check for coreasm subdirectory
        let coreasm = path.join("coreasm");
        if coreasm.exists() {
            return Some(coreasm);
        }
    }

    // 2. XDG config file (~/.config/vox/config)
    if let Some(config_path) = get_config_lib_path() {
        if config_path.exists() {
            return Some(config_path);
        }
    }
    
    // 3. System paths (Unix standard locations)
    let system_paths = [
        "/usr/local/share/vox/coreasm",
        "/usr/share/vox/coreasm",
        "/opt/vox/coreasm",
    ];
    for path in &system_paths {
        let p = PathBuf::from(path);
        if p.exists() {
            return Some(p);
        }
    }
    
    // 4. Executable-relative (walk up from exe to find coreasm/)
    if let Ok(exe) = env::current_exe() {
        let mut dir = exe.parent();
        while let Some(d) = dir {
            let candidate = d.join("coreasm");
            if candidate.exists() {
                return Some(candidate);
            }
            dir = d.parent();
        }
    }
    
    // 5. Current working directory fallback
    let cwd_coreasm = PathBuf::from("coreasm");
    if cwd_coreasm.exists() {
        return Some(cwd_coreasm);
    }
    
    None
}

/// Read lib_path from the XDG config file.
///
/// `~/.config/vox/config` is the documented path; `~/.config/ec/config` is the
/// pre-rename path kept as a deprecated fallback (the `ec` → `vox` rename left
/// existing installs with the old file, and a hard rename would silently drop
/// their config — surfacing as an inscrutable "coreasm not found"). When both
/// exist, `vox` wins. When only the deprecated file is found, a one-line note
/// points the author at the new path. Consistent with `resolve_core_env_override`
/// — these two read as one decision.
fn get_config_lib_path() -> Option<PathBuf> {
    // XDG Base Directory: $XDG_CONFIG_HOME, else ~/.config
    let config_dir = env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            env::var("HOME")
                .map(|h| PathBuf::from(h).join(".config"))
                .unwrap_or_default()
        });

    // ~/.config/vox/config is the documented path; ~/.config/ec/config is the
    // pre-rename fallback. vox wins when both exist.
    let vox_cfg = config_dir.join("vox").join("config");
    let ec_cfg = config_dir.join("ec").join("config");
    let (config_file, deprecate) = resolve_config_file_path(
        vox_cfg.exists().then_some(vox_cfg),
        ec_cfg.exists().then_some(ec_cfg),
    );
    if deprecate {
        eprintln!(
            "note: ~/.config/ec/config is deprecated; use ~/.config/vox/config \
             instead (still read as a fallback for now)."
        );
    }
    let Some(config_file) = config_file else {
        return None;
    };

    if let Ok(file) = fs::File::open(&config_file) {
        let reader = BufReader::new(file);
        for line in reader.lines().map_while(Result::ok) {
            let line = line.trim();
            if line.starts_with('#') || line.is_empty() {
                continue;
            }
            if let Some(value) = line.strip_prefix("core_path=") {
                let path = PathBuf::from(value.trim());
                let coreasm = if path.ends_with("coreasm") {
                    path
                } else {
                    path.join("coreasm")
                };
                return Some(coreasm);
            }
        }
    }
    
    None
}

/// Track included files to prevent circular dependencies
fn process_includes(
    program: &mut parser::ast::Program,
    base_path: &Path,
    included: &mut HashSet<PathBuf>,
    verbose: bool,
) {
    let mut new_statements = Vec::new();
    
    for stmt in program.statements.drain(..) {
        if let Statement::See { ref path, .. } = stmt {
            // Resolve path relative to current file
            let include_path = if path.starts_with("./") || path.starts_with("../") {
                base_path.parent().unwrap_or(Path::new(".")).join(path)
            } else if path.starts_with('/') {
                PathBuf::from(path)
            } else {
                // Check system library path first
                let system_path = PathBuf::from("/usr/share/vox/lib").join(path);
                if system_path.exists() {
                    system_path
                } else {
                    base_path.parent().unwrap_or(Path::new(".")).join(path)
                }
            };
            
            let canonical = include_path.canonicalize().unwrap_or(include_path.clone());
            
            // Skip if already included (prevents circular dependencies)
            if included.contains(&canonical) {
                if verbose {
                    println!("Skipping already included: {}", path);
                }
                new_statements.push(stmt);
                continue;
            }
            
            // Only inline Vox source. A `see` of a source file splices its
            // statements in here, before compilation; a `.lib` import is kept
            // as a marker and resolved later by resolve_program_imports. (A
            // `.so` `see` never reaches here — stage A5 made it a parse error
            // directing the user to the `.lib`.)
            if path.ends_with(".vox") {
                if let Ok(source) = fs::read_to_string(&include_path) {
                    if verbose {
                        println!("Including: {}", include_path.display());
                    }
                    
                    included.insert(canonical);
                    
                    let mut lexer = Lexer::new(&source);
                    let tokens = lexer.tokenize();
                    let mut parser = Parser::new(tokens);
                    
                    if let Ok(mut included_program) = parser.parse() {
                        // Recursively process includes in the included file
                        process_includes(&mut included_program, &include_path, included, verbose);
                        
                        // Add included statements (replaces the see statement)
                        new_statements.extend(included_program.statements);
                    } else if verbose {
                        eprintln!("Warning: Failed to parse {}", include_path.display());
                    }
                } else if verbose {
                    eprintln!("Warning: Could not read file: {}", include_path.display());
                }
                // Don't keep the see statement for source files - content is inlined
            } else {
                // Keep the see statement — a `.lib` import, resolved later
                // by resolve_program_imports (which keys on the `.lib` suffix).
                new_statements.push(stmt);
            }
        } else {
            new_statements.push(stmt);
        }
    }
    
    program.statements = new_statements;
}

fn show_version() {
    eprintln!("vox v{} By Josjuar Lister 2026", env!("CARGO_PKG_VERSION"));
}

fn show_help() {
    eprintln!("Usage: vox <source.vox> [options]");
    eprintln!();
    eprintln!("Options:");
    eprintln!("  --emit-asm       Output assembly only (don't assemble/link)");
    eprintln!("  --keep-asm       Keep assembly file after linking");
    eprintln!("  --run            Compile and run the program");
    eprintln!("  --shared         Build a shared library (.so) instead of executable");
    eprintln!("  --link <libs>    Link against shared libraries (comma-separated)");
    eprintln!("  --lib-path <paths>  Additional library search paths (comma-separated)");
    eprintln!("  --target <arch>   Target architecture (default: x86_64)");
    eprintln!("  -o <file>        Output file name");
    eprintln!("  -v | --verbose   Verbose output");
    eprintln!("  -h | --help           Show help");
    eprintln!("  -V | --version        Show version");
    eprintln!();
    show_version();
}

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() < 2 {
        show_help();
        std::process::exit(1);
    }

    // Check for help/version flags before treating first arg as source file
    if args.len() == 2 {
        match args[1].as_str() {
            "--help" | "-h" => {
                show_help();
                std::process::exit(0);
            }
            "--version" | "-V" => {
                show_version();
                std::process::exit(0);
            }
            _ => {}
        }
    }

    let mut source_paths: Vec<String> = Vec::new();
    let mut emit_asm_only = false;
    let mut keep_asm = false;
    let mut run_after = false;
    let mut build_shared = false;
    let mut output_name = None;
    let mut verbose = false;
    let mut link_libs: Vec<String> = Vec::new();
    let mut lib_paths: Vec<String> = Vec::new();
    let mut target_arch = option_env!("TARGET_ARCH").unwrap_or("x86_64").to_string();

    // Any positional argument that is not a recognised flag (and not the value
    // consumed by -o/--link/--lib-path/--target) is a source file. This accepts
    // one or several: `vox a.vox --shared -o lib.so` (single, as today) and
    // `vox a.vox b.vox --shared -o lib.so` (plan 230 stage A2: several libraries
    // linked into one .so in a single link step).
    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--help" | "-h" => {
                show_help();
                std::process::exit(0);
            }
            "--version" | "-V" => {
                show_version();
                std::process::exit(0);
            }
            "--emit-asm" => emit_asm_only = true,
            "--keep-asm" => keep_asm = true,
            "--run" => run_after = true,
            "--shared" => build_shared = true,
            "--verbose" | "-v" => verbose = true,
            "-o" => {
                i += 1;
                if i < args.len() {
                    output_name = Some(args[i].clone());
                }
            }
            "--link" => {
                i += 1;
                if i < args.len() {
                    link_libs.extend(args[i].split(',').map(|s| s.trim().to_string()));
                }
            }
            "--lib-path" => {
                i += 1;
                if i < args.len() {
                    lib_paths.extend(args[i].split(',').map(|s| s.trim().to_string()));
                }
            }
            "--target" => {
                i += 1;
                if i < args.len() {
                    target_arch = args[i].clone();
                }
            }
            _ => {
                // A positional argument: a source file. The old loop started at
                // index 2 and silently dropped any extra positional args (the
                // `_ => {}` arm), so `vox a.vox b.vox --shared` quietly lost
                // `b.vox`. Collecting them here is what makes multi-input work.
                source_paths.push(args[i].clone());
            }
        }
        i += 1;
    }

    if source_paths.is_empty() {
        show_help();
        std::process::exit(1);
    }

    // Multi-input is --shared only. Two `main`-equivalents in one executable
    // has no meaning, and silently picking one would be the worst outcome; a
    // shared build is the one place several sources combine into one output.
    if source_paths.len() > 1 && !build_shared {
        eprintln!(
            "Multiple source files ({}) are only valid with --shared, which links \
             them into one library in a single link step. An executable build takes \
             a single source — pass --shared, or compile each source separately.",
            source_paths.join(", ")
        );
        std::process::exit(1);
    }

    let first_source = source_paths[0].clone();

    // Parse each source independently, then concatenate the statements into
    // ONE compilation unit so the coreasm runtime is emitted once and shared by
    // every library in the .so (plan 230 stage A2's design: one resource table,
    // one .fini_array, one idempotent _cleanup_all — never a runtime per input).
    // Each input is lexed/parsed on its own so a parse error names its own file
    // and a `see` of a .vox resolves relative to that file's directory.
    let mut combined_statements: Vec<Statement> = Vec::new();
    // (mangled identity prefix, raw library, raw version, filename) per input,
    // to reject duplicate identities. The mangler folds every character
    // outside [A-Za-z0-9_] to '_', so two inputs whose `<library, version>`
    // pairs *look* distinct (`a-b`/`1.0` vs `a_b`/`1.0`) become the same symbol
    // prefix and would emit colliding labels in the .so. The check therefore
    // compares what the identities become — `mangle_library_symbol(lib, ver, "")`
    // — not what was written, and the diagnostic names both raw identities so
    // the author can see why `a-b` and `a_b` are the same to the linker.
    let mut identities: Vec<(String, String, String, String)> = Vec::new();
    // Stage A4: imports resolved from `see "<lib>" version "<ver>" from
    // "...lib".` across every input — verified signatures for the analyzer
    // and codegen, and the .so paths for the link line.
    let mut all_imported_functions: Vec<lib_file::ImportedFunction> = Vec::new();
    let mut imported_sos: Vec<PathBuf> = Vec::new();
    for source_path in &source_paths {
        let source = match fs::read_to_string(source_path) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("Error reading file '{}': {}", source_path, e);
                std::process::exit(1);
            }
        };

        if verbose {
            println!("Compiling {}...", source_path);
        }

        let mut lexer = Lexer::new(&source);
        let tokens = lexer.tokenize();

        let mut parser = Parser::new(tokens).with_source(source_path, &source);
        let mut program = match parser.parse() {
            Ok(p) => p,
            Err(e) => {
                eprintln!("{}", e);
                std::process::exit(1);
            }
        };

        // Process includes (see statements) with circular dependency tracking,
        // relative to this input's directory.
        let source_path_buf = PathBuf::from(source_path);
        let mut included_files = HashSet::new();
        included_files.insert(
            source_path_buf
                .canonicalize()
                .unwrap_or(source_path_buf.clone()),
        );
        process_includes(&mut program, &source_path_buf, &mut included_files, verbose);

        // Stage A4: resolve this input's `see ... from "*.lib"` imports NOW,
        // while this input's directory is in hand (the .lib resolves relative
        // to the source, then --lib-path; Location relative to the .lib,
        // then --lib-path). Resolution parses the .lib, selects the
        // <lib,version> block, verifies every promised symbol against the
        // .so's .dynsym, and yields the signatures calls type-check against.
        // Each failure mode has its own message naming the file and what was
        // expected; they are fatal — the analyzer can only type-check calls
        // against signatures it actually holds.
        let source_dir = source_path_buf
            .parent()
            .unwrap_or(Path::new("."))
            .to_path_buf();
        match lib_file::resolve_program_imports(&program, &source_dir, &lib_paths) {
            Ok(imports) => {
                for import in imports {
                    all_imported_functions.extend(import.functions);
                    if !imported_sos.contains(&import.so_path) {
                        imported_sos.push(import.so_path);
                    }
                }
            }
            Err(message) => {
                eprintln!("Error: {}", message);
                std::process::exit(1);
            }
        }

        // Multi-input --shared: every input must carry its own `Library`
        // declaration (its symbols are mangled by it), and no two inputs may
        // claim the same <library, version> — the second would silently
        // overwrite the first's signatures, the wrong-code bug A1 found. This
        // is a property of the inputs, not the program, so it is checked here
        // in the driver where both filenames are in hand (the analyzer sees one
        // concatenated unit and has no filenames).
        if build_shared && source_paths.len() > 1 {
            let identity = program.statements.iter().find_map(|s| {
                if let Statement::LibraryDecl { name, version } = s {
                    Some((name.clone(), version.clone()))
                } else {
                    None
                }
            });
            match identity {
                Some((lib, ver)) => {
                    // The symbol prefix every function in this library will
                    // share; two inputs that sanitise to the same prefix emit
                    // colliding labels in the .so, so this — not the raw
                    // strings — is what must be distinct.
                    let prefix = mangle_library_symbol(&lib, &ver, "");
                    if let Some((plib, pver, prev_file)) = identities
                        .iter()
                        .find_map(|(p, l, v, f)| {
                            if p == &prefix {
                                Some((l.clone(), v.clone(), f.clone()))
                            } else {
                                None
                            }
                        })
                    {
                        if plib == lib && pver == ver {
                            // Same raw identity (e.g. the same file passed
                            // twice): keep the existing diagnostic's shape.
                            eprintln!(
                                "Duplicate library identity: '{}' and '{}' both declare \
                                 Library {} version \"{}\". Two sources linked into one \
                                 .so must each name a distinct library and version, or the \
                                 second's signatures silently overwrite the first's. Rename \
                                 one.",
                                prev_file, source_path, format_lib_name(&lib), ver
                            );
                        } else {
                            // Different raw identities that sanitise to the
                            // same symbol prefix. Name both files and both raw
                            // identities plus the colliding prefix, so the
                            // author sees why their distinct-looking names are
                            // the same to the linker.
                            eprintln!(
                                "Duplicate library identity: '{}' declares Library {} \
                                 version \"{}\" and '{}' declares Library {} version \"{}\", \
                                 but both mangle to the symbol prefix '{}'. The mangler folds \
                                 every character outside [A-Za-z0-9_] to '_', so these are the \
                                 same to the linker and the second's signatures silently \
                                 overwrite the first's. Rename one so the library and version \
                                 stay distinct after mangling.",
                                prev_file, format_lib_name(&plib), pver, source_path, format_lib_name(&lib), ver, prefix
                            );
                        }
                        std::process::exit(1);
                    }
                    identities.push((prefix, lib, ver, source_path.clone()));
                }
                None => {
                    eprintln!(
                        "'{}' has no `Library` declaration. A source linked into a shared \
                         library alongside others must declare its identity — \
                         `Library name version \"x.y\".` — so its symbols are mangled \
                         apart from the other libraries' and a `.lib` can be written for it. \
                         Add one before the function definitions.",
                        source_path
                    );
                    std::process::exit(1);
                }
            }
        }

        combined_statements.extend(program.statements);
    }

    // One Program for the whole .so. `Program::new` defaults every uses_* flag
    // to false; the single analyze pass below sets them from the combined
    // statement list, so the runtime include block reflects what every library
    // in the .so actually needs (and is emitted once).
    let mut program = Program::new(combined_statements);

    // The analyzer locates errors by text search in one source file (plan 210
    // P3 — the Statement AST carries no span). For a multi-input build that is
    // the first file; a symbol in a later library may mislocate, but a
    // spanned AST is the separate work that fixes it. Single-input builds are
    // unchanged: the same source, the same content, as today.
    let first_source_content = fs::read_to_string(&first_source).unwrap_or_default();
    let mut analyzer = Analyzer::new()
        .with_source(&first_source, &first_source_content)
        .with_shared_mode(build_shared)
        .with_imports(all_imported_functions.clone());
    analyzer.analyze(&mut program);

    // Stage A4 warnings (a local definition shadowing an imported name) are
    // non-fatal but never silent: print them whether or not errors follow.
    for warning in &analyzer.warnings {
        eprintln!("warning: {}", warning);
    }

    if !analyzer.errors.is_empty() {
        for err in &analyzer.errors {
            eprintln!("{}", err);
        }
        std::process::exit(1);
    }

    let mut codegen = CodeGenerator::new();
    codegen.set_shared_lib_mode(build_shared);
    codegen.set_target_arch(&target_arch);
    codegen.set_imports(all_imported_functions);
    let assembly = codegen.generate(&program);
    
    let base_name = Path::new(&first_source)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("output");
    
    let asm_path = format!("{}.asm", base_name);
    let obj_path = format!("{}.o", base_name);
    let output_path = output_name.unwrap_or_else(|| {
        if build_shared {
            format!("lib{}.so", base_name)
        } else {
            base_name.to_string()
        }
    });
    
    if let Err(e) = fs::write(&asm_path, &assembly) {
        eprintln!("Error writing assembly: {}", e);
        std::process::exit(1);
    }
    if verbose {
        println!("Generated {}", asm_path);
    }
    
    if emit_asm_only {
        return;
    }

    // `<stem>.asm` is an intermediate. On the success path it is removed below
    // (unless --keep-asm); until F2 a failure path left it in the user's
    // directory — a temp file they never asked to see. Every failure exit
    // after this point calls `cleanup_asm_on_failure()` first. It honours the
    // same `keep_asm` flag as the success path (a user who passed --keep-asm
    // asked to keep the assembly, even from a failed build); --emit-asm has
    // already returned above, so its assembly is untouched. The `let _ =`
    // swallows a missing-file error from an earlier write failure that left no
    // file behind.
    let cleanup_asm_on_failure = || {
        if !keep_asm {
            let _ = fs::remove_file(&asm_path);
        }
    };

    // A `--shared` build writes `<stem>.lib` beside the `.so` as a declared
    // output — derived from the user's `-o`, exactly like the `.so` and the
    // `.asm`, and overwritten the same way on a rebuild. The earlier refusal
    // (plan 230 A3, borrowed from plan 210 P1's `.map` collision) made a
    // library buildable once and then never again without manual cleanup —
    // every edit-build loop, every warm-directory CI run, hit it. The `.map`
    // danger does not apply: that name was derived from the *source* and could
    // collide with an unrelated file; this name is derived from `-o`, which
    // the user chose. The `.so` and `.lib` are written as a pair or not at
    // all (see the write below): a rebuild must never leave a fresh `.so`
    // beside a stale `.lib` — the exact inconsistency the `.dynsym`
    // verification exists to catch. Skipped for non-shared builds (no .lib
    // is written) and for `--emit-asm` (returned above).
    let lib_path = if build_shared {
        Some(Path::new(&output_path).with_extension("lib"))
    } else {
        None
    };
    
    // Find coreasm library using standard resolution order
    // The ASM uses %include "coreasm/core.asm", so we need the parent directory
    let coreasm_include = match find_coreasm_path() {
        Some(path) => {
            // Get parent directory since ASM includes "coreasm/..." paths
            if let Some(parent) = path.parent() {
                format!("-I{}/", parent.display())
            } else {
                format!("-I{}/", path.display())
            }
        }
        None => {
            eprintln!("Warning: coreasm library not found. Set VOX_CORE_PATH (or the deprecated EC_CORE_PATH) or install to /usr/local/share/vox/");
            "-I./".to_string()
        }
    };
    
    if verbose {
        println!("Assembling...");
    }
    
    // For shared libraries, we need position-independent code
    let nasm_args = if build_shared {
        vec!["-f", "elf64", "-DPIC", &coreasm_include, "-o", &obj_path, &asm_path]
    } else {
        vec!["-f", "elf64", &coreasm_include, "-o", &obj_path, &asm_path]
    };
    
    let nasm_result = Command::new("nasm")
        .args(&nasm_args)
        .status();
    
    match nasm_result {
        Ok(status) if status.success() => {}
        Ok(_) => {
            eprintln!("NASM assembly failed");
            cleanup_asm_on_failure();
            std::process::exit(1);
        }
        Err(e) => {
            eprintln!("Failed to run NASM: {}", e);
            eprintln!("Make sure NASM is installed: sudo apt install nasm");
            cleanup_asm_on_failure();
            std::process::exit(1);
        }
    }
    
    if verbose {
        println!("Linking...");
    }
    
    // The version script is a pure implementation detail the user never asked
    // to see, so it must never touch their working directory — a `.map` next to
    // a source file is entirely plausible (linker scripts and source maps both
    // use that extension), and writing then deleting `<base_name>.map` there
    // would destroy a pre-existing file (plan 210 P1). Put it in the system temp
    // dir under a name unique to this process. The single `map_path` is the
    // only place this path exists, so the cleanup below cannot drift from the
    // write (plan 210 P7 — the two used to be recomputed independently).
    let map_path = if build_shared {
        Some(env::temp_dir().join(format!("vox-{}-{}.map", base_name, std::process::id())))
    } else {
        None
    };

    // Stage A4: every imported .so goes on the link line. It is named by
    // EXACT filename (`-l:<name>.so`, found through a `-L` on its directory)
    // rather than by raw path so the recorded DT_NEEDED stays slash-free:
    // with no SONAME, `ld` would otherwise bake the build-time path into the
    // binary, and the loader would key on that instead of the rpath. The
    // rpath (the .so's canonical directory) is where the loader then finds
    // it. All `-L` entries precede all `-l:` entries so a same-directory
    // pair never relies on argument order.
    let mut import_ld_args: Vec<String> = Vec::new();
    let mut import_rpaths: Vec<String> = Vec::new();
    {
        let mut so_dirs: Vec<String> = Vec::new();
        let mut so_names: Vec<String> = Vec::new();
        for so in &imported_sos {
            let dir = so
                .parent()
                .map(|d| d.to_path_buf())
                .unwrap_or_else(|| PathBuf::from("."));
            let dir = dir.canonicalize().unwrap_or(dir);
            let dir_s = dir.display().to_string();
            if !so_dirs.contains(&dir_s) {
                so_dirs.push(dir_s.clone());
                import_ld_args.push(format!("-L{}", dir_s));
                import_rpaths.push(dir_s);
            }
            if let Some(fname) = so.file_name().and_then(|f| f.to_str()) {
                so_names.push(format!("-l:{}", fname));
            }
        }
        import_ld_args.extend(so_names);
    }

    let ld_result = if build_shared {
        // An anonymous version script restricts the dynamic symbol table to
        // exactly the library's exported functions. coreasm declares ~54 of
        // its runtime symbols `global`; without this script every one would
        // leak into .dynsym, colliding on generic names like `_str_len` when
        // two Vox .so files are loaded together. The fix is link-time (zero
        // coreasm edits) for the same reason the %define mangling is: coreasm
        // is ported per architecture. The anonymous form (no version tag)
        // keeps `nm -D` reporting the plain symbol names.
        let map_path = map_path.as_ref().unwrap();
        let mut script = String::from("{ global:");
        for func in codegen.exported_functions() {
            script.push_str(&format!(" {};", func));
        }
        script.push_str(" local:*; };\n");
        if let Err(e) = fs::write(&map_path, &script) {
            eprintln!("Error writing version script: {}", e);
            cleanup_asm_on_failure();
            std::process::exit(1);
        }

        let mut all_args: Vec<String> = vec![
            "-shared".to_string(),
            format!("--version-script={}", map_path.display()),
            "-o".to_string(),
            output_path.clone(),
            obj_path.clone(),
        ];
        for p in lib_paths.iter().map(|p| format!("-L{}", p)) {
            all_args.push(p);
        }
        for l in link_libs.iter().map(|l| format!("-l{}", l)) {
            all_args.push(l);
        }
        // Stage A4: a library can itself `see` another library's .lib; the
        // imported .so becomes a DT_NEEDED of this one, found the same way.
        for a in &import_ld_args {
            all_args.push(a.clone());
        }
        for r in &import_rpaths {
            all_args.push("-rpath".to_string());
            all_args.push(r.clone());
        }

        let arg_refs: Vec<&str> = all_args.iter().map(|s| s.as_str()).collect();
        Command::new("ld")
            .args(&arg_refs)
            .status()
    } else {
        // Build executable
        let ld_args = vec!["-o", &output_path, &obj_path];

        // Add library search paths
        let lib_path_args: Vec<String> = lib_paths.iter()
            .map(|p| format!("-L{}", p))
            .collect();

        // Add linked libraries
        let link_args: Vec<String> = link_libs.iter()
            .map(|l| format!("-l{}", l))
            .collect();

        // A static executable has no PT_INTERP and no runtime dependencies, so
        // it execs directly. But an executable linked against a shared library
        // needs the dynamic loader to map the .so in at runtime, and an rpath so
        // the loader finds it. Add both ONLY when there are link libs or .lib
        // imports, so plain static builds are untouched — the default Vox
        // output stays a flat static binary with no loader dependency.
        let mut dynamic_args: Vec<String> = Vec::new();
        if !link_libs.is_empty() || !imported_sos.is_empty() {
            // FIXME(x86-64, M6): this loader path is hard-coded for x86-64.
            // `target_arch` is in scope here (it is threaded down from the
            // --arch flag / TARGET_ARCH at line ~249), so M6's port can derive
            // the path from it per architecture instead of fixing this string
            // by hand. Left as-is for now because the other architectures do
            // not exist yet and guessing their loader paths would be worse
            // than a grep-findable marker. See plan 210 P6.
            dynamic_args.push("-dynamic-linker".to_string());
            dynamic_args.push("/lib64/ld-linux-x86-64.so.2".to_string());
            for p in lib_paths.iter() {
                dynamic_args.push("-rpath".to_string());
                dynamic_args.push(p.clone());
            }
            for r in &import_rpaths {
                dynamic_args.push("-rpath".to_string());
                dynamic_args.push(r.clone());
            }
        }

        let mut all_args: Vec<&str> = ld_args;
        for a in &dynamic_args {
            all_args.push(a);
        }
        for p in &lib_path_args {
            all_args.push(p);
        }
        for l in &link_args {
            all_args.push(l);
        }
        for a in &import_ld_args {
            all_args.push(a);
        }

        Command::new("ld")
            .args(&all_args)
            .status()
    };
    
    // Remove the version script regardless of whether `ld` succeeded. The
    // cleanup used to live after the success check, so a failed link left
    // `<name>.map` in the user's working directory — a file they never asked
    // to see, named for a script they have no reason to know exists. Removing
    // it here covers both the success and the two failure exits below. The
    // temp path lives in `map_path`, the same value written above.
    if let Some(ref p) = map_path {
        let _ = fs::remove_file(p);
    }

    match ld_result {
        Ok(status) if status.success() => {}
        Ok(_) => {
            eprintln!("Linking failed");
            cleanup_asm_on_failure();
            std::process::exit(1);
        }
        Err(e) => {
            eprintln!("Failed to run ld: {}", e);
            cleanup_asm_on_failure();
            std::process::exit(1);
        }
    }

    let _ = fs::remove_file(&obj_path);

    // A3: emit the `.lib` interface file beside the `.so` — one `Library` block
    // per input, a `Location` relative to the `.lib`, and a `Table of Contents`
    // of every exported signature. Round-trip is the test: stage A4 parses what
    // we write here, so it must be re-readable and its ToC must match
    // `nm -D --defined-only` one-for-one. The `Location` is the `.so`'s basename
    // (relative, so moving the pair does not break it); absolute paths are
    // honoured on read but never generated.
    if let Some(ref lib_path) = lib_path {
        let so_filename = Path::new(&output_path)
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or(&output_path);
        let lib_text = render_lib_file(codegen.library_blocks(), so_filename);
        if let Err(e) = fs::write(lib_path, &lib_text) {
            // A declared output, written as a pair with the .so. If the .lib
            // cannot be written (permissions, a directory in the way), do not
            // leave the fresh .so beside a stale/no .lib — that is the
            // disagreeing pair the .dynsym verification exists to detect.
            // Remove the .so this build just produced and fail loudly.
            let _ = fs::remove_file(&output_path);
            eprintln!("Error writing .lib '{}': {}", lib_path.display(), e);
            std::process::exit(1);
        }
        if verbose {
            println!("Created library interface: {}", lib_path.display());
        }
    }

    if verbose {
        if build_shared {
            println!("Created shared library: {}", output_path);
        } else {
            println!("Created executable: {}", output_path);
        }
    }

    if keep_asm {
        if verbose {
            println!("Kept assembly file: {}", asm_path);
        }
    } else {
        if verbose {
            println!("Removed assembly file: {}", asm_path);
        }
        let _ = fs::remove_file(&asm_path);
    }
    
    if run_after {
        if build_shared {
            eprintln!("Cannot run a shared library directly");
            std::process::exit(1);
        }
        if verbose {
            println!("\nRunning {}...\n", output_path);
        }
        let run_result = Command::new(format!("./{}", output_path))
            .status();
        
        if let Ok(status) = run_result {
            std::process::exit(status.code().unwrap_or(0));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // D5: VOX_CORE_PATH is the documented name and resolves the core path.
    #[test]
    fn vox_core_path_resolves() {
        let (path, deprecate) = resolve_core_env_override(Some("/opt/vox"), None);
        assert_eq!(path, Some(PathBuf::from("/opt/vox")));
        assert!(!deprecate);
    }

    // D5: EC_CORE_PATH still works as a deprecated alias.
    #[test]
    fn ec_core_path_still_resolves() {
        let (path, deprecate) = resolve_core_env_override(None, Some("/opt/ec"));
        assert_eq!(path, Some(PathBuf::from("/opt/ec")));
        // Only the deprecated name was present: the caller should note it.
        assert!(deprecate);
    }

    // D5: when both are set, VOX_CORE_PATH takes precedence and no
    // deprecation note is warranted (the author already uses the new name).
    #[test]
    fn vox_core_path_takes_precedence_over_ec_core_path() {
        let (path, deprecate) = resolve_core_env_override(Some("/opt/vox"), Some("/opt/ec"));
        assert_eq!(path, Some(PathBuf::from("/opt/vox")));
        assert!(!deprecate);
    }

    // D5: with neither set, there is no override and nothing to note.
    #[test]
    fn neither_env_set_is_no_override() {
        let (path, deprecate) = resolve_core_env_override(None, None);
        assert_eq!(path, None);
        assert!(!deprecate);
    }

    // D5: an empty value is treated as unset, so a stray `EC_CORE_PATH=`
    // in the environment does not spuriously trigger the deprecation note.
    #[test]
    fn empty_values_are_treated_as_unset() {
        let (path, deprecate) = resolve_core_env_override(Some(""), Some(""));
        assert_eq!(path, None);
        assert!(!deprecate);
    }

    // D6: ~/.config/vox/config is the documented path and is chosen when present.
    #[test]
    fn vox_config_file_resolves() {
        let (path, deprecate) =
            resolve_config_file_path(Some(PathBuf::from("/home/u/.config/vox/config")), None);
        assert_eq!(path, Some(PathBuf::from("/home/u/.config/vox/config")));
        assert!(!deprecate);
    }

    // D6: ~/.config/ec/config still works as a deprecated fallback.
    #[test]
    fn ec_config_file_still_resolves() {
        let (path, deprecate) =
            resolve_config_file_path(None, Some(PathBuf::from("/home/u/.config/ec/config")));
        assert_eq!(path, Some(PathBuf::from("/home/u/.config/ec/config")));
        // Only the deprecated file was present: the caller should note it.
        assert!(deprecate);
    }

    // D6: when both exist, ~/.config/vox/config takes precedence and no
    // deprecation note is warranted (the author already uses the new path).
    #[test]
    fn vox_config_file_takes_precedence_over_ec_config_file() {
        let (path, deprecate) = resolve_config_file_path(
            Some(PathBuf::from("/home/u/.config/vox/config")),
            Some(PathBuf::from("/home/u/.config/ec/config")),
        );
        assert_eq!(path, Some(PathBuf::from("/home/u/.config/vox/config")));
        assert!(!deprecate);
    }

    // D6: with neither file present, there is no config override and nothing
    // to note.
    #[test]
    fn neither_config_file_present_is_no_override() {
        let (path, deprecate) = resolve_config_file_path(None, None);
        assert_eq!(path, None);
        assert!(!deprecate);
    }
}