• 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

84.64
/src/semantics/mod.rs
1
use serde::{Deserialize, Serialize};
2
use std::collections::HashMap;
3

4
use crate::parser;
5
use crate::parser::tokenizer::{TokenKind, tokenize_assertion};
6
use crate::plugins::{PluginManager, PluginSignature, TypeInfo};
7
use crate::utils::section_content_line;
8

9
#[derive(Debug, Clone, Serialize, Deserialize)]
10
pub struct AssertionTypeMismatch {
11
    pub rule_id: String,
12
    pub line: usize,
13
    pub expression: String,
14
    pub message: String,
15
    pub expected: String,
16
    pub actual: String,
17
}
18

19
#[derive(Debug, Clone, Serialize, Deserialize)]
20
pub struct UnknownPluginCall {
21
    pub rule_id: String,
22
    pub line: usize,
23
    pub expression: String,
24
    pub plugin_name: String,
25
    pub message: String,
26
    pub suggestion: Option<String>,
27
}
28

29
fn operator_from_tokens(
7✔
30
    tokens: &[parser::tokenizer::Token],
7✔
31
) -> Option<(&'static str, usize, usize)> {
7✔
32
    for token in tokens {
47✔
33
        if let TokenKind::Op(op) = &token.kind {
47✔
34
            let static_op: Option<&'static str> = match op.as_str() {
7✔
35
                "==" => Some("=="),
7✔
36
                "!=" => Some("!="),
1✔
37
                ">=" => Some(">="),
1✔
38
                "<=" => Some("<="),
1✔
39
                ">" => Some(">"),
1✔
40
                "<" => Some("<"),
1✔
41
                "contains" => Some("contains"),
1✔
42
                "matches" => Some("matches"),
1✔
43
                "startsWith" => Some("startsWith"),
1✔
NEW
44
                "endsWith" => Some("endsWith"),
×
NEW
45
                _ => None,
×
46
            };
47
            if let Some(s) = static_op {
7✔
48
                return Some((s, token.span.start, token.span.len()));
7✔
NEW
49
            }
×
50
        }
40✔
51
    }
UNCOV
52
    None
×
53
}
7✔
54

55
fn plugin_signatures() -> &'static HashMap<String, PluginSignature> {
71✔
56
    use crate::plugins::PLUGIN_SIGNATURES;
57
    &PLUGIN_SIGNATURES
71✔
58
}
71✔
59

60
fn extract_plugin_calls(expr: &str) -> Vec<String> {
6✔
61
    let chars: Vec<char> = expr.chars().collect();
6✔
62
    let mut calls = Vec::new();
6✔
63
    let mut i = 0;
6✔
64

65
    while i < chars.len() {
132✔
66
        if chars[i] != '@' {
126✔
67
            i += 1;
121✔
68
            continue;
121✔
69
        }
5✔
70

71
        let start = i + 1;
5✔
72
        let mut end = start;
5✔
73
        while end < chars.len() && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
38✔
74
            end += 1;
33✔
75
        }
33✔
76

77
        if end == start {
5✔
78
            i += 1;
×
79
            continue;
×
80
        }
5✔
81

82
        let mut cursor = end;
5✔
83
        while cursor < chars.len() && chars[cursor].is_whitespace() {
5✔
84
            cursor += 1;
×
85
        }
×
86

87
        if cursor < chars.len() && chars[cursor] == '(' {
5✔
88
            let name: String = chars[start..end].iter().collect();
5✔
89
            calls.push(name);
5✔
90
        }
5✔
91

92
        i = end;
5✔
93
    }
94

95
    calls
6✔
96
}
6✔
97

98
fn best_plugin_suggestion(unknown: &str, known_plugins: &[String]) -> Option<String> {
3✔
99
    fn common_prefix_len(a: &str, b: &str) -> usize {
51✔
100
        a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
63✔
101
    }
51✔
102

103
    let mut best: Option<(&str, usize, usize)> = None;
3✔
104
    for candidate in known_plugins {
51✔
105
        let prefix = common_prefix_len(unknown, candidate);
51✔
106
        let len_diff = unknown.len().abs_diff(candidate.len());
51✔
107

108
        match best {
51✔
109
            None => best = Some((candidate.as_str(), prefix, len_diff)),
3✔
110
            Some((_, best_prefix, best_len_diff)) => {
48✔
111
                if prefix > best_prefix || (prefix == best_prefix && len_diff < best_len_diff) {
48✔
112
                    best = Some((candidate.as_str(), prefix, len_diff));
9✔
113
                }
39✔
114
            }
115
        }
116
    }
117

118
    best.and_then(|(name, prefix, _)| {
3✔
119
        if prefix >= 3 {
3✔
120
            Some(name.to_string())
3✔
121
        } else {
122
            None
×
123
        }
124
    })
3✔
125
}
3✔
126

127
fn infer_type_from_tokens(
14✔
128
    tokens: &[parser::tokenizer::Token],
14✔
129
    signatures: &HashMap<String, PluginSignature>,
14✔
130
) -> TypeInfo {
14✔
131
    if tokens.len() == 1 {
14✔
132
        return match &tokens[0].kind {
7✔
133
            TokenKind::StringLit(_) => TypeInfo::String,
1✔
134
            TokenKind::NumberLit(v) if v.parse::<f64>().is_ok() => TypeInfo::Number,
1✔
135
            TokenKind::Ident(s) if s == "true" || s == "false" => TypeInfo::Bool,
5✔
NEW
136
            TokenKind::LBracket => TypeInfo::Any,
×
NEW
137
            TokenKind::LBrace => TypeInfo::Any,
×
NEW
138
            _ => TypeInfo::Any,
×
139
        };
140
    }
7✔
141

142
    if tokens.len() >= 3
7✔
143
        && matches!(&tokens[0].kind, TokenKind::At)
6✔
144
        && matches!(&tokens[1].kind, TokenKind::Ident(name) if {
6✔
145
            if let Some(sig) = signatures.get(name.as_str()) {
6✔
146
                return sig.return_type;
4✔
147
            }
2✔
148
            false
2✔
NEW
149
        })
×
150
    {
NEW
151
        return TypeInfo::Any;
×
152
    }
3✔
153

154
    for token in tokens {
17✔
155
        if let TokenKind::StringLit(_) = &token.kind {
17✔
156
            return TypeInfo::String;
1✔
157
        }
16✔
158
    }
159

160
    TypeInfo::Any
2✔
161
}
14✔
162

163
fn detect_type_mismatch(
7✔
164
    expr: &str,
7✔
165
    signatures: &HashMap<String, PluginSignature>,
7✔
166
) -> Option<AssertionTypeMismatch> {
7✔
167
    let tokens = tokenize_assertion(expr);
7✔
168
    let (op, op_idx, op_len) = operator_from_tokens(&tokens)?;
7✔
169
    let lhs = expr[..op_idx].trim();
7✔
170
    let rhs = expr[op_idx + op_len..].trim();
7✔
171
    if lhs.is_empty() || rhs.is_empty() {
7✔
172
        return None;
×
173
    }
7✔
174

175
    let lhs_tokens = tokenize_assertion(lhs);
7✔
176
    let rhs_tokens = tokenize_assertion(rhs);
7✔
177
    let lhs_type = infer_type_from_tokens(&lhs_tokens, signatures);
7✔
178
    let rhs_type = infer_type_from_tokens(&rhs_tokens, signatures);
7✔
179

180
    // Check if the operator is valid for the left-hand side type
181
    let (valid, reason) = lhs_type.supports_operator(op);
7✔
182
    if !valid {
7✔
183
        return Some(AssertionTypeMismatch {
1✔
184
            rule_id: "SEM_T005".to_string(),
1✔
185
            line: 0,
1✔
186
            expression: expr.to_string(),
1✔
187
            message: format!(
1✔
188
                "Operator '{}' is not valid for {}: {}",
1✔
189
                op,
1✔
190
                lhs_type.display_name(),
1✔
191
                reason.unwrap_or("")
1✔
192
            ),
1✔
193
            expected: format!("a type that supports '{}'", op),
1✔
194
            actual: lhs_type.display_name().to_string(),
1✔
195
        });
1✔
196
    }
6✔
197

198
    // For comparison operators, also check type compatibility between LHS and RHS
199
    if op == "==" || op == "!=" {
6✔
200
        // Equality is allowed between most types, but flag obvious mismatches
201
        if lhs_type != TypeInfo::Any
6✔
202
            && rhs_type != TypeInfo::Any
4✔
203
            && !types_compatible(lhs_type, rhs_type)
4✔
204
        {
205
            return Some(AssertionTypeMismatch {
2✔
206
                rule_id: "SEM_T001".to_string(),
2✔
207
                line: 0,
2✔
208
                expression: expr.to_string(),
2✔
209
                message: format!(
2✔
210
                    "Type-incompatible comparison: {} is {}, but {} is {}",
2✔
211
                    lhs,
2✔
212
                    lhs_type.display_name(),
2✔
213
                    rhs,
2✔
214
                    rhs_type.display_name()
2✔
215
                ),
2✔
216
                expected: lhs_type.display_name().to_string(),
2✔
217
                actual: rhs_type.display_name().to_string(),
2✔
218
            });
2✔
219
        }
4✔
UNCOV
220
    }
×
221

222
    if matches!(op, ">" | "<" | ">=" | "<=") && !rhs_type.is_numeric() && rhs_type != TypeInfo::Any
4✔
223
    {
NEW
224
        return Some(AssertionTypeMismatch {
×
NEW
225
            rule_id: "SEM_T002".to_string(),
×
NEW
226
            line: 0,
×
NEW
227
            expression: expr.to_string(),
×
NEW
228
            message: format!(
×
NEW
229
                "Ordering operator '{}' requires a number on the right, but {} is {}",
×
NEW
230
                op,
×
NEW
231
                rhs,
×
NEW
232
                rhs_type.display_name()
×
NEW
233
            ),
×
NEW
234
            expected: "number".to_string(),
×
NEW
235
            actual: rhs_type.display_name().to_string(),
×
NEW
236
        });
×
237
    }
4✔
238

239
    if matches!(op, "contains" | "startsWith" | "endsWith" | "matches")
4✔
NEW
240
        && !rhs_type.is_stringy()
×
NEW
241
        && rhs_type != TypeInfo::Any
×
242
    {
NEW
243
        return Some(AssertionTypeMismatch {
×
NEW
244
            rule_id: "SEM_T003".to_string(),
×
NEW
245
            line: 0,
×
NEW
246
            expression: expr.to_string(),
×
NEW
247
            message: format!(
×
NEW
248
                "Operator '{}' requires a string on the right, but {} is {}",
×
NEW
249
                op,
×
NEW
250
                rhs,
×
NEW
251
                rhs_type.display_name()
×
NEW
252
            ),
×
NEW
253
            expected: "string".to_string(),
×
NEW
254
            actual: rhs_type.display_name().to_string(),
×
NEW
255
        });
×
256
    }
4✔
257

258
    None
4✔
259
}
7✔
260

261
/// Check if two types can be reasonably compared with ==/!=.
262
fn types_compatible(a: TypeInfo, b: TypeInfo) -> bool {
4✔
263
    if a == b {
4✔
264
        return true;
2✔
265
    }
2✔
266
    // Numeric types are compatible
267
    if a.is_numeric() && b.is_numeric() {
2✔
NEW
268
        return true;
×
269
    }
2✔
270
    // String-like types are compatible
271
    if a.is_stringy() && b.is_stringy() {
2✔
NEW
272
        return true;
×
273
    }
2✔
274
    // Bool is compatible with BoolOrNull
275
    if matches!(a, TypeInfo::Bool | TypeInfo::BoolOrNull)
2✔
NEW
276
        && matches!(b, TypeInfo::Bool | TypeInfo::BoolOrNull)
×
277
    {
NEW
278
        return true;
×
279
    }
2✔
280
    // Unknown (Any) is compatible with anything
281
    if a == TypeInfo::Any || b == TypeInfo::Any {
2✔
NEW
282
        return true;
×
283
    }
2✔
284
    false
2✔
285
}
4✔
286

287
pub fn validate_plugin_semantics_completeness() -> Vec<String> {
1✔
288
    let mut issues = Vec::new();
1✔
289
    for plugin in PluginManager::new().list() {
17✔
290
        let name = plugin.name().to_string();
17✔
291
        let sig = plugin.signature();
17✔
292

293
        if sig.return_type == TypeInfo::Any {
17✔
NEW
294
            issues.push(format!("{}: return_type is Any (unknown)", name));
×
295
        }
17✔
296
        let _ = sig.arg_names;
17✔
297
    }
298
    issues
1✔
299
}
1✔
300

301
pub fn collect_assertion_type_mismatches(doc: &parser::GctfDocument) -> Vec<AssertionTypeMismatch> {
36✔
302
    let signatures = plugin_signatures();
36✔
303
    let mut mismatches = Vec::new();
36✔
304

305
    for section in &doc.sections {
110✔
306
        if section.section_type != parser::ast::SectionType::Asserts {
110✔
307
            continue;
103✔
308
        }
7✔
309

310
        for (idx, line) in section.raw_content.lines().enumerate() {
8✔
311
            let trimmed = match parser::assertions::strip_assertion_comments(line) {
8✔
312
                Some(t) => t,
7✔
313
                None => continue,
1✔
314
            };
315

316
            if let Some(mut mismatch) = detect_type_mismatch(&trimmed, signatures) {
7✔
317
                mismatch.line = section_content_line(section.start_line, idx);
3✔
318
                mismatches.push(mismatch);
3✔
319
            }
4✔
320
        }
321
    }
322

323
    mismatches
36✔
324
}
36✔
325

326
pub fn collect_unknown_plugin_calls(doc: &parser::GctfDocument) -> Vec<UnknownPluginCall> {
35✔
327
    let signatures = plugin_signatures();
35✔
328
    let mut known_plugins: Vec<String> = signatures.keys().cloned().collect();
35✔
329
    known_plugins.sort();
35✔
330

331
    let mut unknown = Vec::new();
35✔
332

333
    for section in &doc.sections {
108✔
334
        if section.section_type != parser::ast::SectionType::Asserts {
108✔
335
            continue;
102✔
336
        }
6✔
337

338
        for (idx, line) in section.raw_content.lines().enumerate() {
7✔
339
            let trimmed = match parser::assertions::strip_assertion_comments(line) {
7✔
340
                Some(t) => t,
6✔
341
                None => continue,
1✔
342
            };
343

344
            for plugin_name in extract_plugin_calls(&trimmed) {
6✔
345
                if signatures.contains_key(plugin_name.as_str()) {
5✔
346
                    continue;
2✔
347
                }
3✔
348

349
                let suggestion =
3✔
350
                    best_plugin_suggestion(&plugin_name, &known_plugins).map(|s| format!("@{}", s));
3✔
351
                let message = match &suggestion {
3✔
352
                    Some(s) => format!(
3✔
353
                        "Unknown assertion plugin '@{}'. Did you mean {}?",
354
                        plugin_name, s
355
                    ),
356
                    None => format!("Unknown assertion plugin '@{}'", plugin_name),
×
357
                };
358

359
                unknown.push(UnknownPluginCall {
3✔
360
                    rule_id: "SEM_F001".to_string(),
3✔
361
                    line: section_content_line(section.start_line, idx),
3✔
362
                    expression: trimmed.to_string(),
3✔
363
                    plugin_name,
3✔
364
                    message,
3✔
365
                    suggestion,
3✔
366
                });
3✔
367
            }
368
        }
369
    }
370

371
    unknown
35✔
372
}
35✔
373

374
#[cfg(test)]
375
mod tests {
376
    use super::*;
377

378
    #[test]
379
    fn test_semantics_detects_boolean_vs_number() {
1✔
380
        let content = r#"--- ENDPOINT ---
1✔
381
test.Service/Method
1✔
382

1✔
383
--- ASSERTS ---
1✔
384
@len(.names) == true
1✔
385
"#;
1✔
386

387
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
388
        let mismatches = collect_assertion_type_mismatches(&doc);
1✔
389
        assert_eq!(mismatches.len(), 1);
1✔
390
        assert_eq!(mismatches[0].rule_id, "SEM_T001");
1✔
391
    }
1✔
392

393
    #[test]
394
    fn test_semantics_allows_boolean_compare() {
1✔
395
        let content = r#"--- ENDPOINT ---
1✔
396
test.Service/Method
1✔
397

1✔
398
--- ASSERTS ---
1✔
399
@has_header("x-request-id") == true
1✔
400
"#;
1✔
401

402
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
403
        let mismatches = collect_assertion_type_mismatches(&doc);
1✔
404
        assert!(mismatches.is_empty());
1✔
405
    }
1✔
406

407
    #[test]
408
    fn test_semantics_detects_startswith_non_string() {
1✔
409
        let content = r#"--- ENDPOINT ---
1✔
410
test.Service/Method
1✔
411

1✔
412
--- ASSERTS ---
1✔
413
@len(.names) startsWith "a"
1✔
414
"#;
1✔
415

416
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
417
        let mismatches = collect_assertion_type_mismatches(&doc);
1✔
418
        assert_eq!(mismatches.len(), 1);
1✔
419
        // SEM_T005: startsWith is not valid for non-string LHS (UInt from @len)
420
        assert_eq!(mismatches[0].rule_id, "SEM_T005");
1✔
421
    }
1✔
422

423
    #[test]
424
    fn test_plugin_semantics_completeness() {
1✔
425
        let issues = validate_plugin_semantics_completeness();
1✔
426
        assert!(issues.is_empty(), "Incomplete plugin semantics: {issues:?}");
1✔
427
    }
1✔
428

429
    #[test]
430
    fn test_semantics_detects_unknown_plugin_calls() {
1✔
431
        let content = r#"--- ENDPOINT ---
1✔
432
test.Service/Method
1✔
433

1✔
434
--- ASSERTS ---
1✔
435
@regexp(.name, "^a") == true
1✔
436
"#;
1✔
437

438
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
439
        let unknown = collect_unknown_plugin_calls(&doc);
1✔
440
        assert_eq!(unknown.len(), 1);
1✔
441
        assert_eq!(unknown[0].rule_id, "SEM_F001");
1✔
442
        assert_eq!(unknown[0].plugin_name, "regexp");
1✔
443
        assert_eq!(unknown[0].suggestion.as_deref(), Some("@regex"));
1✔
444
    }
1✔
445

446
    #[test]
447
    fn test_semantics_allows_known_plugin_calls() {
1✔
448
        let content = r#"--- ENDPOINT ---
1✔
449
test.Service/Method
1✔
450

1✔
451
--- ASSERTS ---
1✔
452
@regex(.name, "^a") == true
1✔
453
"#;
1✔
454

455
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
456
        let unknown = collect_unknown_plugin_calls(&doc);
1✔
457
        assert!(unknown.is_empty());
1✔
458
    }
1✔
459
}
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