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

gripmock / grpctestify-rust / 24902954483

24 Apr 2026 05:29PM UTC coverage: 78.02% (+0.3%) from 77.729%
24902954483

Pull #43

github

web-flow
Merge b18cb9c15 into 017e47d15
Pull Request #43: new command gen grpcurl & call

741 of 993 new or added lines in 24 files covered. (74.62%)

3 existing lines in 3 files now uncovered.

19594 of 25114 relevant lines covered (78.02%)

39580.43 hits per line

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

97.67
/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 anyhow::{Context, Result};
8
use serde::Serialize;
9
use std::collections::HashMap;
10
use std::fs;
11
use std::path::Path;
12
use std::time::Instant;
13

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

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

27
    // Split sections into documents based on implicit boundaries
28
    let documents = super::document_splitter::split_sections_by_boundary_owned(all_sections);
11,358✔
29

30
    if documents.is_empty() {
11,358✔
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,357✔
36

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

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

49
    head.ok_or_else(|| anyhow::anyhow!("No documents parsed"))
11,357✔
50
}
11,358✔
51

52
/// Split sections into documents based on implicit boundaries.
53
///
54
/// Extract source lines for a document from the original content.
55
fn extract_doc_source_from_lines(sections: &[Section], lines: &[&str]) -> String {
40,452✔
56
    if sections.is_empty() {
40,452✔
57
        return String::new();
1✔
58
    }
40,451✔
59

60
    let (start, end) = match (sections.first(), sections.last()) {
40,451✔
61
        (Some(first), Some(last)) => (first.start_line, last.end_line),
40,451✔
NEW
62
        _ => return String::new(),
×
63
    };
64
    lines.get(start..end).unwrap_or(&[]).join("\n")
40,451✔
65
}
40,452✔
66

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

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

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

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

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

99
    // Split into documents using implicit boundaries
100
    let documents = super::document_splitter::split_sections_by_boundary_owned(sections);
67✔
101

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

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

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

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

144
    Ok((document, diagnostics))
67✔
145
}
68✔
146

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

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

167
                section_headers += 1;
132,548✔
168

169
                if let Some(section_type) = SectionType::from_keyword(&name) {
132,548✔
170
                    let inline_options =
132,547✔
171
                        if section_type.supports_inline_options() && !raw_options.is_empty() {
132,547✔
172
                            content_parser::parse_inline_options(&raw_options)?
64✔
173
                        } else {
174
                            InlineOptions::default()
132,483✔
175
                        };
176
                    current_section = Some((section_type, token.line, Vec::new(), inline_options));
132,547✔
177
                } else {
178
                    return Err(anyhow::anyhow!("Unknown section type: {}", name));
1✔
179
                }
180
            }
181
            GctfTokenKind::Comment(text) | GctfTokenKind::Content(text) => {
133,391✔
182
                if let Some((_, _, ref mut content, _)) = current_section {
133,452✔
183
                    content.push(text);
133,432✔
184
                }
133,432✔
185
            }
186
            GctfTokenKind::Blank => {
187
                if let Some((_, _, ref mut content, _)) = current_section {
101,150✔
188
                    content.push(String::new());
101,098✔
189
                }
101,098✔
190
            }
191
        }
192
    }
193

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

201
    Ok((sections, section_headers))
11,429✔
202
}
11,430✔
203

204
#[cfg(test)]
205
mod tests {
206
    use super::*;
207
    use crate::polyfill::runtime;
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
        if !runtime::supports(runtime::Capability::IsolatedFsIo) {
1✔
368
            return;
×
369
        }
1✔
370
        let result = parse_gctf_with_diagnostics(Path::new("/nonexistent/file.gctf"));
1✔
371
        assert!(result.is_err());
1✔
372
    }
1✔
373

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

1✔
380
--- REQUEST ---
1✔
381
{}
1✔
382

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

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

409
    #[test]
410
    fn test_extract_doc_source_empty() {
1✔
411
        let result = extract_doc_source_from_lines(&[], &[]);
1✔
412
        assert!(result.is_empty());
1✔
413
    }
1✔
414
}
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