antigen-macros 0.0.1

Procedural macros for the antigen crate. Not for direct use; use antigen instead.
Documentation
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
//! Argument parsing for the antigen attribute macros.
//!
//! ## Span discipline (W4)
//!
//! Validation errors point at the offending token, not the whole macro
//! invocation. Each parsed field carries its own `proc_macro2::Span` (the
//! span of the *value* literal, e.g., the `""` in `name = ""`). For
//! missing-required-field errors there is no offending token — those errors
//! are anchored at `args_span`, the span of the macro's argument list. This
//! is consistently better than `Span::call_site()`, which points at the
//! whole `#[antigen(...)]` invocation.

use proc_macro2::Span;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, Ident, Lit, LitStr, Path, Token};

/// Arguments to `#[antigen(...)]`.
#[allow(dead_code)]
// family/summary/references are captured for validation but
// not currently used in macro expansion. They will be used
// when the macro emits richer #[doc] forwards or registers
// declarations for cross-crate discovery (future ADRs).
#[derive(Debug)]
pub struct AntigenArgs {
    pub name: String,
    pub fingerprint: String,
    pub family: Option<String>,
    pub summary: Option<String>,
    pub references: Vec<String>,
    /// Span of the `name`'s string literal value.
    /// `None` only when the field was missing — see [`AntigenArgs::validate`].
    pub name_span: Option<Span>,
    /// Span of the `fingerprint`'s string literal value.
    /// `None` only when the field was missing — see [`AntigenArgs::validate`].
    pub fingerprint_span: Option<Span>,
    /// Span of the macro's argument list as a whole. Used as the fallback
    /// anchor for missing-required-field errors (no offending token).
    pub args_span: Span,
}

/// Arguments to `#[presents(antigen_type)]`.
pub struct PresentsArgs {
    #[allow(dead_code)]
    pub antigen: Path,
}

/// Arguments to `#[immune(antigen_type, witness = ..., [rationale = ...])]`.
pub struct ImmuneArgs {
    pub antigen: Path,
    pub witness: Option<Expr>,
    #[allow(dead_code)]
    pub rationale: Option<String>,
}

/// Arguments to `#[descended_from(parent_path)]`.
pub struct DescendedFromArgs {
    #[allow(dead_code)]
    pub parent: Path,
}

/// Arguments to `#[antigen_tolerance(antigen, rationale = "...", until = "...", see = [...])]`.
///
/// Per ADR-011: positional antigen, required `rationale` (non-empty),
/// optional `until` (non-empty if present), optional `see` (open-vocab string array).
pub struct ToleranceArgs {
    #[allow(dead_code)]
    pub antigen: Path,
    pub rationale: Option<String>,
    pub rationale_span: Option<Span>,
    pub until: Option<String>,
    pub until_span: Option<Span>,
    #[allow(dead_code)]
    pub see: Vec<String>,
    pub args_span: Span,
}

// ============================================================================
// AntigenArgs parsing
// ============================================================================

impl Parse for AntigenArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let args_span = input.span();

        let mut name: Option<String> = None;
        let mut name_span: Option<Span> = None;
        let mut fingerprint: Option<String> = None;
        let mut fingerprint_span: Option<Span> = None;
        let mut family: Option<String> = None;
        let mut summary: Option<String> = None;
        let mut references: Vec<String> = Vec::new();

        let pairs: Punctuated<MetaPair, Token![,]> =
            input.parse_terminated(MetaPair::parse, Token![,])?;

        for pair in pairs {
            match pair.key.to_string().as_str() {
                "name" => {
                    let (s, span) = pair.expect_string_spanned()?;
                    name = Some(s);
                    name_span = Some(span);
                }
                "fingerprint" => {
                    let (s, span) = pair.expect_string_spanned()?;
                    fingerprint = Some(s);
                    fingerprint_span = Some(span);
                }
                "family" => family = Some(pair.expect_string()?),
                "summary" => summary = Some(pair.expect_string()?),
                "references" => references = pair.expect_string_array()?,
                other => {
                    return Err(syn::Error::new(
                        pair.key.span(),
                        format!(
                            "unknown #[antigen] field `{other}`; expected one of: \
                                 name, fingerprint, family, summary, references"
                        ),
                    ))
                }
            }
        }

        let name =
            name.ok_or_else(|| syn::Error::new(args_span, "#[antigen] requires `name = \"...\"`"))?;
        let fingerprint = fingerprint.ok_or_else(|| {
            syn::Error::new(args_span, "#[antigen] requires `fingerprint = \"...\"`")
        })?;

        Ok(Self {
            name,
            fingerprint,
            family,
            summary,
            references,
            name_span,
            fingerprint_span,
            args_span,
        })
    }
}

impl AntigenArgs {
    pub fn validate(&self) -> syn::Result<()> {
        if self.name.is_empty() {
            return Err(syn::Error::new(
                self.name_span.unwrap_or(self.args_span),
                "#[antigen] `name` cannot be empty",
            ));
        }
        if !is_kebab_case(&self.name) {
            return Err(syn::Error::new(
                self.name_span.unwrap_or(self.args_span),
                format!(
                    "#[antigen] `name = \"{}\"` must be kebab-case (lowercase with hyphens)",
                    self.name
                ),
            ));
        }
        if self.fingerprint.is_empty() {
            return Err(syn::Error::new(
                self.fingerprint_span.unwrap_or(self.args_span),
                "#[antigen] `fingerprint` cannot be empty",
            ));
        }
        // W6a: per ADR-010 Amendment 3 Clause E, the fingerprint string is
        // parsed at macro-compile time so malformed fingerprints don't ship.
        // Re-anchor any Path-C parser error to the fingerprint literal's span
        // so the user sees the squiggle on the offending text.
        if let Err(parse_err) = antigen_fingerprint::Fingerprint::parse(&self.fingerprint) {
            let anchor = self.fingerprint_span.unwrap_or(self.args_span);
            return Err(syn::Error::new(
                anchor,
                format!(
                    "#[antigen] `fingerprint` does not parse: {parse_err}\n\
                     (per ADR-010 Amendment 1 Path C — DSL syntax, not raw Rust expressions)"
                ),
            ));
        }
        Ok(())
    }
}

// ============================================================================
// PresentsArgs parsing
// ============================================================================

impl Parse for PresentsArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let antigen: Path = input.parse()?;
        Ok(Self { antigen })
    }
}

// ============================================================================
// ImmuneArgs parsing
// ============================================================================

impl Parse for ImmuneArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let antigen: Path = input.parse()?;
        let mut witness: Option<Expr> = None;
        let mut rationale: Option<String> = None;

        while !input.is_empty() {
            input.parse::<Token![,]>()?;
            if input.is_empty() {
                break;
            }
            let key: Ident = input.parse()?;
            input.parse::<Token![=]>()?;
            match key.to_string().as_str() {
                "witness" => {
                    witness = Some(input.parse()?);
                }
                "rationale" => {
                    let lit: LitStr = input.parse()?;
                    rationale = Some(lit.value());
                }
                other => {
                    return Err(syn::Error::new(
                        key.span(),
                        format!(
                            "unknown #[immune] field `{other}`; expected one of: witness, rationale"
                        ),
                    ));
                }
            }
        }

        Ok(Self {
            antigen,
            witness,
            rationale,
        })
    }
}

impl ImmuneArgs {
    pub fn validate(&self) -> syn::Result<()> {
        if self.witness.is_none() {
            return Err(syn::Error::new_spanned(
                &self.antigen,
                "#[immune] requires `witness = ...` (a test, proptest, lint reference, \
                 formal-verification proof, or phantom-type construction). \
                 A marker without proof is not a claim.",
            ));
        }
        Ok(())
    }
}

// ============================================================================
// DescendedFromArgs parsing
// ============================================================================

impl Parse for DescendedFromArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let parent: Path = input.parse()?;
        Ok(Self { parent })
    }
}

// ============================================================================
// ToleranceArgs parsing (ADR-011)
// ============================================================================

impl Parse for ToleranceArgs {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let args_span = input.span();
        let antigen: Path = input.parse()?;
        let mut rationale: Option<String> = None;
        let mut rationale_span: Option<Span> = None;
        let mut until: Option<String> = None;
        let mut until_span: Option<Span> = None;
        let mut see: Vec<String> = Vec::new();

        while !input.is_empty() {
            input.parse::<Token![,]>()?;
            if input.is_empty() {
                break;
            }
            let key: Ident = input.parse()?;
            input.parse::<Token![=]>()?;
            match key.to_string().as_str() {
                "rationale" => {
                    let lit: LitStr = input.parse()?;
                    rationale_span = Some(lit.span());
                    rationale = Some(lit.value());
                }
                "until" => {
                    let lit: LitStr = input.parse()?;
                    until_span = Some(lit.span());
                    until = Some(lit.value());
                }
                "see" => {
                    let arr: syn::ExprArray = input.parse()?;
                    for elem in &arr.elems {
                        if let Expr::Lit(syn::ExprLit {
                            lit: Lit::Str(s), ..
                        }) = elem
                        {
                            see.push(s.value());
                        } else {
                            return Err(syn::Error::new_spanned(
                                elem,
                                "expected a string literal in `see` array",
                            ));
                        }
                    }
                }
                other => {
                    return Err(syn::Error::new(
                        key.span(),
                        format!(
                            "unknown #[antigen_tolerance] field `{other}`; expected one of: \
                             rationale, until, see",
                        ),
                    ));
                }
            }
        }

        Ok(Self {
            antigen,
            rationale,
            rationale_span,
            until,
            until_span,
            see,
            args_span,
        })
    }
}

impl ToleranceArgs {
    /// Trust-boundary checks per ADR-011 Mechanics:
    /// - rationale required and non-empty (claim without rationale is not a claim)
    /// - until non-empty if present (empty string indicates user error)
    pub fn validate(&self) -> syn::Result<()> {
        let Some(rationale) = self.rationale.as_deref() else {
            return Err(syn::Error::new_spanned(
                &self.antigen,
                "#[antigen_tolerance] requires `rationale = \"...\"`. \
                 A tolerance without rationale is not a claim — it's a silent suppression.",
            ));
        };
        if rationale.is_empty() {
            return Err(syn::Error::new(
                self.rationale_span.unwrap_or(self.args_span),
                "#[antigen_tolerance] `rationale` must not be empty",
            ));
        }
        if let Some(until) = self.until.as_deref() {
            if until.is_empty() {
                return Err(syn::Error::new(
                    self.until_span.unwrap_or(self.args_span),
                    "#[antigen_tolerance] `until = \"\"` rejected — \
                     an empty expiry indicates user error. Use `until = \"v1.0\"` \
                     (or similar) or omit the field entirely for forever-tolerance.",
                ));
            }
        }
        Ok(())
    }
}

// ============================================================================
// Helpers
// ============================================================================

struct MetaPair {
    key: Ident,
    value: Expr,
}

impl Parse for MetaPair {
    fn parse(input: ParseStream) -> syn::Result<Self> {
        let key: Ident = input.parse()?;
        input.parse::<Token![=]>()?;
        let value: Expr = input.parse()?;
        Ok(Self { key, value })
    }
}

impl MetaPair {
    fn expect_string(&self) -> syn::Result<String> {
        if let Expr::Lit(syn::ExprLit {
            lit: Lit::Str(s), ..
        }) = &self.value
        {
            Ok(s.value())
        } else {
            Err(syn::Error::new_spanned(
                &self.value,
                format!("expected a string literal for `{}`", self.key),
            ))
        }
    }

    /// Like [`Self::expect_string`] but also returns the span of the string
    /// literal so validation errors can point at the literal itself.
    fn expect_string_spanned(&self) -> syn::Result<(String, Span)> {
        if let Expr::Lit(syn::ExprLit {
            lit: Lit::Str(s), ..
        }) = &self.value
        {
            Ok((s.value(), s.span()))
        } else {
            Err(syn::Error::new_spanned(
                &self.value,
                format!("expected a string literal for `{}`", self.key),
            ))
        }
    }

    fn expect_string_array(&self) -> syn::Result<Vec<String>> {
        if let Expr::Array(arr) = &self.value {
            let mut out = Vec::new();
            for elem in &arr.elems {
                if let Expr::Lit(syn::ExprLit {
                    lit: Lit::Str(s), ..
                }) = elem
                {
                    out.push(s.value());
                } else {
                    return Err(syn::Error::new_spanned(
                        elem,
                        "expected a string literal in references array",
                    ));
                }
            }
            Ok(out)
        } else {
            Err(syn::Error::new_spanned(
                &self.value,
                format!("expected a string array for `{}`", self.key),
            ))
        }
    }
}

fn is_kebab_case(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        && !s.starts_with('-')
        && !s.ends_with('-')
        && !s.contains("--")
}

// ============================================================================
// Cross-parser equivalence fixtures
//
// These fixtures define the invariant: for any input the macro side accepts as
// valid, the scan side must produce equivalent semantic content for the four
// overlapping fields (name, fingerprint, family, summary). The same fixture
// table appears in `antigen/src/scan.rs` (ScanAntigenArgs tests) — keeping the
// inputs and expected outputs literally identical is what makes the
// equivalence inspectable.
//
// ATK-001-2 lesson: the brittle string-manipulation parser corrupted
// fingerprints with inner double-quotes silently. Property tests over both
// parsers prevent that class of drift from re-emerging.
//
// When adding a fixture here, add the matching one to scan.rs. When adding a
// new field to the antigen attribute grammar, add fixtures here AND to scan.rs
// to lock the equivalence.
// ============================================================================

/// Fixture tuple shape: `(input, expected_name, expected_fingerprint,
/// expected_family, expected_summary)`.
#[cfg(test)]
type AntigenFixture = (
    &'static str,
    &'static str,
    &'static str,
    Option<&'static str>,
    Option<&'static str>,
);

#[cfg(test)]
const ANTIGEN_PARSER_FIXTURES: &[AntigenFixture] = &[
    // 1. Smoke test: just the two required fields.
    (
        r#"name = "panicking-in-drop", fingerprint = "impl Drop with panic""#,
        "panicking-in-drop",
        "impl Drop with panic",
        None,
        None,
    ),
    // 2. All four fields populated.
    (
        r#"name = "frame-translation", fingerprint = "class enum + meet", family = "semantic-drift", summary = "Polarity inverts at the frame boundary""#,
        "frame-translation",
        "class enum + meet",
        Some("semantic-drift"),
        Some("Polarity inverts at the frame boundary"),
    ),
    // 3. Inner-quoted fingerprint (the ATK-001-2 regression case).
    (
        r#"name = "x", fingerprint = "item: enum, has_method(\"meet\", \"(Self, Self) -> Self\")""#,
        "x",
        r#"item: enum, has_method("meet", "(Self, Self) -> Self")"#,
        None,
        None,
    ),
    // 4. Reordered fields (order-invariance check).
    (
        r#"summary = "S", family = "F", fingerprint = "FP", name = "n""#,
        "n",
        "FP",
        Some("F"),
        Some("S"),
    ),
    // 5. References array present (macro stores; scan ignores; both must
    //    accept without error).
    (
        r#"name = "x", fingerprint = "y", references = ["GAP-1", "DEC-2"]"#,
        "x",
        "y",
        None,
        None,
    ),
    // 6. Multi-line whitespace (tab + newline) — common rustfmt output shape.
    (
        "name = \"multi-line\",\n\tfingerprint = \"shape\",\n\tfamily = \"family\"",
        "multi-line",
        "shape",
        Some("family"),
        None,
    ),
];

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

    #[test]
    fn antigen_parser_accepts_all_fixtures() {
        for (input, exp_name, exp_fp, exp_family, exp_summary) in ANTIGEN_PARSER_FIXTURES {
            let tokens: TokenStream = input
                .parse()
                .unwrap_or_else(|e| panic!("fixture failed to tokenize: {input:?}: {e}"));
            let args = syn::parse2::<AntigenArgs>(tokens)
                .unwrap_or_else(|e| panic!("macro parser rejected fixture {input:?}: {e}"));
            assert_eq!(&args.name, exp_name, "name mismatch for fixture: {input:?}");
            assert_eq!(
                &args.fingerprint, exp_fp,
                "fingerprint mismatch for fixture: {input:?}"
            );
            assert_eq!(
                args.family.as_deref(),
                *exp_family,
                "family mismatch for fixture: {input:?}"
            );
            assert_eq!(
                args.summary.as_deref(),
                *exp_summary,
                "summary mismatch for fixture: {input:?}"
            );
        }
    }

    #[test]
    fn antigen_parser_rejects_missing_name() {
        let tokens: TokenStream = r#"fingerprint = "x""#.parse().unwrap();
        assert!(syn::parse2::<AntigenArgs>(tokens).is_err());
    }

    #[test]
    fn antigen_parser_rejects_missing_fingerprint() {
        let tokens: TokenStream = r#"name = "x""#.parse().unwrap();
        assert!(syn::parse2::<AntigenArgs>(tokens).is_err());
    }

    #[test]
    fn antigen_parser_rejects_unknown_field() {
        let tokens: TokenStream = r#"name = "x", fingerprint = "y", bogus = "z""#.parse().unwrap();
        match syn::parse2::<AntigenArgs>(tokens) {
            Ok(_) => panic!("expected parse to reject unknown field `bogus`"),
            Err(e) => {
                let msg = e.to_string();
                assert!(
                    msg.contains("unknown") && msg.contains("bogus"),
                    "expected unknown-field error mentioning `bogus`, got: {msg}"
                );
            }
        }
    }

    /// Construct an `AntigenArgs` with the given name + a valid DSL fingerprint.
    /// Used by direct-construction tests that bypass `Parse` to exercise
    /// name-validation paths in `validate()`. Tests that need to exercise
    /// fingerprint-validation paths build their own `AntigenArgs` literal
    /// with the specific fingerprint they want to assert against.
    fn args_with(name: &str, fingerprint: &str) -> AntigenArgs {
        AntigenArgs {
            name: name.to_string(),
            fingerprint: fingerprint.to_string(),
            family: None,
            summary: None,
            references: Vec::new(),
            name_span: Some(proc_macro2::Span::call_site()),
            fingerprint_span: Some(proc_macro2::Span::call_site()),
            args_span: proc_macro2::Span::call_site(),
        }
    }

    /// Minimal DSL fingerprint string accepted by the W6a parser. Tests that
    /// don't care about fingerprint content but DO want `validate()` to succeed
    /// use this to keep their assertions focused on name validation.
    const VALID_DSL: &str = r#"name = matches("*")"#;

    #[test]
    fn validate_rejects_empty_name() {
        assert!(args_with("", VALID_DSL).validate().is_err());
    }

    #[test]
    fn validate_rejects_non_kebab_name() {
        assert!(args_with("FooBar", VALID_DSL).validate().is_err());
    }

    #[test]
    fn validate_accepts_kebab_name_with_digits() {
        assert!(args_with("frame-2-translation", VALID_DSL)
            .validate()
            .is_ok());
    }

    #[test]
    fn validate_rejects_name_with_double_hyphen() {
        assert!(args_with("frame--translation", VALID_DSL)
            .validate()
            .is_err());
    }

    #[test]
    fn validate_rejects_name_starting_with_hyphen() {
        assert!(args_with("-frame", VALID_DSL).validate().is_err());
    }

    #[test]
    fn validate_rejects_malformed_dsl_fingerprint() {
        let args = args_with("ok-name", "this is not the dsl");
        let err = args.validate().unwrap_err().to_string();
        assert!(err.contains("fingerprint"), "got: {err}");
    }

    #[test]
    fn immune_parser_requires_witness() {
        let tokens: TokenStream = r"PanickingInDrop".parse().unwrap();
        let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
        assert!(args.validate().is_err());
    }

    #[test]
    fn immune_parser_accepts_witness_path() {
        let tokens: TokenStream = r"PanickingInDrop, witness = my::module::test_fn"
            .parse()
            .unwrap();
        let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
        assert!(args.witness.is_some());
        assert!(args.validate().is_ok());
    }

    #[test]
    fn immune_parser_accepts_rationale() {
        let tokens: TokenStream = r#"X, witness = my_test, rationale = "checked manually""#
            .parse()
            .unwrap();
        let args = syn::parse2::<ImmuneArgs>(tokens).unwrap();
        assert_eq!(args.rationale.as_deref(), Some("checked manually"));
    }
}

// ============================================================================
// Property tests (W1) — proptest invariants over the macro-side parser surface.
//
// These proptests are the macro-side half of the cross-parser equivalence
// story. The matching scan-side proptests live in
// `antigen/src/scan.rs::tests::scan_parser_props`. The two test modules share:
//
//   - the same `valid_*` strategies (literal-copied; if you change one,
//     change the other in the same commit); and
//   - the same expected-outcome assertions for inputs both parsers accept.
//
// Because `proc-macro = true` crates cannot be linked as libraries, the two
// parsers cannot be invoked from a single test binary. The fixture-table
// approach in the same file (`ANTIGEN_PARSER_FIXTURES`) provides
// by-construction cross-parser checks at six concrete points; the proptest
// strategies fuzz the input space around the same grammar shape from each
// side independently. Drift between the two manifests as one side accepting
// inputs the other rejects — caught here on the macro side, caught there on
// the scan side.
//
// Cross-parser invariants asserted (per ADR-001 Amendment 1 C5
// drift-detection-at-scan-time, and the ATK-001-2 lesson):
//
//   I1 — equivalence-on-intersection: for any input the macro side accepts,
//        the scan side accepts and produces equivalent semantic content for
//        name/fingerprint/family/summary. (Macro side checks "I accept";
//        scan side checks "I accept and the result matches what I'd render
//        back into the macro grammar.")
//
//   I2 — strict-superset-of-rejection: the macro side strictly rejects more
//        than the scan side (asymmetric by design — see scan.rs comments on
//        unknown-field tolerance and missing-required-field tolerance).
//        Rejecting more is fine; accepting where the macro rejects is not.
//
// Adversarial input shapes worth fuzzing (per W1's adversarial-pass plan):
// Unicode in names, nested macros / inner-quoted strings in fingerprints,
// extremely long string literals, malformed array literals, multi-line
// rustfmt output, all-whitespace edge cases, kebab-case boundary inputs.
// ============================================================================

#[cfg(test)]
mod parser_props {
    use super::*;
    use proc_macro2::TokenStream;
    use proptest::prelude::*;

    // Rust reserved words that cannot appear as path segments. Generated by
    // strategy `[a-z][a-z_0-9]{0,8}` without this filter, causing syn to reject
    // inputs that are otherwise syntactically correct `#[immune]` bodies.
    const RUST_KEYWORDS: &[&str] = &[
        "as", "async", "await", "box", "break", "const", "continue", "crate", "do", "dyn", "else",
        "enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "macro",
        "match", "mod", "move", "mut", "pub", "ref", "return", "self", "static", "struct", "super",
        "trait", "true", "type", "union", "unsafe", "use", "where", "while", "yield", "abstract",
        "become", "final", "override", "priv", "try",
    ];

    // --- Strategies (shared shape with antigen/src/scan.rs::tests) ---

    /// Generate a kebab-case name: `[a-z][a-z0-9]*(-[a-z0-9]+)*`. The
    /// substrate's `is_kebab_case` rule rejects leading/trailing hyphens
    /// and consecutive double-hyphens; this strategy generates only legal
    /// shapes so we can lock the validate-accepts side. (Rejection of
    /// non-kebab is tested by the existing fixture tests.)
    fn valid_kebab() -> impl Strategy<Value = String> {
        // 1-4 segments, each 1-8 chars from [a-z0-9] and starting with [a-z].
        proptest::collection::vec(
            (
                proptest::char::range('a', 'z'),
                proptest::collection::vec(
                    prop_oneof![
                        proptest::char::range('a', 'z'),
                        proptest::char::range('0', '9')
                    ],
                    0..8usize,
                ),
            )
                .prop_map(|(first, rest)| {
                    let mut s = String::with_capacity(rest.len() + 1);
                    s.push(first);
                    for c in rest {
                        s.push(c);
                    }
                    s
                }),
            1..5usize,
        )
        .prop_map(|segments| segments.join("-"))
    }

    /// Generate a non-empty string suitable for use as a fingerprint /
    /// summary / family content. Avoids characters that would close the
    /// outer `"..."` literal at the token level (since these end up
    /// embedded in a Rust source-text string literal we synthesize). We
    /// allow inner content that includes escaped quotes via the
    /// fixture-style escape `\"` — the serialization layer handles
    /// escaping.
    fn valid_text(max_len: usize) -> impl Strategy<Value = String> {
        // Keep characters in a printable-ASCII range that can be safely
        // round-tripped through `Debug` formatting (which is how we emit
        // string literals into the synthetic source). Excludes: backslash
        // (escape complications), null bytes, raw quotes (the encoder will
        // escape them anyway, but we keep the strategy simple here).
        proptest::collection::vec(
            prop_oneof![
                proptest::char::range(' ', '~').prop_filter("excluded chars", |c| {
                    *c != '\\' && *c != '"' && *c != '\0'
                }),
            ],
            1..=max_len,
        )
        .prop_map(|chars| chars.into_iter().collect())
    }

    /// Encode a Rust string literal: wrap in double-quotes and escape
    /// inner quotes/backslashes via `format!("{:?}", s)`, which is the
    /// canonical Debug-encoding for `String` and matches what
    /// `syn::LitStr` accepts/produces.
    fn lit(s: &str) -> String {
        format!("{s:?}")
    }

    /// Render a `(name, fingerprint, family, summary)` tuple as the
    /// canonical `#[antigen(...)]` body in name-first order.
    fn render_antigen_body(
        name: &str,
        fingerprint: &str,
        family: Option<&str>,
        summary: Option<&str>,
    ) -> String {
        let mut parts = vec![
            format!("name = {}", lit(name)),
            format!("fingerprint = {}", lit(fingerprint)),
        ];
        if let Some(f) = family {
            parts.push(format!("family = {}", lit(f)));
        }
        if let Some(s) = summary {
            parts.push(format!("summary = {}", lit(s)));
        }
        parts.join(", ")
    }

    proptest! {
        // P1 — round-trip: any valid set of args parses, and re-rendering it
        // produces a body that re-parses to the same args.
        #[test]
        fn antigen_parser_round_trip(
            name in valid_kebab(),
            fingerprint in valid_text(64),
            family in proptest::option::of(valid_text(32)),
            summary in proptest::option::of(valid_text(64)),
        ) {
            let body = render_antigen_body(&name, &fingerprint, family.as_deref(), summary.as_deref());
            let tokens: TokenStream = body.parse().expect("body must tokenize");
            let args = syn::parse2::<AntigenArgs>(tokens).expect("body must parse");
            prop_assert_eq!(&args.name, &name);
            prop_assert_eq!(&args.fingerprint, &fingerprint);
            prop_assert_eq!(args.family.as_deref(), family.as_deref());
            prop_assert_eq!(args.summary.as_deref(), summary.as_deref());
            // W6a: validate() now invokes antigen_fingerprint::Fingerprint::parse,
            // which rejects arbitrary text. The round-trip property is about
            // parse-render-parse idempotency for the value fields, not about
            // DSL validity; drop the validate() assertion here. A separate
            // proptest with a valid_dsl() strategy is future work.

            // Round-trip: re-render the parsed args and re-parse. Result
            // must be identical (idempotency under the canonical rendering).
            let re_rendered = render_antigen_body(&args.name, &args.fingerprint, args.family.as_deref(), args.summary.as_deref());
            let re_tokens: TokenStream = re_rendered.parse().expect("re-rendered body must tokenize");
            let args2 = syn::parse2::<AntigenArgs>(re_tokens).expect("re-rendered body must parse");
            prop_assert_eq!(&args.name, &args2.name);
            prop_assert_eq!(&args.fingerprint, &args2.fingerprint);
            prop_assert_eq!(args.family, args2.family);
            prop_assert_eq!(args.summary, args2.summary);
        }

        // P2 — order-invariance: shuffling the order of valid fields does
        // not change the parsed result.
        #[test]
        fn antigen_parser_order_invariant(
            name in valid_kebab(),
            fingerprint in valid_text(48),
            family in valid_text(24),
            summary in valid_text(48),
        ) {
            // Two orderings: name-first (canonical) and reversed.
            let canonical = format!(
                "name = {}, fingerprint = {}, family = {}, summary = {}",
                lit(&name), lit(&fingerprint), lit(&family), lit(&summary),
            );
            let reversed = format!(
                "summary = {}, family = {}, fingerprint = {}, name = {}",
                lit(&summary), lit(&family), lit(&fingerprint), lit(&name),
            );
            let a: AntigenArgs = syn::parse2(canonical.parse::<TokenStream>().unwrap()).unwrap();
            let b: AntigenArgs = syn::parse2(reversed.parse::<TokenStream>().unwrap()).unwrap();
            prop_assert_eq!(&a.name, &b.name);
            prop_assert_eq!(&a.fingerprint, &b.fingerprint);
            prop_assert_eq!(&a.family, &b.family);
            prop_assert_eq!(&a.summary, &b.summary);
        }

        // P3 — kebab-case validator accepts every kebab-case string our
        // generator produces. (Negative shapes are tested by the fixture
        // tests — `validate_rejects_*` — already.)
        #[test]
        fn is_kebab_case_accepts_generator(name in valid_kebab()) {
            prop_assert!(is_kebab_case(&name), "is_kebab_case rejected generator output: {name:?}");
        }

        // P4 — required-field enforcement: any input missing `name` is
        // rejected with an error mentioning `name`. Same for `fingerprint`.
        #[test]
        fn antigen_parser_requires_name(
            fingerprint in valid_text(32),
            family in proptest::option::of(valid_text(16)),
        ) {
            let mut parts = vec![format!("fingerprint = {}", lit(&fingerprint))];
            if let Some(f) = &family {
                parts.push(format!("family = {}", lit(f)));
            }
            let body = parts.join(", ");
            let tokens: TokenStream = body.parse().expect("body tokenizes");
            let result = syn::parse2::<AntigenArgs>(tokens);
            prop_assert!(result.is_err(), "expected parser to reject input missing `name`: {body:?}");
            let err = result.unwrap_err().to_string();
            prop_assert!(err.contains("name"), "error must mention `name`, got: {err:?}");
        }

        #[test]
        fn antigen_parser_requires_fingerprint(
            name in valid_kebab(),
            family in proptest::option::of(valid_text(16)),
        ) {
            let mut parts = vec![format!("name = {}", lit(&name))];
            if let Some(f) = &family {
                parts.push(format!("family = {}", lit(f)));
            }
            let body = parts.join(", ");
            let tokens: TokenStream = body.parse().expect("body tokenizes");
            let result = syn::parse2::<AntigenArgs>(tokens);
            prop_assert!(result.is_err(), "expected parser to reject input missing `fingerprint`: {body:?}");
            let err = result.unwrap_err().to_string();
            prop_assert!(err.contains("fingerprint"), "error must mention `fingerprint`, got: {err:?}");
        }

        // P5 — unknown-field rejection (macro-side strictness; the scan
        // side tolerates these — that's the documented asymmetry).
        #[test]
        fn antigen_parser_rejects_unknown_field(
            name in valid_kebab(),
            fingerprint in valid_text(32),
            // Generate an unknown field name that doesn't collide with any
            // of the known field names.
            unknown in "[a-z][a-z_]{2,12}".prop_filter(
                "must not collide with known fields or Rust keywords",
                |s| {
                    !matches!(s.as_str(), "name" | "fingerprint" | "family" | "summary" | "references")
                        && !RUST_KEYWORDS.contains(&s.as_str())
                },
            ),
        ) {
            let body = format!(
                "name = {}, fingerprint = {}, {} = \"x\"",
                lit(&name), lit(&fingerprint), unknown,
            );
            let tokens: TokenStream = body.parse().expect("body tokenizes");
            let result = syn::parse2::<AntigenArgs>(tokens);
            prop_assert!(result.is_err(), "expected unknown field rejection for: {body:?}");
            let err = result.unwrap_err().to_string();
            prop_assert!(
                err.contains("unknown") && err.contains(&unknown),
                "error must name the unknown field. got: {err:?}",
            );
        }

        // P6 — references array round-trips: any list of valid strings in
        // the references array parses without error and we record them.
        #[test]
        fn antigen_parser_accepts_references_array(
            name in valid_kebab(),
            fingerprint in valid_text(32),
            refs in proptest::collection::vec(valid_text(24), 0..6usize),
        ) {
            let refs_lit: Vec<String> = refs.iter().map(|s| lit(s)).collect();
            let body = format!(
                "name = {}, fingerprint = {}, references = [{}]",
                lit(&name), lit(&fingerprint), refs_lit.join(", "),
            );
            let tokens: TokenStream = body.parse().expect("body tokenizes");
            let args = syn::parse2::<AntigenArgs>(tokens).expect("body parses");
            prop_assert_eq!(&args.references, &refs);
        }

        // P7 — ImmuneArgs: any valid (path, witness-path) pair parses
        // and validate() accepts.
        //
        // Strategy note: `[a-z][a-z_0-9]{0,8}` can generate Rust reserved
        // words (fn, if, in, let, mod, …). syn rejects reserved words as
        // path segments, so we filter them out. The filter does not weaken the
        // property: the invariant is about VALID witness paths, and keywords
        // are not valid path segments.
        #[test]
        fn immune_parser_accepts_witness(
            antigen in "[A-Z][A-Za-z0-9]{0,16}",
            witness_segments in proptest::collection::vec(
                "[a-z][a-z_0-9]{0,8}".prop_filter("must not be a Rust keyword", |s| {
                    !RUST_KEYWORDS.contains(&s.as_str())
                }),
                1..4usize,
            ),
        ) {
            let witness = witness_segments.join("::");
            let body = format!("{antigen}, witness = {witness}");
            let tokens: TokenStream = body.parse().expect("body tokenizes");
            let args = syn::parse2::<ImmuneArgs>(tokens).expect("body parses");
            prop_assert!(args.witness.is_some());
            prop_assert!(args.validate().is_ok());
        }

        // P8 — ImmuneArgs: missing witness => validate() errors with
        // the witness-required message. (The Parse impl accepts a bare
        // antigen path; validate() is the trust-boundary check.)
        #[test]
        fn immune_parser_validate_rejects_missing_witness(
            antigen in "[A-Z][A-Za-z0-9]{0,16}",
        ) {
            let tokens: TokenStream = antigen.parse().expect("antigen tokenizes");
            let args = syn::parse2::<ImmuneArgs>(tokens).expect("bare path parses");
            prop_assert!(args.witness.is_none());
            let err = args.validate().unwrap_err().to_string();
            prop_assert!(err.contains("witness"), "validate must mention `witness`, got: {err:?}");
        }
    }
}