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

pomsky-lang / pomsky / 9142134100

18 May 2024 07:23PM UTC coverage: 82.949% (-1.3%) from 84.258%
9142134100

push

github

Aloso
fix e2e tests

1 of 1 new or added line in 1 file covered. (100.0%)

271 existing lines in 24 files now uncovered.

4242 of 5114 relevant lines covered (82.95%)

420176.26 hits per line

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

93.33
/pomsky-lib/src/exprs/char_class.rs
1
//! Implements _character classes_. The analogue in the regex world are
2
//! [character classes](https://www.regular-expressions.info/charclass.html),
3
//! [shorthand character classes](https://www.regular-expressions.info/shorthand.html),
4
//! [non-printable characters](https://www.regular-expressions.info/nonprint.html),
5
//! [Unicode categories/scripts/blocks](https://www.regular-expressions.info/unicode.html#category),
6
//! [POSIX classes](https://www.regular-expressions.info/posixbrackets.html#class) and the
7
//! [dot](https://www.regular-expressions.info/dot.html).
8
//!
9
//! All kinds of character classes mentioned above require `[` square brackets
10
//! `]` in Pomsky. A character class can be negated by putting the keyword `not`
11
//! after the opening bracket. For example, `![.]` compiles to `\n`.
12
//!
13
//! ## Items
14
//!
15
//! A character class can contain multiple _items_, which can be
16
//!
17
//! - A __code point__, e.g. `['a']` or `[U+107]`
18
//!
19
//!   - This includes [non-printable characters](https://www.regular-expressions.info/nonprint.html).\
20
//!     Supported are `[n]`, `[r]`, `[t]`, `[a]`, `[e]` and `[f]`.
21
//!
22
//! - A __range of code points__. For example, `[U+10 - U+200]` matches any code
23
//!   point P where `U+10 ≤ P ≤ U+200`
24
//!
25
//! - A __named character class__, which can be one of
26
//!
27
//!   - a [shorthand character class](https://www.regular-expressions.info/shorthand.html).\
28
//!     Supported are `[w]`, `[d]`, `[s]`, `[h]`, `[v]` and `[R]`.
29
//!
30
//!   - a [POSIX class](https://www.regular-expressions.info/posixbrackets.html#class).\
31
//!     Supported are `[ascii_alnum]`, `[ascii_alpha]`, `[ascii]`,
32
//!     `[ascii_blank]`, `[ascii_cntrl]`, `[ascii_digit]`, `[ascii_graph]`,
33
//!     `[ascii_lower]`, `[ascii_print]`, `[ascii_punct]`, ´ `[ascii_space]`,
34
//!     `[ascii_upper]`, `[ascii_word]` and `[ascii_xdigit]`.\ _Note_: POSIX
35
//!     classes are not Unicode aware!\ _Note_: They're converted to ranges,
36
//!     e.g. `[ascii_alpha]` = `[a-zA-Z]`.
37
//!
38
//!   - a [Unicode category, script or block](https://www.regular-expressions.info/unicode.html#category).\
39
//!     For example: `[Letter]` compiles to `\p{Letter}`. Pomsky currently
40
//!     treats any uppercase identifier except `R` as Unicode class.
41
//!
42
//! ## Compilation
43
//!
44
//! When a character class contains only a single item (e.g. `[w]`), the
45
//! character class is "flattened":
46
//!
47
//! - `['a']` = `a`
48
//! - `[w]` = `\w`
49
//! - `[Letter]` = `\p{Letter}`
50
//!
51
//! When there is more than one item or a range (e.g. `['a'-'z' '!']`), a regex
52
//! character class is created:
53
//!
54
//! - `['a'-'z' '!']` = `[a-z!]`
55
//! - `[w e Punctuation]` = `[\w\e\p{Punctuation}]`
56
//!
57
//! ### Negation
58
//!
59
//! Negation is implemented as follows:
60
//!
61
//! - Ranges and chars such as `!['a'-'z' '!' e]` are wrapped in a negative
62
//!   character class, e.g. `[^a-z!\e]`.
63
//!
64
//! - The `h`, `v` and `R` shorthands are also wrapped in a negative character
65
//!   class.
66
//!
67
//! - The `w`, `d` and `s` shorthands are negated by making them uppercase
68
//!   (`![w]` = `\W`), except when there is more than one item in the class
69
//!   (`![w '-']` = `[^\w\-]`)
70
//!
71
//! - `w`, `s`, `d` and Unicode categories/scripts/blocks can be negated
72
//!   individually _within a character class_, e.g. `[s !s]` = `[\s\S]`,
73
//!   `![!Latin 'a']` = `[^\P{Latin}a]`.
74
//!
75
//!   When a negated character class only contains 1 item, which is also
76
//! negated, the class is   removed and the negations cancel each other out:
77
//! `![!w]` = `\w`, `![!L]` = `\p{L}`.
78

79
use std::collections::HashSet;
80
use std::fmt;
81

82
use crate::{
83
    compile::{CompileResult, CompileState},
84
    diagnose::{CompileError, CompileErrorKind, Feature},
85
    exprs::literal,
86
    options::{CompileOptions, RegexFlavor},
87
    regex::{Regex, RegexProperty, RegexShorthand},
88
};
89

90
use pomsky_syntax::{
91
    exprs::{Category, CharClass, CodeBlock, GroupItem, GroupName, OtherProperties, Script},
92
    Span,
93
};
94

95
use super::RuleExt;
96

97
impl RuleExt for CharClass {
98
    fn compile(&self, options: CompileOptions, _state: &mut CompileState<'_>) -> CompileResult {
224✔
99
        if self.inner.len() == 1 && !matches!(&&*self.inner, [GroupItem::Char('\r')]) {
224✔
100
            let first = self.inner.first().unwrap();
168✔
101
            if let &GroupItem::Char(c) = first {
168✔
102
                return Ok(Regex::Literal(c.to_string()));
62✔
103
            }
106✔
104
        }
56✔
105

106
        let mut prev_items: HashSet<GroupItem> = HashSet::new();
162✔
107

162✔
108
        let mut negative = false;
162✔
109
        let is_single = self.inner.len() == 1;
162✔
110
        let mut buf = Vec::new();
162✔
111
        for item in &self.inner {
459✔
112
            if prev_items.contains(item) {
305✔
113
                continue;
26✔
114
            }
279✔
115
            prev_items.insert(*item);
279✔
116

279✔
117
            match *item {
279✔
118
                GroupItem::Char(c) => {
121✔
119
                    validate_char_in_class(c, options.flavor, self.span)?;
121✔
120
                    buf.push(RegexCharSetItem::Char(c));
121✔
121
                }
122
                GroupItem::Range { first, last } => {
29✔
123
                    validate_char_in_class(first, options.flavor, self.span)?;
29✔
124
                    validate_char_in_class(last, options.flavor, self.span)?;
29✔
125
                    buf.push(RegexCharSetItem::Range { first, last });
29✔
126
                }
127
                GroupItem::Named { name, negative: item_negative } => {
129✔
128
                    if !self.unicode_aware {
129✔
129
                        named_class_to_regex_ascii(
27✔
130
                            name,
27✔
131
                            item_negative,
27✔
132
                            options.flavor,
27✔
133
                            self.span,
27✔
134
                            &mut buf,
27✔
135
                        )?;
27✔
136
                    } else {
137
                        named_class_to_regex_unicode(
102✔
138
                            name,
102✔
139
                            item_negative,
102✔
140
                            &mut negative,
102✔
141
                            is_single,
102✔
142
                            options.flavor,
102✔
143
                            self.span,
102✔
144
                            &mut buf,
102✔
145
                        )?;
102✔
146
                    }
147
                }
148
            }
149
        }
150

151
        Ok(Regex::CharSet(RegexCharSet { negative, items: buf }))
154✔
152
    }
224✔
153
}
154

155
fn validate_char_in_class(char: char, flavor: RegexFlavor, span: Span) -> Result<(), CompileError> {
179✔
156
    if flavor == RegexFlavor::DotNet && char > '\u{FFFF}' {
179✔
UNCOV
157
        Err(CompileErrorKind::Unsupported(Feature::LargeCodePointInCharClass(char), flavor)
×
UNCOV
158
            .at(span))
×
159
    } else {
160
        Ok(())
179✔
161
    }
162
}
179✔
163

164
pub(crate) fn check_char_class_empty(
46✔
165
    char_set: &RegexCharSet,
46✔
166
    span: Span,
46✔
167
) -> Result<(), CompileError> {
46✔
168
    if char_set.negative {
46✔
169
        let mut prev_items = vec![];
45✔
170

171
        for mut item in char_set.items.iter().copied() {
84✔
172
            use RegexCharSetItem as RCSI;
173
            use RegexProperty as RP;
174
            use RegexShorthand as RS;
175

176
            if let RCSI::Property { negative, value: RP::Category(Category::Separator) } = item {
84✔
UNCOV
177
                item = RCSI::Shorthand(if negative { RS::NotSpace } else { RS::Space });
×
178
            }
84✔
179

180
            if let Some(negated) = item.negate() {
84✔
181
                if prev_items.contains(&negated) {
42✔
182
                    return Err(CompileErrorKind::EmptyClassNegated {
3✔
183
                        group1: negated,
3✔
184
                        group2: item,
3✔
185
                    }
3✔
186
                    .at(span));
3✔
187
                }
39✔
188
            }
42✔
189

190
            prev_items.push(item);
81✔
191
        }
192
    }
1✔
193
    Ok(())
43✔
194
}
46✔
195

196
fn named_class_to_regex_ascii(
27✔
197
    group: GroupName,
27✔
198
    negative: bool,
27✔
199
    flavor: RegexFlavor,
27✔
200
    span: Span,
27✔
201
    buf: &mut Vec<RegexCharSetItem>,
27✔
202
) -> Result<(), CompileError> {
27✔
203
    if negative
27✔
204
        // In JS, \W and \D can be used for negation because they're ascii-only
205
        && (flavor != RegexFlavor::JavaScript
1✔
UNCOV
206
            || (group != GroupName::Digit && group != GroupName::Word))
×
207
    {
208
        return Err(CompileErrorKind::NegativeShorthandInAsciiMode.at(span));
1✔
209
    }
26✔
210

26✔
211
    match group {
26✔
212
        GroupName::Word => {
213
            if flavor == RegexFlavor::JavaScript {
7✔
214
                let s = if negative { RegexShorthand::NotWord } else { RegexShorthand::Word };
1✔
215
                buf.push(RegexCharSetItem::Shorthand(s));
1✔
216
            } else {
6✔
217
                // we already checked above if negative
6✔
218
                buf.extend([
6✔
219
                    RegexCharSetItem::Range { first: 'a', last: 'z' },
6✔
220
                    RegexCharSetItem::Range { first: 'A', last: 'Z' },
6✔
221
                    RegexCharSetItem::Range { first: '0', last: '9' },
6✔
222
                    RegexCharSetItem::Char('_'),
6✔
223
                ]);
6✔
224
            }
6✔
225
        }
226
        GroupName::Digit => {
227
            if flavor == RegexFlavor::JavaScript {
11✔
228
                let s = if negative { RegexShorthand::NotDigit } else { RegexShorthand::Digit };
1✔
229
                buf.push(RegexCharSetItem::Shorthand(s));
1✔
230
            } else {
10✔
231
                // we already checked above if negative
10✔
232
                buf.push(RegexCharSetItem::Range { first: '0', last: '9' });
10✔
233
            }
10✔
234
        }
235
        GroupName::Space => buf.extend([
7✔
236
            RegexCharSetItem::Char(' '),
7✔
237
            RegexCharSetItem::Range { first: '\x09', last: '\x0D' }, // \t\n\v\f\r
7✔
238
        ]),
7✔
UNCOV
239
        GroupName::HorizSpace => buf.push(RegexCharSetItem::Char('\t')),
×
UNCOV
240
        GroupName::VertSpace => buf.push(RegexCharSetItem::Range { first: '\x0A', last: '\x0D' }),
×
241
        _ => return Err(CompileErrorKind::UnicodeInAsciiMode.at(span)),
1✔
242
    }
243
    Ok(())
25✔
244
}
27✔
245

246
fn named_class_to_regex_unicode(
102✔
247
    group: GroupName,
102✔
248
    negative: bool,
102✔
249
    group_negative: &mut bool,
102✔
250
    is_single: bool,
102✔
251
    flavor: RegexFlavor,
102✔
252
    span: Span,
102✔
253
    buf: &mut Vec<RegexCharSetItem>,
102✔
254
) -> Result<(), CompileError> {
102✔
255
    match group {
5✔
256
        GroupName::Word => {
257
            if flavor == RegexFlavor::JavaScript {
22✔
258
                if negative {
5✔
259
                    if is_single {
2✔
260
                        *group_negative ^= true;
1✔
261
                    } else {
1✔
262
                        return Err(CompileErrorKind::Unsupported(
1✔
263
                            Feature::NegativeShorthandW,
1✔
264
                            flavor,
1✔
265
                        )
1✔
266
                        .at(span));
1✔
267
                    }
268
                }
3✔
269
                buf.extend([
4✔
270
                    RegexProperty::Other(OtherProperties::Alphabetic).negative_item(false),
4✔
271
                    RegexProperty::Category(Category::Mark).negative_item(false),
4✔
272
                    RegexProperty::Category(Category::Decimal_Number).negative_item(false),
4✔
273
                    RegexProperty::Category(Category::Connector_Punctuation).negative_item(false),
4✔
274
                ]);
4✔
275
            } else {
17✔
276
                let s = if negative { RegexShorthand::NotWord } else { RegexShorthand::Word };
17✔
277
                buf.push(RegexCharSetItem::Shorthand(s));
17✔
278
            }
279
        }
280
        GroupName::Digit => {
281
            if flavor == RegexFlavor::JavaScript {
16✔
282
                buf.push(RegexProperty::Category(Category::Decimal_Number).negative_item(negative));
4✔
283
            } else {
12✔
284
                let s = if negative { RegexShorthand::NotDigit } else { RegexShorthand::Digit };
12✔
285
                buf.push(RegexCharSetItem::Shorthand(s));
12✔
286
            }
287
        }
288

289
        GroupName::Space => buf.push(RegexCharSetItem::Shorthand(if negative {
12✔
290
            RegexShorthand::NotSpace
3✔
291
        } else {
292
            RegexShorthand::Space
9✔
293
        })),
294

UNCOV
295
        GroupName::HorizSpace | GroupName::VertSpace if negative => {
×
UNCOV
296
            return Err(CompileErrorKind::NegatedHorizVertSpace.at(span));
×
297
        }
298

299
        GroupName::HorizSpace | GroupName::VertSpace
300
            if matches!(flavor, RegexFlavor::Pcre | RegexFlavor::Java) =>
5✔
301
        {
302
            buf.push(RegexCharSetItem::Shorthand(if group == GroupName::HorizSpace {
6✔
303
                RegexShorthand::HorizSpace
3✔
304
            } else {
305
                RegexShorthand::VertSpace
3✔
306
            }));
307
        }
308
        GroupName::HorizSpace => {
309
            buf.push(RegexCharSetItem::Char('\t'));
2✔
310
            if flavor == RegexFlavor::Python {
2✔
UNCOV
311
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
×
312
            } else {
2✔
313
                buf.push(RegexProperty::Category(Category::Space_Separator).negative_item(false));
2✔
314
            }
2✔
315
        }
316
        GroupName::VertSpace => buf.extend([
2✔
317
            RegexCharSetItem::Range { first: '\x0A', last: '\x0D' },
2✔
318
            RegexCharSetItem::Char('\u{85}'),
2✔
319
            RegexCharSetItem::Char('\u{2028}'),
2✔
320
            RegexCharSetItem::Char('\u{2029}'),
2✔
321
        ]),
2✔
322

323
        _ if flavor == RegexFlavor::Python => {
42✔
324
            return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
2✔
325
        }
326
        GroupName::Category(c) => {
5✔
UNCOV
327
            if let (RegexFlavor::Rust, Category::Surrogate)
×
328
            | (RegexFlavor::DotNet, Category::Cased_Letter) = (flavor, c)
5✔
329
            {
UNCOV
330
                return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
331
            }
5✔
332
            buf.push(RegexProperty::Category(c).negative_item(negative));
5✔
333
        }
334
        GroupName::Script(s) => {
21✔
335
            if flavor == RegexFlavor::DotNet {
21✔
336
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeScript, flavor).at(span));
1✔
337
            }
20✔
338
            if let (
20✔
339
                RegexFlavor::Pcre | RegexFlavor::Ruby | RegexFlavor::Java,
20✔
340
                Script::Kawi | Script::Nag_Mundari,
12✔
341
            )
342
            | (RegexFlavor::Rust, Script::Unknown) = (flavor, s)
20✔
343
            {
UNCOV
344
                return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
345
            }
20✔
346
            buf.push(RegexProperty::Script(s).negative_item(negative));
20✔
347
        }
348
        GroupName::CodeBlock(b) => match flavor {
7✔
349
            RegexFlavor::DotNet | RegexFlavor::Java | RegexFlavor::Ruby => {
350
                match (flavor, b) {
6✔
351
                    (
352
                        RegexFlavor::Java,
353
                        CodeBlock::Arabic_Extended_C
354
                        | CodeBlock::CJK_Unified_Ideographs_Extension_H
355
                        | CodeBlock::Combining_Diacritical_Marks_For_Symbols
356
                        | CodeBlock::Cyrillic_Extended_D
357
                        | CodeBlock::Cyrillic_Supplement
358
                        | CodeBlock::Devanagari_Extended_A
359
                        | CodeBlock::Greek_And_Coptic
360
                        | CodeBlock::Kaktovik_Numerals
361
                        | CodeBlock::No_Block,
362
                    )
363
                    | (
364
                        RegexFlavor::Ruby,
365
                        CodeBlock::Arabic_Extended_C
366
                        | CodeBlock::CJK_Unified_Ideographs_Extension_H
367
                        | CodeBlock::Cyrillic_Extended_D
368
                        | CodeBlock::Devanagari_Extended_A
369
                        | CodeBlock::Kaktovik_Numerals,
370
                    ) => {
UNCOV
371
                        return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
372
                    }
373
                    (RegexFlavor::DotNet, _) => {
374
                        let dotnet_name = b.as_str().replace("_And_", "_and_").replace('_', "");
2✔
375
                        if pomsky_syntax::blocks_supported_in_dotnet()
2✔
376
                            .binary_search(&dotnet_name.as_str())
2✔
377
                            .is_err()
2✔
378
                        {
UNCOV
379
                            return Err(
×
UNCOV
380
                                CompileErrorKind::unsupported_specific_prop_in(flavor).at(span)
×
UNCOV
381
                            );
×
382
                        }
2✔
383
                    }
384
                    _ => {}
4✔
385
                }
386

387
                buf.push(RegexProperty::Block(b).negative_item(negative));
6✔
388
            }
389
            _ => return Err(CompileErrorKind::Unsupported(Feature::UnicodeBlock, flavor).at(span)),
1✔
390
        },
391
        GroupName::OtherProperties(o) => {
7✔
392
            use OtherProperties as OP;
7✔
393
            use RegexFlavor as RF;
7✔
394

7✔
395
            if let RF::JavaScript | RF::Rust | RF::Pcre | RF::Ruby = flavor {
7✔
396
                match (flavor, o) {
7✔
397
                    (RF::JavaScript, _) => {}
4✔
398
                    (_, OP::Changes_When_NFKC_Casefolded)
399
                    | (RF::Pcre, OP::Assigned)
400
                    | (RF::Ruby, OP::Bidi_Mirrored) => {
401
                        return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
1✔
402
                    }
403
                    _ => {}
2✔
404
                }
405
                buf.push(RegexProperty::Other(o).negative_item(negative));
6✔
406
            } else {
UNCOV
407
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
×
408
            }
409
        }
410
    }
411
    Ok(())
96✔
412
}
102✔
413

414
#[cfg_attr(feature = "dbg", derive(Debug))]
415
pub(crate) struct RegexCharSet {
416
    negative: bool,
417
    items: Vec<RegexCharSetItem>,
418
}
419

420
impl RegexCharSet {
421
    pub(crate) fn new(items: Vec<RegexCharSetItem>) -> Self {
157✔
422
        Self { negative: false, items }
157✔
423
    }
157✔
424

425
    pub(crate) fn negate(mut self) -> Self {
46✔
426
        self.negative = !self.negative;
46✔
427
        self
46✔
428
    }
46✔
429

430
    pub(crate) fn codegen(&self, buf: &mut String, flavor: RegexFlavor) {
306✔
431
        if self.items.len() == 1 {
306✔
432
            match self.items.first().unwrap() {
219✔
433
                RegexCharSetItem::Shorthand(s) => {
24✔
434
                    let shorthand = if self.negative { s.negate() } else { Some(*s) };
24✔
435
                    if let Some(shorthand) = shorthand {
24✔
436
                        return shorthand.codegen(buf);
22✔
437
                    }
2✔
438
                }
439
                RegexCharSetItem::Property { negative, value } => {
36✔
440
                    return value.codegen(buf, negative ^ self.negative, flavor);
36✔
441
                }
442
                RegexCharSetItem::Char(c) if !self.negative => {
12✔
443
                    return literal::codegen_char_esc(*c, buf, flavor);
3✔
444
                }
445
                _ => {}
156✔
446
            }
447
        }
87✔
448

449
        if self.negative {
245✔
450
            buf.push_str("[^");
27✔
451
        } else {
218✔
452
            buf.push('[');
218✔
453
        }
218✔
454

455
        let mut is_first = true;
245✔
456
        for item in &self.items {
675✔
457
            match *item {
430✔
458
                RegexCharSetItem::Char(c) => {
146✔
459
                    literal::compile_char_esc_in_class(c, buf, is_first, flavor);
146✔
460
                }
146✔
461
                RegexCharSetItem::Range { first, last } => {
214✔
462
                    literal::compile_char_esc_in_class(first, buf, is_first, flavor);
214✔
463
                    buf.push('-');
214✔
464
                    literal::compile_char_esc_in_class(last, buf, false, flavor);
214✔
465
                }
214✔
466
                RegexCharSetItem::Shorthand(s) => s.codegen(buf),
49✔
467
                RegexCharSetItem::Property { negative, value } => {
21✔
468
                    value.codegen(buf, negative, flavor);
21✔
469
                }
21✔
470
            }
471
            is_first = false;
430✔
472
        }
473

474
        buf.push(']');
245✔
475
    }
306✔
476
}
477

478
#[derive(Clone, Copy, PartialEq, Eq)]
479
pub(crate) enum RegexCharSetItem {
480
    Char(char),
481
    Range { first: char, last: char },
482
    Shorthand(RegexShorthand),
483
    Property { negative: bool, value: RegexProperty },
484
}
485

486
impl RegexCharSetItem {
487
    pub(crate) fn range_unchecked(first: char, last: char) -> Self {
150✔
488
        Self::Range { first, last }
150✔
489
    }
150✔
490

491
    pub(crate) fn negate(self) -> Option<Self> {
84✔
492
        match self {
84✔
493
            RegexCharSetItem::Char(_) => None,
38✔
494
            RegexCharSetItem::Range { .. } => None,
2✔
495
            RegexCharSetItem::Shorthand(s) => s.negate().map(RegexCharSetItem::Shorthand),
19✔
496
            RegexCharSetItem::Property { negative, value } => {
25✔
497
                Some(RegexCharSetItem::Property { negative: !negative, value })
25✔
498
            }
499
        }
500
    }
84✔
501
}
502

503
impl fmt::Debug for RegexCharSetItem {
504
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6✔
505
        match self {
6✔
UNCOV
506
            Self::Char(c) => c.fmt(f),
×
UNCOV
507
            Self::Range { first, last } => write!(f, "{first:?}-{last:?}"),
×
508
            Self::Shorthand(s) => f.write_str(s.as_str()),
4✔
509
            &Self::Property { value, negative } => {
2✔
510
                if negative {
2✔
511
                    f.write_str("!")?;
1✔
512
                }
1✔
513
                f.write_str(value.as_str())
2✔
514
            }
515
        }
516
    }
6✔
517
}
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