sysml-v2-parser 0.22.0

SysML v2 textual notation parser for Rust
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
//! Parser tests: recovery

use sysml_v2_parser::ast::*;
use sysml_v2_parser::{parse, parse_with_diagnostics};

#[test]
fn test_parse_with_diagnostics_partial_ast_and_multiple_errors() {
    // One valid element, two invalid lines, then another valid element. Recovery should collect
    // two errors and still produce a partial AST with both valid packages.
    let input = "package Foo;\nnot valid\nalso bad\npackage Bar;";
    let result = parse_with_diagnostics(input);
    assert!(!result.is_ok(), "should have parse errors");
    assert_eq!(result.errors.len(), 2, "should report two parse errors");
    assert_eq!(
        result.root.elements.len(),
        2,
        "partial AST should contain both valid packages"
    );
    // First element is Foo, second is Bar
    let names: Vec<&str> = result
        .root
        .elements
        .iter()
        .filter_map(|n| {
            if let RootElement::Package(p) = &n.value {
                p.value.identification.name.as_deref()
            } else {
                None
            }
        })
        .collect();
    assert_eq!(names, ["Foo", "Bar"]);

    // Error quality: each error should have "found" snippet and expected context
    for err in &result.errors {
        assert!(
            err.found.is_some(),
            "error should have 'found' snippet: {}",
            err.message
        );
        assert!(
            err.expected.is_some(),
            "error should have 'expected' context: {}",
            err.message
        );
        assert!(
            err.expected
                .as_deref()
                .is_some_and(|e| e.contains("package") || e.contains("namespace")),
            "expected should mention package or namespace: {:?}",
            err.expected
        );
        assert!(err.code.is_some(), "error should have a code");
    }
    // First error is at "not valid"
    assert!(
        result.errors[0]
            .found
            .as_deref()
            .is_some_and(|f| f.contains("not")),
        "first error found should mention invalid token: {:?}",
        result.errors[0].found
    );
}

#[test]
fn test_parse_error_expected_end_of_input_has_found() {
    // Trailing text after valid packages: parse succeeds for "package Foo; package Bar;" then rest "garbage" triggers "expected end of input"
    let input = "package Foo; package Bar; garbage";
    let result = parse(input);
    let err = result.unwrap_err();
    assert!(
        err.message.contains("expected end of input"),
        "error should be 'expected end of input': {}",
        err
    );
    assert!(
        err.found.is_some(),
        "expected end of input error should have 'found': {}",
        err
    );
    assert!(
        err.found.as_deref().is_some_and(|f| f.contains("garbage")),
        "found should show trailing text: {:?}",
        err.found
    );
    assert_eq!(err.code.as_deref(), Some("expected_end_of_input"));
}

#[test]
fn test_parse_error_display_includes_found_and_location() {
    let input = "package Foo;\nxyz";
    let result = parse_with_diagnostics(input);
    let err = &result.errors[0];
    let display = err.to_string();
    assert!(
        display.contains("line"),
        "Display should include line number"
    );
    assert!(
        err.found.as_ref().is_some_and(|f| display.contains(f)),
        "Display should include found snippet: {}",
        display
    );
}

#[test]
fn test_action_def_is_not_parsed_as_action_usage() {
    let input = r#"package P {
action def ExecutePatrol {
}
}"#;
    let root = sysml_v2_parser::parse_root(input).expect("should parse");
    let pkg = match &root.elements[0].value {
        sysml_v2_parser::ast::RootElement::Package(p) => &p.value,
        _ => panic!("expected package root element"),
    };
    let sysml_v2_parser::ast::PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected brace body");
    };
    let first = elements.first().expect("expected a body element");
    match &first.value {
        sysml_v2_parser::ast::PackageBodyElement::ActionDef(a) => {
            assert_eq!(
                a.value.identification.name.as_deref(),
                Some("ExecutePatrol"),
                "expected ActionDef name ExecutePatrol"
            );
        }
        other => panic!("expected ActionDef, got {:?}", other),
    }
}

#[test]
fn test_parse_with_diagnostics_recovers_and_reports_later_errors() {
    // Intentionally malformed body statements (unknown keywords) followed by a valid member.
    // The goal is to ensure we report multiple diagnostics and still parse later valid elements.
    let input = r#"package P {
action def A {
  badstmt {};
  badstmt2 {};
}
action def B { }
}"#;
    let result = parse_with_diagnostics(input);
    assert!(
        result.errors.len() >= 2,
        "expected 2+ diagnostics, got: {:?}",
        result.errors
    );

    // Ensure we still parsed the later action def `B`.
    let pkg = match &result.root.elements[0].value {
        sysml_v2_parser::ast::RootElement::Package(p) => &p.value,
        _ => panic!("expected package root element"),
    };
    let sysml_v2_parser::ast::PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected brace body");
    };
    let has_b = elements.iter().any(|e| match &e.value {
        sysml_v2_parser::ast::PackageBodyElement::ActionDef(a) => {
            a.value.identification.name.as_deref() == Some("B")
        }
        _ => false,
    });
    assert!(has_b, "expected later ActionDef `B` to still be parsed");
}

#[test]
fn test_package_body_recovery_skips_annotated_member_and_keeps_later_sibling() {
    let input = "package P {\n#fmeaspec requirement req1 { }\npart def Good;\n}";
    let result = parse(input).expect("parse should succeed with recovery");
    let pkg = match &result.elements[0].value {
        RootElement::Package(p) => &p.value,
        _ => panic!("expected package"),
    };
    let elements = match &pkg.body {
        PackageBody::Brace { elements } => elements,
        _ => panic!("expected brace body"),
    };
    assert!(
        elements.iter().any(|e| matches!(e.value, PackageBodyElement::PartDef(_))),
        "later valid sibling should still be present after recovering from annotated unsupported member"
    );
    assert!(
        elements
            .iter()
            .any(|e| matches!(e.value, PackageBodyElement::Error(_))),
        "recovered package region should be represented explicitly in the AST"
    );
}

#[test]
fn test_package_body_recovery_skips_malformed_abstract_part_and_keeps_next_member() {
    let input = "package P {\nabstract part def Broken { invalid }\npart def Good;\n}";
    let result = parse(input).expect("parse should succeed");
    let pkg = match &result.elements[0].value {
        RootElement::Package(p) => &p.value,
        _ => panic!("expected package"),
    };
    let elements = match &pkg.body {
        PackageBody::Brace { elements } => elements,
        _ => panic!("expected brace body"),
    };
    assert_eq!(
        elements
            .iter()
            .filter(|e| matches!(e.value, PackageBodyElement::PartDef(_)))
            .count(),
        2,
        "both part declarations should map to dedicated PartDef nodes"
    );
}

#[test]
fn test_part_def_recovery_preserves_other_member_and_later_sibling() {
    let input =
        "package P {\npart def Vehicle {\nstate monitor: Mode;\nattribute mass: MassValue;\n}\n}";
    let result = parse_with_diagnostics(input);
    let pkg = match &result.root.elements[0].value {
        RootElement::Package(p) => &p.value,
        _ => panic!("expected package"),
    };
    let PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected brace body");
    };
    let part_def = elements
        .iter()
        .find_map(|e| match &e.value {
            PackageBodyElement::PartDef(p) => Some(&p.value),
            _ => None,
        })
        .expect("part def should be present");
    let sysml_v2_parser::ast::PartDefBody::Brace { elements } = &part_def.body else {
        panic!("expected part def body");
    };
    assert!(
        elements.iter().any(|e| matches!(
            e.value,
            sysml_v2_parser::ast::PartDefBodyElement::Other(_)
                | sysml_v2_parser::ast::PartDefBodyElement::OpaqueMember(_)
        )),
        "library-tolerant unmodeled part members should be preserved explicitly"
    );
    assert!(
        elements.iter().any(|e| matches!(
            &e.value,
            sysml_v2_parser::ast::PartDefBodyElement::AttributeDef(a)
                if a.value.typing.is_some()
        )),
        "later modeled members should still parse"
    );
}

#[test]
fn test_state_def_recovery_no_longer_truncates_body() {
    let input = "package P {\nstate def Machine {\nunknown stuff;\ntransition t then Ready;\n}\n}";
    let result = parse_with_diagnostics(input);
    let pkg = match &result.root.elements[0].value {
        RootElement::Package(p) => &p.value,
        _ => panic!("expected package"),
    };
    let PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected brace body");
    };
    let state_def = elements
        .iter()
        .find_map(|e| match &e.value {
            PackageBodyElement::StateDef(s) => Some(&s.value),
            _ => None,
        })
        .expect("state def should be present");
    let sysml_v2_parser::ast::StateDefBody::Brace { elements } = &state_def.body else {
        panic!("expected state body");
    };
    assert!(
        elements
            .iter()
            .any(|e| matches!(e.value, sysml_v2_parser::ast::StateDefBodyElement::Other(_))),
        "unknown state members should be preserved explicitly instead of truncating the body"
    );
    assert!(
        elements.iter().any(|e| matches!(
            e.value,
            sysml_v2_parser::ast::StateDefBodyElement::Transition(_)
        )),
        "later valid state members should still parse"
    );
}

#[test]
fn test_parse_with_diagnostics_accepts_structured_requirement_attributes() {
    let input = "package P {\nrequirement def R {\nsubject vehicle : Vehicle;\nattribute massActual: MassValue;\nrequire constraint { }\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        result.errors.is_empty(),
        "structured requirement attributes should not produce recovery diagnostics: {:?}",
        result.errors
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_actor_name_in_use_case_body() {
    let input = "package P {\nuse case def U {\nactor: User;\nobjective { }\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing actor name should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("missing_member_name"))
        .expect("expected missing_member_name diagnostic");
    assert_eq!(err.expected.as_deref(), Some("actor name before ':'"));
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("actor user: User;")),
        "diagnostic should show an actor example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_subject_type_in_requirement_body() {
    let input = "package P {\nrequirement def R {\nsubject laptop: ;\nrequire constraint { }\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing subject type should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("missing_type_reference"))
        .expect("expected missing_type_reference diagnostic");
    assert_eq!(err.expected.as_deref(), Some("subject type after ':'"));
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("subject laptop: Laptop;")),
        "diagnostic should show a subject type example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_actor_type_in_use_case_body() {
    let input = "package P {\nuse case def U {\nactor user: ;\nobjective { }\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing actor type should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("missing_type_reference"))
        .expect("expected missing_type_reference diagnostic");
    assert_eq!(err.expected.as_deref(), Some("actor type after ':'"));
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("actor user: User;")),
        "diagnostic should show an actor type example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_state_name_in_state_body() {
    let input = "package P {\nstate def Machine {\nstate: Mode;\ntransition t then Ready;\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing state name should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.expected.as_deref() == Some("state name before ':'"))
        .expect("expected state-name diagnostic");
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("state ready: Mode;")),
        "diagnostic should show a state example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_part_type_in_part_body() {
    let input = "package P {\npart def Vehicle {\npart wheel: ;\nattribute mass: MassValue;\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing part type should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.expected.as_deref() == Some("part type after ':'"))
        .expect("expected part-type diagnostic");
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("part wheel: Wheel;")),
        "diagnostic should show a part type example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_attribute_type_in_part_body() {
    let input = "package P {\npart Vehicle {\nattribute bad : ;\nattribute ok : MassValue;\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing attribute type should produce diagnostics"
    );
    assert!(
        !result.errors.is_empty(),
        "missing attribute type should be reported"
    );
    let pkg = match &result.root.elements[0].value {
        RootElement::Package(p) => &p.value,
        other => panic!("expected package, got {other:?}"),
    };
    let PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected package body");
    };
    let part = elements
        .iter()
        .find_map(|e| match &e.value {
            PackageBodyElement::PartUsage(p) => Some(&p.value),
            _ => None,
        })
        .expect("part usage should survive recovery");
    let PartUsageBody::Brace { elements } = &part.body else {
        panic!("expected part body");
    };
    assert!(
        elements.iter().any(|e| matches!(
            &e.value,
            PartUsageBodyElement::AttributeUsage(a) if a.value.name == "ok"
        )),
        "later attribute sibling should remain parseable"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_occurrence_type_and_keeps_sibling() {
    let input = "package P {\noccurrence bad defined by ;\npart def Good;\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing occurrence type should produce diagnostics"
    );
    assert!(
        !result.errors.is_empty(),
        "missing occurrence type should be reported"
    );
    let pkg = match &result.root.elements[0].value {
        RootElement::Package(p) => &p.value,
        other => panic!("expected package, got {other:?}"),
    };
    let PackageBody::Brace { elements } = &pkg.body else {
        panic!("expected package body");
    };
    assert!(
        elements
            .iter()
            .any(|e| matches!(&e.value, PackageBodyElement::PartDef(p) if p.value.identification.name.as_deref() == Some("Good"))),
        "later package sibling should remain parseable"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_part_name_in_part_body() {
    let input = "package P {\npart def Vehicle {\npart: Wheel;\nattribute mass: MassValue;\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing part name should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.expected.as_deref() == Some("part name before ':'"))
        .expect("expected part-name diagnostic");
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("part wheel: Wheel;")),
        "diagnostic should show a part example fix"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_local_package_recovery() {
    let input = "package P {\n#fmeaspec requirement req1 { }\npart def Good;\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "package-level recovery should surface as diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("unsupported_annotation_syntax"))
        .expect("expected local package recovery diagnostic");
    assert_eq!(err.line, Some(2));
    assert!(
        err.found
            .as_deref()
            .is_some_and(|f| f.contains("#fmeaspec")),
        "diagnostic should preserve recovered snippet"
    );
    assert!(
        err.message.contains("annotation"),
        "annotation recovery should say why the declaration could not be parsed: {}",
        err.message
    );
    assert_eq!(
        err.severity,
        Some(sysml_v2_parser::DiagnosticSeverity::Warning)
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_semicolon_between_package_members() {
    let input = "package P {\npart def A {\nexhibit state s : S\npart b : B;\n}\n}";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "missing semicolon should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("missing_semicolon"))
        .expect("expected missing_semicolon diagnostic");
    assert_eq!(err.expected.as_deref(), Some("';'"));
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("Insert ';'")),
        "diagnostic should include a semicolon suggestion"
    );
}

#[test]
fn test_parse_with_diagnostics_accepts_expose_feature_chain() {
    let input = "package Views { view structure: GeneralView { expose SurveillanceDrone.SurveillanceQuadrotorDrone; } }";
    let result = parse_with_diagnostics(input);
    assert!(
        result.is_ok(),
        "expose feature chains should parse without separator diagnostics: {:?}",
        result.errors
    );
    assert!(
        !result
            .errors
            .iter()
            .any(|e| e.code.as_deref() == Some("invalid_qualified_name_separator")),
        "feature-chain expose should not emit invalid_qualified_name_separator: {:?}",
        result.errors
    );
}

#[test]
fn test_parse_with_diagnostics_reports_illegal_top_level_part_definition() {
    let input = "part def TopLevel;";
    let result = parse_with_diagnostics(input);
    assert!(!result.is_ok(), "top-level part def should fail");
    let err = &result.errors[0];
    assert_eq!(err.code.as_deref(), Some("illegal_top_level_definition"));
    assert!(
        err.message.contains("illegal top-level"),
        "message should describe illegal top-level declaration"
    );
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("package") && s.contains("namespace")),
        "diagnostic should suggest wrapping in package or namespace"
    );
}

#[test]
fn test_parse_reports_missing_closing_brace_for_unterminated_package() {
    let input = "package P {\npart def A;\n";
    let err = parse(input).expect_err("unterminated package should fail");
    assert_eq!(err.code.as_deref(), Some("missing_closing_brace"));
    assert_eq!(err.expected.as_deref(), Some("'}'"));
    assert!(
        err.suggestion
            .as_deref()
            .is_some_and(|s| s.contains("Add '}'")),
        "missing brace diagnostic should suggest how to close the body"
    );
}

#[test]
fn test_parse_with_diagnostics_reports_missing_closing_brace_for_unterminated_package() {
    let input = "package P {\npart def A;\n";
    let result = parse_with_diagnostics(input);
    assert!(
        !result.is_ok(),
        "unterminated package should produce diagnostics"
    );
    let err = result
        .errors
        .iter()
        .find(|e| e.code.as_deref() == Some("missing_closing_brace"))
        .expect("expected missing closing brace diagnostic");
    assert_eq!(err.expected.as_deref(), Some("'}'"));
}

#[test]
fn test_parse_reports_illegal_top_level_part_definition() {
    let input = "part def TopLevel;";
    let err = parse(input).expect_err("top-level part def should fail");
    assert_eq!(err.code.as_deref(), Some("illegal_top_level_definition"));
    assert_eq!(
        err.expected.as_deref(),
        Some("'package', 'namespace', or 'import'")
    );
}

#[test]
fn test_invalid_input_corpus_is_handled_gracefully() {
    let invalid_inputs = [
        "package P {",
        "package P { part def A {",
        "package P { @@@ ??? }",
        "package P { /* unterminated",
        "namespace N { part def X { ;;; }",
        "part def TopLevel;",
    ];

    for input in invalid_inputs {
        let strict = std::panic::catch_unwind(|| {
            let _ = parse(input).is_ok();
        });
        assert!(strict.is_ok(), "parse should not panic for {:?}", input);

        let recovered = std::panic::catch_unwind(|| parse_with_diagnostics(input));
        assert!(
            recovered.is_ok(),
            "parse_with_diagnostics should not panic for {:?}",
            input
        );
    }
}