• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 24368153097

13 Apr 2026 09:36PM UTC coverage: 75.096% (-0.3%) from 75.445%
24368153097

Pull #35

github

web-flow
Merge 97a02fd78 into 4ba0f08f1
Pull Request #35: feat: meta section & refactoring

2518 of 3592 new or added lines in 47 files covered. (70.1%)

155 existing lines in 9 files now uncovered.

16781 of 22346 relevant lines covered (75.1%)

2495.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.33
/src/parser/core.rs
1
// GCTF file parser - converts .gctf text to AST
2
// Handles section extraction, comment removal, and inline option parsing
3

4
use super::ast::*;
5
use super::content_parser;
6
use super::gctf_tokenizer::{GctfTokenKind, tokenize_gctf};
7
use super::split_sections_by_boundary;
8
use anyhow::{Context, Result};
9
use serde::Serialize;
10
use std::collections::HashMap;
11
use std::fs;
12
use std::path::Path;
13
use std::time::Instant;
14

15
/// Parse a .gctf file into an AST
16
pub fn parse_gctf(file_path: &Path) -> Result<GctfDocument> {
61✔
17
    let (document, _) = parse_gctf_with_diagnostics(file_path)?;
61✔
18
    Ok(document)
61✔
19
}
61✔
20

21
/// Parse .gctf content from string (for LSP/editor use).
22
/// Documents are determined implicitly: REQUEST after RESPONSE/ERROR/ASSERTS,
23
/// or ENDPOINT/ADDRESS starts a new document.
24
pub fn parse_gctf_from_str(content: &str, file_path: &str) -> Result<GctfDocument> {
11,353✔
25
    let (all_sections, _) = parse_sections_from_str(content)?;
11,353✔
26

27
    // Split sections into documents based on implicit boundaries
28
    let documents = split_sections_by_boundary(&all_sections);
11,353✔
29

30
    if documents.is_empty() {
11,353✔
31
        // Return empty single document for backward compatibility
32
        let mut document = GctfDocument::new(file_path.to_string());
1✔
33
        document.metadata.source = Some(content.to_string());
1✔
34
        return Ok(document);
1✔
35
    }
11,352✔
36

37
    // Build chain in reverse order
38
    let mut head: Option<GctfDocument> = None;
11,352✔
39

40
    for doc_sections in documents.into_iter().rev() {
40,445✔
41
        let mut document = GctfDocument::new(file_path.to_string());
40,445✔
42
        document.metadata.source = Some(extract_doc_source(&doc_sections, content));
40,445✔
43
        document.sections = doc_sections;
40,445✔
44
        document.next_document = head.map(Box::new);
40,445✔
45
        head = Some(document);
40,445✔
46
    }
40,445✔
47

48
    head.ok_or_else(|| anyhow::anyhow!("No documents parsed"))
11,352✔
49
}
11,353✔
50

51
/// Split sections into documents based on implicit boundaries.
52
///
53
/// Extract source lines for a document from the original content.
54
fn extract_doc_source(sections: &[Section], original: &str) -> String {
40,447✔
55
    if sections.is_empty() {
40,447✔
56
        return String::new();
1✔
57
    }
40,446✔
58
    let start = sections.first().unwrap().start_line;
40,446✔
59
    let end = sections.last().unwrap().end_line;
40,446✔
60
    original
40,446✔
61
        .lines()
40,446✔
62
        .skip(start)
40,446✔
63
        .take(end.saturating_sub(start))
40,446✔
64
        .collect::<Vec<_>>()
40,446✔
65
        .join("\n")
40,446✔
66
}
40,447✔
67

68
#[derive(Debug, Clone, Serialize, Default)]
69
pub struct ParseTimings {
70
    pub read_ms: f64,
71
    pub parse_sections_ms: f64,
72
    pub build_document_ms: f64,
73
    pub total_ms: f64,
74
}
75

76
#[derive(Debug, Clone, Serialize, Default)]
77
pub struct ParseDiagnostics {
78
    pub file_path: String,
79
    pub bytes: usize,
80
    pub total_lines: usize,
81
    pub section_headers: usize,
82
    pub section_counts: HashMap<String, usize>,
83
    pub timings: ParseTimings,
84
}
85

86
/// Parse .gctf and return AST + diagnostics useful for inspect/debug.
87
/// Supports multiple documents with implicit boundaries (ENDPOINT after terminal section).
88
pub fn parse_gctf_with_diagnostics(file_path: &Path) -> Result<(GctfDocument, ParseDiagnostics)> {
67✔
89
    let total_start = Instant::now();
67✔
90

91
    let read_start = Instant::now();
67✔
92
    let source = fs::read_to_string(file_path)
67✔
93
        .with_context(|| format!("Failed to read file: {}", file_path.display()))?;
67✔
94
    let read_ms = read_start.elapsed().as_secs_f64() * 1000.0;
66✔
95

96
    let parse_sections_start = Instant::now();
66✔
97
    let (sections, section_headers) = parse_sections_from_str(&source)?;
66✔
98
    let parse_sections_ms = parse_sections_start.elapsed().as_secs_f64() * 1000.0;
66✔
99

100
    // Split into documents using implicit boundaries
101
    let documents = split_sections_by_boundary(&sections);
66✔
102

103
    let build_start = Instant::now();
66✔
104
    // Build chain — return head document
105
    let mut head: Option<GctfDocument> = None;
66✔
106
    for doc_sections in documents.into_iter().rev() {
66✔
107
        let mut document = GctfDocument::new(file_path.display().to_string());
66✔
108
        document.metadata.source = Some(source.clone());
66✔
109
        document.sections = doc_sections;
66✔
110
        document.next_document = head.map(Box::new);
66✔
111
        head = Some(document);
66✔
112
    }
66✔
113

114
    let document = head.unwrap_or_else(|| {
66✔
115
        let mut doc = GctfDocument::new(file_path.display().to_string());
×
116
        doc.metadata.source = Some(source.clone());
×
117
        doc
×
118
    });
×
119
    let build_ms = build_start.elapsed().as_secs_f64() * 1000.0;
66✔
120
    let total_ms = total_start.elapsed().as_secs_f64() * 1000.0;
66✔
121

122
    let mut section_counts: HashMap<String, usize> = HashMap::new();
66✔
123
    for d in document.iter_chain() {
66✔
124
        for section in &d.sections {
255✔
125
            *section_counts
255✔
126
                .entry(section.section_type.as_str().to_string())
255✔
127
                .or_insert(0) += 1;
255✔
128
        }
255✔
129
    }
130

131
    let diagnostics = ParseDiagnostics {
66✔
132
        file_path: file_path.display().to_string(),
66✔
133
        bytes: source.len(),
66✔
134
        total_lines: source.lines().count(),
66✔
135
        section_headers,
66✔
136
        section_counts,
66✔
137
        timings: ParseTimings {
66✔
138
            read_ms,
66✔
139
            parse_sections_ms,
66✔
140
            build_document_ms: build_ms,
66✔
141
            total_ms,
66✔
142
        },
66✔
143
    };
66✔
144

145
    Ok((document, diagnostics))
66✔
146
}
67✔
147

148
fn parse_sections_from_str(source: &str) -> Result<(Vec<Section>, usize)> {
11,424✔
149
    let tokens = tokenize_gctf(source);
11,424✔
150
    let mut sections = Vec::new();
11,424✔
151
    let mut section_headers = 0;
11,424✔
152
    let mut current_section: Option<(SectionType, usize, Vec<String>, InlineOptions)> = None;
11,424✔
153

154
    for token in &tokens {
367,057✔
155
        match &token.kind {
367,057✔
156
            GctfTokenKind::SectionHeader { name, raw_options } => {
132,526✔
157
                if let Some((section_type, start_line, content, options)) = current_section.take() {
132,526✔
158
                    let section = content_parser::build_section(
121,103✔
159
                        section_type,
121,103✔
160
                        start_line,
121,103✔
161
                        token.line,
121,103✔
162
                        &content,
121,103✔
163
                        options,
121,103✔
NEW
164
                    )?;
×
165
                    sections.push(section);
121,103✔
166
                }
11,423✔
167

168
                section_headers += 1;
132,526✔
169

170
                if let Some(section_type) = SectionType::from_keyword(name) {
132,526✔
171
                    let inline_options =
132,525✔
172
                        if section_type.supports_inline_options() && !raw_options.is_empty() {
132,525✔
173
                            content_parser::parse_inline_options(raw_options)?
60✔
174
                        } else {
175
                            InlineOptions::default()
132,465✔
176
                        };
177
                    current_section = Some((section_type, token.line, Vec::new(), inline_options));
132,525✔
178
                } else {
179
                    return Err(anyhow::anyhow!("Unknown section type: {}", name));
1✔
180
                }
181
            }
182
            GctfTokenKind::Comment(text) | GctfTokenKind::Content(text) => {
133,336✔
183
                if let Some((_, _, ref mut content, _)) = current_section {
133,397✔
184
                    content.push(text.clone());
133,377✔
185
                }
133,377✔
186
            }
187
            GctfTokenKind::Blank => {
188
                if let Some((_, _, ref mut content, _)) = current_section {
101,134✔
189
                    content.push(String::new());
101,082✔
190
                }
101,082✔
191
            }
192
        }
193
    }
194

195
    if let Some((section_type, start_line, content, options)) = current_section {
11,423✔
196
        let end_line = source.lines().count();
11,422✔
197
        let section =
11,422✔
198
            content_parser::build_section(section_type, start_line, end_line, &content, options)?;
11,422✔
199
        sections.push(section);
11,422✔
200
    }
1✔
201

202
    Ok((sections, section_headers))
11,423✔
203
}
11,424✔
204

205
#[cfg(test)]
206
mod tests {
207
    use super::*;
208

209
    #[test]
210
    fn test_parse_sections_basic() {
1✔
211
        let input = "\
1✔
212
--- ENDPOINT ---
1✔
213
test.Service/Method
1✔
214

1✔
215
--- REQUEST ---
1✔
216
{}
1✔
217

1✔
218
--- RESPONSE ---
1✔
219
{}
1✔
220
";
1✔
221
        let (sections, count) = parse_sections_from_str(input).unwrap();
1✔
222
        assert_eq!(count, 3);
1✔
223
        assert_eq!(sections.len(), 3);
1✔
224
    }
1✔
225

226
    #[test]
227
    fn test_section_header_tokenizer() {
1✔
228
        let input = "\
1✔
229
--- ENDPOINT ---
1✔
230
test.Service/Method
1✔
231

1✔
232
--- REQUEST ---
1✔
233
{}
1✔
234

1✔
235
--- RESPONSE partial=true ---
1✔
236
{}
1✔
237
";
1✔
238
        let (sections, count) = parse_sections_from_str(input).unwrap();
1✔
239
        assert_eq!(count, 3);
1✔
240
        assert_eq!(sections.len(), 3);
1✔
241

242
        let resp = sections
1✔
243
            .iter()
1✔
244
            .find(|s| s.section_type == SectionType::Response)
3✔
245
            .unwrap();
1✔
246
        assert!(resp.inline_options.partial);
1✔
247
    }
1✔
248

249
    #[test]
250
    fn test_parse_multi_document() {
1✔
251
        let input = "\
1✔
252
--- ENDPOINT ---
1✔
253
test.Service/Method
1✔
254

1✔
255
--- REQUEST ---
1✔
256
{}
1✔
257

1✔
258
--- RESPONSE ---
1✔
259
{}
1✔
260

1✔
261
--- ENDPOINT ---
1✔
262
test.Service/Method2
1✔
263

1✔
264
--- REQUEST ---
1✔
265
{\"a\": 1}
1✔
266

1✔
267
--- RESPONSE ---
1✔
268
{\"b\": 2}
1✔
269
";
1✔
270
        let doc = parse_gctf_from_str(input, "test.gctf").unwrap();
1✔
271
        assert_eq!(doc.document_count(), 2);
1✔
272

273
        let first_endpoint = doc.get_endpoint().unwrap();
1✔
274
        assert_eq!(first_endpoint, "test.Service/Method");
1✔
275

276
        let second = doc.get_document(1).unwrap();
1✔
277
        assert_eq!(second.get_endpoint().unwrap(), "test.Service/Method2");
1✔
278
    }
1✔
279

280
    #[test]
281
    fn test_parse_empty_content() {
1✔
282
        let doc = parse_gctf_from_str("", "test.gctf").unwrap();
1✔
283
        assert!(doc.sections.is_empty());
1✔
284
    }
1✔
285

286
    #[test]
287
    fn test_parse_all_section_types() {
1✔
288
        let input = "\
1✔
289
--- ADDRESS ---
1✔
290
localhost:50051
1✔
291

1✔
292
--- ENDPOINT ---
1✔
293
test.Service/Method
1✔
294

1✔
295
--- TLS ---
1✔
296
ca_cert: /path/ca.pem
1✔
297

1✔
298
--- PROTO ---
1✔
299
files: service.proto
1✔
300

1✔
301
--- OPTIONS ---
1✔
302
timeout: 10
1✔
303

1✔
304
--- REQUEST_HEADERS ---
1✔
305
Authorization: Bearer token
1✔
306

1✔
307
--- REQUEST ---
1✔
308
{}
1✔
309

1✔
310
--- RESPONSE ---
1✔
311
{}
1✔
312

1✔
313
--- ASSERTS ---
1✔
314
.x == 1
1✔
315

1✔
316
--- EXTRACT ---
1✔
317
total = .response.total
1✔
318
";
1✔
319
        let (sections, count) = parse_sections_from_str(input).unwrap();
1✔
320
        assert_eq!(count, 10);
1✔
321

322
        let types: Vec<SectionType> = sections.iter().map(|s| s.section_type).collect();
1✔
323
        assert_eq!(types[0], SectionType::Address);
1✔
324
        assert_eq!(types[1], SectionType::Endpoint);
1✔
325
        assert_eq!(types[2], SectionType::Tls);
1✔
326
        assert_eq!(types[3], SectionType::Proto);
1✔
327
        assert_eq!(types[4], SectionType::Options);
1✔
328
        assert_eq!(types[5], SectionType::RequestHeaders);
1✔
329
        assert_eq!(types[6], SectionType::Request);
1✔
330
        assert_eq!(types[7], SectionType::Response);
1✔
331
        assert_eq!(types[8], SectionType::Asserts);
1✔
332
        assert_eq!(types[9], SectionType::Extract);
1✔
333
    }
1✔
334

335
    #[test]
336
    fn test_parse_unknown_section_type() {
1✔
337
        let input = "--- UNKNOWN ---\nhello\n";
1✔
338
        let result = parse_sections_from_str(input);
1✔
339
        assert!(result.is_err());
1✔
340
        assert!(
1✔
341
            result
1✔
342
                .unwrap_err()
1✔
343
                .to_string()
1✔
344
                .contains("Unknown section type")
1✔
345
        );
346
    }
1✔
347

348
    #[test]
349
    fn test_parse_preserves_comments_in_content() {
1✔
350
        let input = "\
1✔
351
--- RESPONSE ---
1✔
352
// This is a comment
1✔
353
{\"status\": \"OK\"}
1✔
354
# Another comment
1✔
355
";
1✔
356
        let (sections, _) = parse_sections_from_str(input).unwrap();
1✔
357
        let resp = sections
1✔
358
            .into_iter()
1✔
359
            .find(|s| s.section_type == SectionType::Response)
1✔
360
            .unwrap();
1✔
361
        assert!(resp.raw_content.contains("// This is a comment"));
1✔
362
        assert!(resp.raw_content.contains("# Another comment"));
1✔
363
    }
1✔
364

365
    #[test]
366
    fn test_parse_with_diagnostics_file_not_found() {
1✔
367
        let result = parse_gctf_with_diagnostics(Path::new("/nonexistent/file.gctf"));
1✔
368
        assert!(result.is_err());
1✔
369
    }
1✔
370

371
    #[test]
372
    fn test_parse_from_str_section_counts() {
1✔
373
        let input = "\
1✔
374
--- ENDPOINT ---
1✔
375
test.Service/Method
1✔
376

1✔
377
--- REQUEST ---
1✔
378
{}
1✔
379

1✔
380
--- ASSERTS ---
1✔
381
.x == 1
1✔
382
";
1✔
383
        let doc = parse_gctf_from_str(input, "test.gctf").unwrap();
1✔
384
        assert_eq!(doc.sections.len(), 3);
1✔
385
        assert!(doc.get_endpoint().is_some());
1✔
386
        let asserts = doc.get_assertions();
1✔
387
        assert_eq!(asserts.len(), 1);
1✔
388
    }
1✔
389

390
    #[test]
391
    fn test_extract_doc_source() {
1✔
392
        let source = "line0\nline1\nline2\nline3\nline4";
1✔
393
        let sections = vec![Section {
1✔
394
            section_type: SectionType::Endpoint,
1✔
395
            content: SectionContent::Single("line1".into()),
1✔
396
            inline_options: InlineOptions::default(),
1✔
397
            raw_content: "line1".into(),
1✔
398
            start_line: 1,
1✔
399
            end_line: 2,
1✔
400
        }];
1✔
401
        let result = extract_doc_source(&sections, source);
1✔
402
        assert_eq!(result, "line1");
1✔
403
    }
1✔
404

405
    #[test]
406
    fn test_extract_doc_source_empty() {
1✔
407
        let result = extract_doc_source(&[], "anything");
1✔
408
        assert!(result.is_empty());
1✔
409
    }
1✔
410
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc