1use crate::assertions::strip_assertion_comments;
5use crate::ast::{DocumentMetadata, FileMeta, GctfDocument, Section, SectionContent, SectionType};
6use crate::gctf_tokenizer;
7use apif_diagnostics::{DiagnosticCode, DiagnosticCollection, Range};
8use std::path::Path;
9
10pub struct ErrorRecoveryResult {
12 pub document: GctfDocument,
13 pub diagnostics: DiagnosticCollection,
14 pub recovered_sections: usize,
15 pub failed_sections: usize,
16}
17
18pub fn parse_with_recovery(file_path: &Path) -> ErrorRecoveryResult {
21 let content = std::fs::read_to_string(file_path).unwrap_or_default();
22 parse_content_with_recovery(&content, file_path.to_string_lossy().as_ref())
23}
24
25pub fn parse_content_with_recovery(content: &str, file_path: &str) -> ErrorRecoveryResult {
29 let single = parse_single_with_recovery(content, file_path);
30
31 let docs = crate::split_sections_by_boundary(&single.document.sections);
33
34 if docs.len() <= 1 {
35 return single;
36 }
37
38 let mut head: Option<GctfDocument> = None;
40 let total_recovered = single.recovered_sections;
41 let total_failed = single.failed_sections;
42
43 for doc_sections in docs.into_iter().rev() {
44 let mut doc = build_doc_from_sections(&doc_sections, file_path);
45 doc.next_document = head.map(Box::new);
46 head = Some(doc);
47 }
48
49 ErrorRecoveryResult {
50 document: head.unwrap_or(single.document),
51 diagnostics: single.diagnostics,
52 recovered_sections: total_recovered,
53 failed_sections: total_failed,
54 }
55}
56
57fn build_doc_from_sections(sections: &[Section], file_path: &str) -> GctfDocument {
58 GctfDocument {
59 file_path: file_path.to_string(),
60 sections: sections.to_vec(),
61 metadata: DocumentMetadata {
62 source: None,
63 mtime: None,
64 parsed_at: 0,
65 ..Default::default()
66 },
67 next_document: None,
68 }
69}
70
71fn parse_single_with_recovery(content: &str, file_path: &str) -> ErrorRecoveryResult {
73 let mut diagnostics = DiagnosticCollection::new();
74 let mut sections = Vec::new();
75 let mut recovered_sections = 0;
76 let failed_sections = 0;
77
78 let lines: Vec<&str> = content.lines().collect();
79 let mut current_line = 0;
80
81 let mut pending_attributes: Vec<crate::ast::GctfAttribute> = Vec::new();
82
83 while current_line < lines.len() {
84 match parse_section(
85 &lines,
86 current_line,
87 &mut diagnostics,
88 &mut pending_attributes,
89 ) {
90 Ok((section, end_line)) => {
91 sections.push(section);
92 recovered_sections += 1;
93 current_line = end_line;
94 }
95 Err(end_line) => {
96 current_line = end_line;
97 }
98 }
99 }
100
101 let document = GctfDocument {
102 file_path: file_path.to_string(),
103 sections,
104 metadata: DocumentMetadata {
105 source: Some(content.to_string()),
106 mtime: None,
107 parsed_at: 0,
108 ..Default::default()
109 },
110 next_document: None,
111 };
112
113 ErrorRecoveryResult {
114 document,
115 diagnostics,
116 recovered_sections,
117 failed_sections,
118 }
119}
120
121fn parse_section(
123 lines: &[&str],
124 start_line: usize,
125 diagnostics: &mut DiagnosticCollection,
126 pending_attributes: &mut Vec<crate::ast::GctfAttribute>,
127) -> Result<(Section, usize), usize> {
128 let line = lines.get(start_line).copied().unwrap_or("");
129 let trimmed = line.trim();
130
131 if trimmed.is_empty() || trimmed.starts_with("//") {
132 return Err(start_line + 1);
133 }
134
135 if trimmed.starts_with("#[") && trimmed.ends_with(']') {
136 let inner = &trimmed[2..trimmed.len() - 1];
137 if let Some(attr) = crate::content_parser::parse_attribute(inner) {
138 pending_attributes.push(attr);
139 }
140 return Err(start_line + 1);
141 }
142
143 if !trimmed.starts_with("---") {
144 return Err(start_line + 1);
145 }
146
147 let (section_type, inline_options) =
148 match parse_section_header(trimmed, start_line, diagnostics) {
149 Some(t) => t,
150 None => return Err(start_line + 1),
151 };
152
153 let content_start = start_line + 1;
154 let (content, content_end) = extract_section_content(lines, content_start, section_type);
155
156 let content_result = parse_section_content(&content, content_start, section_type, diagnostics);
157
158 let section = Section {
159 section_type,
160 content: content_result,
161 inline_options,
162 raw_content: content.join("\n"),
163 start_line,
164 end_line: content_end,
165 attributes: std::mem::take(pending_attributes),
166 };
167
168 Ok((section, content_end + 1))
169}
170
171fn parse_section_header(
173 line: &str,
174 line_num: usize,
175 diagnostics: &mut DiagnosticCollection,
176) -> Option<(SectionType, crate::ast::InlineOptions)> {
177 let without_delimiters = line.trim_start_matches('-').trim_end_matches('-').trim();
179
180 let (section_name, inline_opts_str) = without_delimiters
182 .split_once(' ')
183 .map_or((without_delimiters, ""), |(name, opts)| (name, opts));
184 let section_name = section_name.trim();
185 let inline_opts_str = inline_opts_str.trim();
186
187 let section_type = match section_name.to_uppercase().as_str() {
189 "ADDRESS" => SectionType::Address,
190 "ENDPOINT" => SectionType::Endpoint,
191 "REQUEST" => SectionType::Request,
192 "RESPONSE" => SectionType::Response,
193 "ERROR" => SectionType::Error,
194 "EXTRACT" => SectionType::Extract,
195 "ASSERTS" => SectionType::Asserts,
196 "REQUEST_HEADERS" => SectionType::RequestHeaders,
197 "HEADERS" => {
198 diagnostics.warning(
199 DiagnosticCode::DeprecatedSymbol,
200 "HEADERS is deprecated, use REQUEST_HEADERS".to_string(),
201 Range::at_line(line_num),
202 );
203 SectionType::RequestHeaders
204 }
205 "TLS" => SectionType::Tls,
206 "PROTO" => SectionType::Proto,
207 "OPTIONS" => SectionType::Options,
208 "META" => SectionType::Meta,
209 "BENCH" => SectionType::Bench,
210 _ => {
211 diagnostics.warning(
212 DiagnosticCode::UnknownSectionType,
213 format!("Unknown section type: {}", section_name),
214 Range::at_line(line_num),
215 );
216 return None;
217 }
218 };
219
220 let has_opts = !inline_opts_str.is_empty();
222 let inline_options = if has_opts && section_type.supports_inline_options() {
223 match crate::content_parser::parse_inline_options(inline_opts_str) {
224 Ok(opts) => opts,
225 Err(_) => {
226 parse_inline_options_diagnostic(inline_opts_str, line_num, diagnostics);
227 Default::default()
228 }
229 }
230 } else {
231 if has_opts {
232 parse_inline_options_diagnostic(inline_opts_str, line_num, diagnostics);
233 }
234 Default::default()
235 };
236
237 Some((section_type, inline_options))
238}
239
240fn extract_section_content(
242 lines: &[&str],
243 start: usize,
244 _section_type: SectionType,
245) -> (Vec<String>, usize) {
246 let mut content = Vec::new();
247 let mut end_line = start;
248
249 for (i, line) in lines.iter().enumerate().skip(start) {
250 let trimmed = line.trim();
251
252 if trimmed.starts_with("---") && trimmed.ends_with("---") {
254 break;
255 }
256
257 if trimmed.starts_with("#[") && trimmed.ends_with(']') {
259 continue;
260 }
261
262 content.push(line.to_string());
263 end_line = i;
264 }
265
266 (content, end_line)
267}
268
269fn parse_section_content(
271 content: &[String],
272 start_line: usize,
273 section_type: SectionType,
274 diagnostics: &mut DiagnosticCollection,
275) -> SectionContent {
276 let content_str = content.join("\n");
277
278 match section_type {
279 SectionType::Address | SectionType::Endpoint => {
280 SectionContent::Single(content_str.trim().to_string())
281 }
282 SectionType::Request | SectionType::Response | SectionType::Error => {
283 if content_str.trim().is_empty() {
284 SectionContent::Empty
285 } else {
286 match crate::json_mod::from_str(&content_str) {
288 Ok(value) => SectionContent::Json(value),
289 Err(e) => {
290 diagnostics.error(
292 DiagnosticCode::JsonParseError,
293 format!("Failed to parse JSON: {}", e),
294 Range::at_line(start_line),
295 );
296 SectionContent::Json(serde_json::Value::String(content_str))
298 }
299 }
300 }
301 }
302 SectionType::Extract => {
303 let mut extractions = std::collections::HashMap::new();
305 for (i, line) in content.iter().enumerate() {
306 let trimmed = line.trim();
307 if trimmed.is_empty() || trimmed.starts_with('#') {
308 continue;
309 }
310
311 if let Some(eq_pos) = trimmed.find('=') {
312 let name = trimmed[..eq_pos].trim().to_string();
313 let query = trimmed[eq_pos + 1..].trim().to_string();
314 extractions.insert(name, query);
315 } else {
316 diagnostics.warning(
317 DiagnosticCode::InvalidSyntax,
318 "Invalid EXTRACT syntax, expected: name = query",
319 Range::at_line(start_line + i),
320 );
321 }
322 }
323 SectionContent::Extract(extractions)
324 }
325 SectionType::Asserts => {
326 let assertions: Vec<String> = content
328 .iter()
329 .filter_map(|line| strip_assertion_comments(line))
330 .collect();
331 SectionContent::Assertions(assertions)
332 }
333 SectionType::RequestHeaders
334 | SectionType::Tls
335 | SectionType::Proto
336 | SectionType::Options
337 | SectionType::Bench => {
338 let mut key_values = std::collections::HashMap::new();
340 for (i, line) in content.iter().enumerate() {
341 let trimmed = line.trim();
342 if trimmed.is_empty() || trimmed.starts_with('#') {
343 continue;
344 }
345
346 if let Some(colon_pos) = trimmed.find(':') {
347 let key = trimmed[..colon_pos].trim().to_string();
348 let value = trimmed[colon_pos + 1..].trim().to_string();
349 key_values.insert(key, value);
350 } else {
351 diagnostics.warning(
352 DiagnosticCode::InvalidSyntax,
353 "Invalid key-value syntax, expected: key: value",
354 Range::at_line(start_line + i),
355 );
356 }
357 }
358 SectionContent::KeyValues(key_values)
359 }
360 SectionType::Meta => {
361 let raw = content.join("\n");
363 let tokens = gctf_tokenizer::tokenize_gctf(&raw);
364 let yaml_lines: Vec<String> = content
365 .iter()
366 .zip(tokens.iter())
367 .filter(|(_, t)| !matches!(t.kind, gctf_tokenizer::GctfTokenKind::Comment(_)))
368 .map(|(l, _)| l.clone())
369 .collect();
370 let cleaned = yaml_lines.join("\n");
371 let meta = serde_yaml_ng::from_str::<FileMeta>(&cleaned).unwrap_or_default();
372 SectionContent::Meta(meta)
373 }
374 }
375}
376
377fn parse_inline_options_diagnostic(
379 options_str: &str,
380 line_num: usize,
381 diagnostics: &mut DiagnosticCollection,
382) {
383 for option in options_str.split_whitespace() {
385 if let Some(eq_pos) = option.find('=') {
386 let key = &option[..eq_pos];
387 let value = &option[eq_pos + 1..];
388
389 match key {
390 "with_asserts" | "unordered_arrays" | "partial" => {
391 if value != "true" && value != "false" {
392 diagnostics.warning(
393 DiagnosticCode::InvalidFieldValue,
394 format!("Invalid boolean value for {}: {}", key, value),
395 Range::at_line(line_num),
396 );
397 }
398 }
399 "tolerance" => {
400 if value.parse::<f64>().is_err() {
401 diagnostics.warning(
402 DiagnosticCode::InvalidFieldValue,
403 format!("Invalid numeric value for {}: {}", key, value),
404 Range::at_line(line_num),
405 );
406 }
407 }
408 "redact" => {}
409 _ => {
410 diagnostics.hint(
411 DiagnosticCode::InvalidFieldValue,
412 format!("Unknown inline option: {}", key),
413 Range::at_line(line_num),
414 );
415 }
416 }
417 }
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_parse_with_recovery_valid_file() {
427 let content = r#"--- ENDPOINT ---
428service/Method
429
430--- REQUEST ---
431{"key": "value"}
432
433--- RESPONSE ---
434{"result": "ok"}
435"#;
436
437 let result = parse_content_with_recovery(content, "test.gctf");
438
439 assert_eq!(result.recovered_sections, 3);
440 assert_eq!(result.failed_sections, 0);
441 assert!(!result.document.sections.is_empty());
442 }
443
444 #[test]
445 fn test_parse_with_recovery_invalid_json() {
446 let content = r#"--- ENDPOINT ---
447service/Method
448
449--- REQUEST ---
450{"key": "value"
451
452--- RESPONSE ---
453{"result": "ok"}
454"#;
455
456 let result = parse_content_with_recovery(content, "test.gctf");
457
458 assert_eq!(result.recovered_sections, 3);
460 assert_eq!(result.failed_sections, 0);
461 assert!(result.diagnostics.has_errors());
463 }
464
465 #[test]
466 fn test_parse_with_recovery_multiple_errors() {
467 let content = r#"--- ENDPOINT ---
468service/Method
469
470--- REQUEST ---
471{invalid json
472
473--- RESPONSE ---
474{also invalid
475
476--- EXTRACT ---
477var = .field
478"#;
479
480 let result = parse_content_with_recovery(content, "test.gctf");
481
482 assert_eq!(result.recovered_sections, 4);
484 assert!(result.diagnostics.diagnostics.len() >= 2);
486 }
487
488 #[test]
489 fn test_parse_with_recovery_unknown_section() {
490 let content = r#"--- ENDPOINT ---
491service/Method
492
493--- UNKNOWN_SECTION ---
494content
495
496--- RESPONSE ---
497{"ok": true}
498"#;
499
500 let result = parse_content_with_recovery(content, "test.gctf");
501
502 assert!(result.diagnostics.has_warnings());
504 }
505
506 #[test]
507 fn test_parse_with_recovery_invalid_extract() {
508 let content = r#"--- EXTRACT ---
509valid = .field
510invalid line without equals
511another = .field2
512"#;
513
514 let result = parse_content_with_recovery(content, "test.gctf");
515
516 assert!(result.diagnostics.has_warnings());
518 }
519
520 #[test]
521 fn test_parse_with_recovery_asserts_double_slash_comments() {
522 let content = r#"--- ENDPOINT ---
523grpc.health.v1.Health/Watch
524
525--- REQUEST ---
526{"service": "examples.health.watch"}
527
528--- ASSERTS ---
529// Watch delay in stubs.yaml is 10ms.
530// Delay applies before the first message in the scope.
531@scope.message_count() == 2
532@elapsed_ms() >= 10
533@total_elapsed_ms() >= 10
534"#;
535
536 let result = parse_content_with_recovery(content, "test.gctf");
537 let asserts = result
538 .document
539 .sections
540 .iter()
541 .find(|s| s.section_type == SectionType::Asserts)
542 .expect("ASSERTS section should be parsed");
543
544 if let SectionContent::Assertions(lines) = &asserts.content {
545 assert_eq!(lines.len(), 3);
546 assert_eq!(lines[0], "@scope.message_count() == 2");
547 assert_eq!(lines[1], "@elapsed_ms() >= 10");
548 assert_eq!(lines[2], "@total_elapsed_ms() >= 10");
549 } else {
550 panic!("expected assertions content");
551 }
552 }
553
554 #[test]
555 fn test_parse_with_recovery_asserts_inline_comments() {
556 let content = r#"--- ENDPOINT ---
557grpc.health.v1.Health/Watch
558
559--- REQUEST ---
560{"service": "examples.health.watch"}
561
562--- ASSERTS ---
563@scope.message_count() == 2 // exactly two updates expected
564@elapsed_ms() >= 10 # startup delay should be applied
565@regex(.note, "^https://example.com")
566"#;
567
568 let result = parse_content_with_recovery(content, "test.gctf");
569 let asserts = result
570 .document
571 .sections
572 .iter()
573 .find(|s| s.section_type == SectionType::Asserts)
574 .expect("ASSERTS section should be parsed");
575
576 if let SectionContent::Assertions(lines) = &asserts.content {
577 assert_eq!(lines.len(), 3);
578 assert_eq!(lines[0], "@scope.message_count() == 2");
579 assert_eq!(lines[1], "@elapsed_ms() >= 10");
580 assert_eq!(lines[2], "@regex(.note, \"^https://example.com\")");
581 } else {
582 panic!("expected assertions content");
583 }
584 }
585
586 #[test]
587 fn test_parse_with_recovery_headers_deprecated() {
588 let content = r#"--- ENDPOINT ---
589svc/Method
590
591--- HEADERS ---
592content-type: application/grpc
593
594--- REQUEST ---
595{}
596
597--- RESPONSE ---
598{}
599"#;
600 let result = parse_content_with_recovery(content, "test.gctf");
601 assert!(result.diagnostics.has_warnings());
602 assert_eq!(result.recovered_sections, 4);
604 }
605
606 #[test]
607 fn test_parse_with_recovery_tls_section_key_values() {
608 let content = r#"--- TLS ---
609enabled: true
610cert_path: /path/to/cert
611
612--- ENDPOINT ---
613svc/Method
614
615--- REQUEST ---
616{}
617
618--- RESPONSE ---
619{}
620"#;
621 let result = parse_content_with_recovery(content, "test.gctf");
622 assert_eq!(result.recovered_sections, 4);
623 let tls = result
624 .document
625 .sections
626 .iter()
627 .find(|s| s.section_type == SectionType::Tls)
628 .expect("TLS section should be parsed");
629 if let SectionContent::KeyValues(kvs) = &tls.content {
630 assert_eq!(kvs.get("enabled"), Some(&"true".to_string()));
631 assert_eq!(kvs.get("cert_path"), Some(&"/path/to/cert".to_string()));
632 } else {
633 panic!("expected key-values content");
634 }
635 }
636
637 #[test]
638 fn test_parse_with_recovery_options_section() {
639 let content = r#"--- OPTIONS ---
640timeout: 5000
641retries: 3
642
643--- ENDPOINT ---
644svc/Method
645
646--- REQUEST ---
647{}
648
649--- RESPONSE ---
650{}
651"#;
652 let result = parse_content_with_recovery(content, "test.gctf");
653 assert_eq!(result.recovered_sections, 4);
654 let opts = result
655 .document
656 .sections
657 .iter()
658 .find(|s| s.section_type == SectionType::Options)
659 .expect("OPTIONS section should be parsed");
660 if let SectionContent::KeyValues(kvs) = &opts.content {
661 assert_eq!(kvs.get("timeout"), Some(&"5000".to_string()));
662 assert_eq!(kvs.get("retries"), Some(&"3".to_string()));
663 } else {
664 panic!("expected key-values content");
665 }
666 }
667
668 #[test]
669 fn test_parse_with_recovery_proto_section() {
670 let content = r#"--- PROTO ---
671protos: ["service.proto"]
672import_dirs: ["/protos"]
673
674--- ENDPOINT ---
675svc/Method
676
677--- REQUEST ---
678{}
679
680--- RESPONSE ---
681{}
682"#;
683 let result = parse_content_with_recovery(content, "test.gctf");
684 assert_eq!(result.recovered_sections, 4);
685 }
686
687 #[test]
688 fn test_parse_with_recovery_empty_response() {
689 let content = r#"--- ENDPOINT ---
690svc/Method
691
692--- REQUEST ---
693{}
694
695--- RESPONSE ---
696
697"#;
698 let result = parse_content_with_recovery(content, "test.gctf");
699 assert_eq!(result.recovered_sections, 3);
700 let response = result
701 .document
702 .sections
703 .iter()
704 .find(|s| s.section_type == SectionType::Response)
705 .expect("RESPONSE section should exist");
706 assert!(matches!(response.content, SectionContent::Empty));
707 }
708
709 #[test]
710 fn test_parse_with_recovery_non_section_header_lines() {
711 let content = r#"some random line
712more text
713--- ENDPOINT ---
714svc/Method
715
716--- REQUEST ---
717{}
718
719--- RESPONSE ---
720{}
721"#;
722 let result = parse_content_with_recovery(content, "test.gctf");
723 assert_eq!(result.recovered_sections, 3);
725 }
726
727 #[test]
728 fn test_parse_with_recovery_comment_lines() {
729 let content = r#"# This is a comment
730// Another comment
731
732--- ENDPOINT ---
733svc/Method
734
735--- REQUEST ---
736{}
737
738--- RESPONSE ---
739{}
740"#;
741 let result = parse_content_with_recovery(content, "test.gctf");
742 assert_eq!(result.recovered_sections, 3);
743 }
744
745 #[test]
746 fn test_parse_with_recovery_inline_options_invalid_boolean() {
747 let content = r#"--- ENDPOINT with_asserts=maybe ---
748svc/Method
749
750--- REQUEST ---
751{}
752
753--- RESPONSE ---
754{}
755"#;
756 let result = parse_content_with_recovery(content, "test.gctf");
757 assert!(result.diagnostics.has_warnings());
758 assert_eq!(result.recovered_sections, 3);
759 }
760
761 #[test]
762 fn test_parse_with_recovery_inline_options_invalid_numeric() {
763 let content = r#"--- ENDPOINT tolerance=abc ---
764svc/Method
765
766--- REQUEST ---
767{}
768
769--- RESPONSE ---
770{}
771"#;
772 let result = parse_content_with_recovery(content, "test.gctf");
773 assert!(result.diagnostics.has_warnings());
774 }
775
776 #[test]
777 fn test_parse_with_recovery_inline_options_unknown() {
778 let content = r#"--- ENDPOINT unknown_option=value ---
779svc/Method
780
781--- REQUEST ---
782{}
783
784--- RESPONSE ---
785{}
786"#;
787 let result = parse_content_with_recovery(content, "test.gctf");
788 assert_eq!(result.recovered_sections, 3);
790 }
791
792 #[test]
793 fn test_parse_with_recovery_inline_options_valid() {
794 let content = r#"--- ENDPOINT with_asserts=true unordered_arrays=true partial=false tolerance=0.05 ---
795svc/Method
796
797--- REQUEST ---
798{}
799
800--- RESPONSE ---
801{}
802"#;
803 let result = parse_content_with_recovery(content, "test.gctf");
804 assert_eq!(result.recovered_sections, 3);
805 }
806
807 #[test]
808 fn test_parse_with_recovery_request_headers_section() {
809 let content = r#"--- ENDPOINT ---
810svc/Method
811
812--- REQUEST_HEADERS ---
813authorization: Bearer token
814x-custom: value
815
816--- REQUEST ---
817{}
818
819--- RESPONSE ---
820{}
821"#;
822 let result = parse_content_with_recovery(content, "test.gctf");
823 assert_eq!(result.recovered_sections, 4);
824 let headers = result
825 .document
826 .sections
827 .iter()
828 .find(|s| s.section_type == SectionType::RequestHeaders)
829 .expect("REQUEST_HEADERS section should be parsed");
830 if let SectionContent::KeyValues(kvs) = &headers.content {
831 assert_eq!(kvs.get("authorization"), Some(&"Bearer token".to_string()));
832 assert_eq!(kvs.get("x-custom"), Some(&"value".to_string()));
833 } else {
834 panic!("expected key-values content");
835 }
836 }
837
838 #[test]
839 fn test_parse_with_recovery_invalid_key_value_syntax() {
840 let content = r#"--- TLS ---
841enabled: true
842invalid line without colon
843
844--- ENDPOINT ---
845svc/Method
846
847--- REQUEST ---
848{}
849
850--- RESPONSE ---
851{}
852"#;
853 let result = parse_content_with_recovery(content, "test.gctf");
854 assert!(result.diagnostics.has_warnings());
855 assert_eq!(result.recovered_sections, 4);
856 }
857}