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

pomsky-lang / pomsky / 12075733362

28 Nov 2024 09:40PM UTC coverage: 82.811% (-0.3%) from 83.091%
12075733362

push

github

Aloso
feat: Add RE2 and improve diagnostics

69 of 91 new or added lines in 12 files covered. (75.82%)

1 existing line in 1 file now uncovered.

4307 of 5201 relevant lines covered (82.81%)

413156.17 hits per line

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

92.18
/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::fmt;
80

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

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

95
use super::Compile;
96

97
impl Compile for CharClass {
98
    fn compile(&self, options: CompileOptions, _state: &mut CompileState<'_>) -> CompileResult {
234✔
99
        // when single, a `[!w]` can be turned into `![w]`
234✔
100
        let is_single = self.inner.len() == 1;
234✔
101
        let mut group_negative = false;
234✔
102

234✔
103
        let mut set = UnicodeSet::new();
234✔
104
        for item in &self.inner {
600✔
105
            match *item {
374✔
106
                GroupItem::Char(c) => {
204✔
107
                    if !is_single {
204✔
108
                        validate_char_in_class(c, options.flavor, self.span)?;
140✔
109
                    }
64✔
110
                    set.add_char(c)
204✔
111
                }
112
                GroupItem::Range { first, last } => {
30✔
113
                    validate_char_in_class(first, options.flavor, self.span)?;
30✔
114
                    validate_char_in_class(last, options.flavor, self.span)?;
30✔
115
                    set.add_range(first..=last);
30✔
116
                }
117
                GroupItem::Named { name, negative, span } => {
140✔
118
                    if self.unicode_aware {
140✔
119
                        named_class_to_regex_unicode(
110✔
120
                            name,
110✔
121
                            negative,
110✔
122
                            &mut group_negative,
110✔
123
                            is_single,
110✔
124
                            options.flavor,
110✔
125
                            span,
110✔
126
                            &mut set,
110✔
127
                        )?;
110✔
128
                    } else {
129
                        named_class_to_regex_ascii(name, negative, options.flavor, span, &mut set)?;
30✔
130
                    }
131
                }
132
            }
133
        }
134

135
        // this makes it possible to use code points outside the BMP in .NET,
136
        // as long as there is only one in the character set
137
        if let Some(only_char) = set.try_into_char() {
226✔
138
            return Ok(Regex::Literal(only_char.to_string()));
63✔
139
        }
163✔
140

163✔
141
        Ok(Regex::CharSet(RegexCharSet { negative: group_negative, set }))
163✔
142
    }
234✔
143
}
144

145
fn validate_char_in_class(char: char, flavor: RegexFlavor, span: Span) -> Result<(), CompileError> {
200✔
146
    if flavor == RegexFlavor::DotNet && char > '\u{FFFF}' {
200✔
147
        Err(CompileErrorKind::Unsupported(Feature::LargeCodePointInCharClass(char), flavor)
×
148
            .at(span))
×
149
    } else {
150
        Ok(())
200✔
151
    }
152
}
200✔
153

154
pub(crate) fn check_char_class_empty(
49✔
155
    char_set: &RegexCharSet,
49✔
156
    span: Span,
49✔
157
) -> Result<(), CompileError> {
49✔
158
    if char_set.negative {
49✔
159
        if let Some((group1, group2)) = char_set.set.full_props() {
48✔
160
            return Err(CompileErrorKind::EmptyClassNegated { group1, group2 }.at(span));
3✔
161
        }
45✔
162
    }
1✔
163
    Ok(())
46✔
164
}
49✔
165

166
pub fn is_ascii_only_in_flavor(group: GroupName, flavor: RegexFlavor) -> bool {
1✔
167
    match flavor {
1✔
NEW
168
        RegexFlavor::JavaScript => matches!(group, GroupName::Word | GroupName::Digit),
×
NEW
169
        RegexFlavor::RE2 => matches!(group, GroupName::Word | GroupName::Digit | GroupName::Space),
×
170
        _ => false,
1✔
171
    }
172
}
1✔
173

174
fn named_class_to_regex_ascii(
30✔
175
    group: GroupName,
30✔
176
    negative: bool,
30✔
177
    flavor: RegexFlavor,
30✔
178
    span: Span,
30✔
179
    set: &mut UnicodeSet,
30✔
180
) -> Result<(), CompileError> {
30✔
181
    // In JS, \W and \D can be used for negation because they're ascii-only
30✔
182
    // Same goes for \W, \D and \S in RE2
30✔
183
    if negative && !is_ascii_only_in_flavor(group, flavor) {
30✔
184
        return Err(CompileErrorKind::NegativeShorthandInAsciiMode.at(span));
1✔
185
    }
29✔
186

29✔
187
    match group {
29✔
188
        GroupName::Word => {
189
            if let RegexFlavor::JavaScript | RegexFlavor::RE2 = flavor {
8✔
190
                let s = if negative { RegexShorthand::NotWord } else { RegexShorthand::Word };
2✔
191
                set.add_prop(RegexCharSetItem::Shorthand(s));
2✔
192
            } else {
6✔
193
                // we already checked above if negative
6✔
194
                set.add_range('a'..='z');
6✔
195
                set.add_range('A'..='Z');
6✔
196
                set.add_range('0'..='9');
6✔
197
                set.add_char('_');
6✔
198
            }
6✔
199
        }
200
        GroupName::Digit => {
201
            if let RegexFlavor::JavaScript | RegexFlavor::RE2 = flavor {
12✔
202
                let s = if negative { RegexShorthand::NotDigit } else { RegexShorthand::Digit };
2✔
203
                set.add_prop(RegexCharSetItem::Shorthand(s));
2✔
204
            } else {
10✔
205
                // we already checked above if negative
10✔
206
                set.add_range('0'..='9');
10✔
207
            }
10✔
208
        }
209
        GroupName::Space => {
210
            if let RegexFlavor::RE2 = flavor {
8✔
211
                let s = if negative { RegexShorthand::NotSpace } else { RegexShorthand::Space };
1✔
212
                set.add_prop(RegexCharSetItem::Shorthand(s));
1✔
213
            } else {
7✔
214
                set.add_char(' ');
7✔
215
                set.add_range('\x09'..='\x0D'); // \t\n\v\f\r
7✔
216
            }
7✔
217
        }
218
        GroupName::HorizSpace => set.add_char('\t'),
×
219
        GroupName::VertSpace => set.add_range('\x0A'..='\x0D'),
×
220
        _ => return Err(CompileErrorKind::UnicodeInAsciiMode.at(span)),
1✔
221
    }
222
    Ok(())
28✔
223
}
30✔
224

225
fn named_class_to_regex_unicode(
110✔
226
    group: GroupName,
110✔
227
    negative: bool,
110✔
228
    group_negative: &mut bool,
110✔
229
    is_single: bool,
110✔
230
    flavor: RegexFlavor,
110✔
231
    span: Span,
110✔
232
    set: &mut UnicodeSet,
110✔
233
) -> Result<(), CompileError> {
110✔
234
    match group {
5✔
235
        GroupName::Word => {
236
            if flavor == RegexFlavor::RE2 {
22✔
NEW
237
                return Err(CompileErrorKind::Unsupported(Feature::ShorthandW, flavor).at(span));
×
238
            } else if flavor == RegexFlavor::JavaScript {
22✔
239
                if negative {
5✔
240
                    if is_single {
2✔
241
                        *group_negative ^= true;
1✔
242
                    } else {
1✔
243
                        return Err(CompileErrorKind::Unsupported(
1✔
244
                            Feature::NegativeShorthandW,
1✔
245
                            flavor,
1✔
246
                        )
1✔
247
                        .at(span));
1✔
248
                    }
249
                }
3✔
250
                set.add_prop(
4✔
251
                    RegexProperty::Other(OtherProperties::Alphabetic).negative_item(false),
4✔
252
                );
4✔
253
                set.add_prop(RegexProperty::Category(Category::Mark).negative_item(false));
4✔
254
                set.add_prop(
4✔
255
                    RegexProperty::Category(Category::Decimal_Number).negative_item(false),
4✔
256
                );
4✔
257
                set.add_prop(
4✔
258
                    RegexProperty::Category(Category::Connector_Punctuation).negative_item(false),
4✔
259
                );
4✔
260
            } else {
261
                let s = if negative { RegexShorthand::NotWord } else { RegexShorthand::Word };
17✔
262
                set.add_prop(RegexCharSetItem::Shorthand(s));
17✔
263
            }
264
        }
265
        GroupName::Digit => {
266
            if matches!(flavor, RegexFlavor::JavaScript | RegexFlavor::RE2) {
20✔
267
                set.add_prop(
8✔
268
                    RegexProperty::Category(Category::Decimal_Number).negative_item(negative),
8✔
269
                );
8✔
270
            } else {
8✔
271
                let s = if negative { RegexShorthand::NotDigit } else { RegexShorthand::Digit };
12✔
272
                set.add_prop(RegexCharSetItem::Shorthand(s));
12✔
273
            }
274
        }
275

276
        GroupName::Space => {
277
            if flavor == RegexFlavor::RE2 {
16✔
278
                if negative {
4✔
279
                    if is_single {
1✔
280
                        *group_negative ^= true;
1✔
281
                    } else {
1✔
NEW
282
                        return Err(CompileErrorKind::Unsupported(
×
NEW
283
                            Feature::NegativeShorthandS,
×
NEW
284
                            flavor,
×
NEW
285
                        )
×
NEW
286
                        .at(span));
×
287
                    }
288
                }
3✔
289

290
                // [ \f\n\r\t\u000b\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]
291
                set.add_prop(RegexCharSetItem::Shorthand(RegexShorthand::Space));
4✔
292
                set.add_char('\x0b');
4✔
293
                set.add_char('\u{a0}');
4✔
294
                set.add_char('\u{1680}');
4✔
295
                set.add_range('\u{2000}'..='\u{200a}');
4✔
296
                set.add_range('\u{2028}'..='\u{2029}');
4✔
297
                set.add_char('\u{202f}');
4✔
298
                set.add_char('\u{205f}');
4✔
299
                set.add_char('\u{3000}');
4✔
300
                set.add_char('\u{feff}');
4✔
301
            } else {
302
                set.add_prop(RegexCharSetItem::Shorthand(if negative {
12✔
303
                    RegexShorthand::NotSpace
3✔
304
                } else {
305
                    RegexShorthand::Space
9✔
306
                }))
307
            }
308
        }
309

310
        GroupName::HorizSpace | GroupName::VertSpace if negative => {
×
311
            return Err(CompileErrorKind::NegatedHorizVertSpace.at(span));
×
312
        }
313

314
        GroupName::HorizSpace | GroupName::VertSpace
315
            if matches!(flavor, RegexFlavor::Pcre | RegexFlavor::Java) =>
5✔
316
        {
317
            set.add_prop(RegexCharSetItem::Shorthand(if group == GroupName::HorizSpace {
6✔
318
                RegexShorthand::HorizSpace
3✔
319
            } else {
320
                RegexShorthand::VertSpace
3✔
321
            }));
322
        }
323
        GroupName::HorizSpace => {
324
            set.add_char('\t');
2✔
325
            if flavor == RegexFlavor::Python {
2✔
326
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
×
327
            } else {
2✔
328
                set.add_prop(
2✔
329
                    RegexProperty::Category(Category::Space_Separator).negative_item(false),
2✔
330
                );
2✔
331
            }
2✔
332
        }
333
        GroupName::VertSpace => {
2✔
334
            set.add_range('\x0A'..='\x0D');
2✔
335
            set.add_char('\u{85}');
2✔
336
            set.add_char('\u{2028}');
2✔
337
            set.add_char('\u{2029}');
2✔
338
        }
2✔
339

340
        _ if flavor == RegexFlavor::Python => {
42✔
341
            return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
2✔
342
        }
343
        GroupName::Category(c) => {
5✔
344
            if let (RegexFlavor::Rust, Category::Surrogate)
5✔
345
            | (RegexFlavor::DotNet | RegexFlavor::RE2, Category::Cased_Letter) = (flavor, c)
5✔
346
            {
347
                return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
348
            }
5✔
349
            set.add_prop(RegexProperty::Category(c).negative_item(negative));
5✔
350
        }
351
        GroupName::Script(s) => {
21✔
352
            if flavor == RegexFlavor::DotNet {
21✔
353
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeScript, flavor).at(span));
1✔
354
            }
20✔
355
            if let (RegexFlavor::Ruby, Script::Kawi | Script::Nag_Mundari)
20✔
356
            | (RegexFlavor::Rust, Script::Unknown) = (flavor, s)
20✔
357
            {
358
                return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
359
            }
20✔
360
            set.add_prop(RegexProperty::Script(s).negative_item(negative));
20✔
361
        }
362
        GroupName::CodeBlock(b) => match flavor {
7✔
363
            RegexFlavor::DotNet | RegexFlavor::Java | RegexFlavor::Ruby => {
364
                match (flavor, b) {
6✔
365
                    (RegexFlavor::Java, CodeBlock::No_Block)
366
                    | (
367
                        // These should work since Oniguruma updated to Unicode 15.1
368
                        // ... but our C bindings for Oniguruma are unmaintained!
369
                        RegexFlavor::Ruby,
370
                        CodeBlock::Arabic_Extended_C
371
                        | CodeBlock::CJK_Unified_Ideographs_Extension_H
372
                        | CodeBlock::Cyrillic_Extended_D
373
                        | CodeBlock::Devanagari_Extended_A
374
                        | CodeBlock::Kaktovik_Numerals,
375
                    ) => {
376
                        return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
×
377
                    }
378
                    (RegexFlavor::DotNet, _) => {
379
                        let dotnet_name = b.as_str().replace("_And_", "_and_").replace('_', "");
2✔
380
                        if pomsky_syntax::blocks_supported_in_dotnet()
2✔
381
                            .binary_search(&dotnet_name.as_str())
2✔
382
                            .is_err()
2✔
383
                        {
384
                            return Err(
×
385
                                CompileErrorKind::unsupported_specific_prop_in(flavor).at(span)
×
386
                            );
×
387
                        }
2✔
388
                    }
389
                    _ => {}
4✔
390
                }
391

392
                set.add_prop(RegexProperty::Block(b).negative_item(negative));
6✔
393
            }
394
            _ => return Err(CompileErrorKind::Unsupported(Feature::UnicodeBlock, flavor).at(span)),
1✔
395
        },
396
        GroupName::OtherProperties(o) => {
7✔
397
            use OtherProperties as OP;
398
            use RegexFlavor as RF;
399

400
            if let RF::JavaScript | RF::Rust | RF::Pcre | RF::Ruby = flavor {
7✔
401
                match (flavor, o) {
7✔
402
                    (RF::JavaScript, _) => {}
4✔
403
                    (_, OP::Changes_When_NFKC_Casefolded)
404
                    | (RF::Pcre, OP::Assigned)
405
                    | (RF::Ruby, OP::Bidi_Mirrored) => {
406
                        return Err(CompileErrorKind::unsupported_specific_prop_in(flavor).at(span));
1✔
407
                    }
408
                    _ => {}
2✔
409
                }
410
                set.add_prop(RegexProperty::Other(o).negative_item(negative));
6✔
411
            } else {
412
                return Err(CompileErrorKind::Unsupported(Feature::UnicodeProp, flavor).at(span));
×
413
            }
414
        }
415
    }
416
    Ok(())
104✔
417
}
110✔
418

419
#[cfg_attr(feature = "dbg", derive(Debug))]
420
#[derive(Default)]
421
pub(crate) struct RegexCharSet {
422
    pub(crate) negative: bool,
423
    pub(crate) set: UnicodeSet,
424
}
425

426
impl RegexCharSet {
427
    pub(crate) fn new(items: UnicodeSet) -> Self {
244✔
428
        Self { negative: false, set: items }
244✔
429
    }
244✔
430

431
    pub(crate) fn negate(mut self) -> Self {
49✔
432
        self.negative = !self.negative;
49✔
433
        self
49✔
434
    }
49✔
435

436
    pub(crate) fn codegen(&self, buf: &mut String, flavor: RegexFlavor) {
401✔
437
        if self.set.len() == 1 {
401✔
438
            if let Some(range) = self.set.ranges().next() {
308✔
439
                let (first, last) = range.as_chars();
242✔
440
                if first == last && !self.negative {
242✔
441
                    return literal::codegen_char_esc(first, buf, flavor);
75✔
442
                }
167✔
443
            } else if let Some(prop) = self.set.props().next() {
66✔
444
                match prop {
66✔
445
                    RegexCharSetItem::Shorthand(s) => {
27✔
446
                        let shorthand = if self.negative { s.negate() } else { Some(s) };
27✔
447
                        if let Some(shorthand) = shorthand {
27✔
448
                            return shorthand.codegen(buf);
25✔
449
                        }
2✔
450
                    }
451
                    RegexCharSetItem::Property { negative, value } => {
39✔
452
                        return value.codegen(buf, negative ^ self.negative, flavor);
39✔
453
                    }
454
                }
455
            }
×
456
        }
93✔
457

458
        if self.negative {
262✔
459
            buf.push_str("[^");
30✔
460
        } else {
232✔
461
            buf.push('[');
232✔
462
        }
232✔
463

464
        let mut is_first = true;
262✔
465
        for prop in self.set.props() {
262✔
466
            match prop {
76✔
467
                RegexCharSetItem::Shorthand(s) => s.codegen(buf),
55✔
468
                RegexCharSetItem::Property { negative, value } => {
21✔
469
                    value.codegen(buf, negative, flavor);
21✔
470
                }
21✔
471
            }
472
            is_first = false;
76✔
473
        }
474
        for range in self.set.ranges() {
378✔
475
            let (first, last) = range.as_chars();
378✔
476
            if first == last {
378✔
477
                literal::compile_char_esc_in_class(first, buf, is_first, flavor);
119✔
478
            } else {
119✔
479
                literal::compile_char_esc_in_class(first, buf, is_first, flavor);
259✔
480
                if range.first + 1 < range.last {
259✔
481
                    buf.push('-');
207✔
482
                }
207✔
483
                literal::compile_char_esc_in_class(last, buf, false, flavor);
259✔
484
            }
485
            is_first = false;
378✔
486
        }
487

488
        buf.push(']');
262✔
489
    }
401✔
490
}
491

492
#[derive(Clone, Copy, PartialEq, Eq)]
493
pub(crate) enum RegexCharSetItem {
494
    Shorthand(RegexShorthand),
495
    Property { negative: bool, value: RegexProperty },
496
}
497

498
impl RegexCharSetItem {
499
    pub(crate) fn negate(self) -> Option<Self> {
47✔
500
        match self {
47✔
501
            RegexCharSetItem::Shorthand(s) => s.negate().map(RegexCharSetItem::Shorthand),
21✔
502
            RegexCharSetItem::Property { negative, value } => {
26✔
503
                Some(RegexCharSetItem::Property { negative: !negative, value })
26✔
504
            }
505
        }
506
    }
47✔
507
}
508

509
impl fmt::Debug for RegexCharSetItem {
510
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
6✔
511
        match self {
6✔
512
            Self::Shorthand(s) => f.write_str(s.as_str()),
4✔
513
            &Self::Property { value, negative } => {
2✔
514
                if negative {
2✔
515
                    f.write_str("!")?;
1✔
516
                }
1✔
517
                f.write_str(value.as_str())
2✔
518
            }
519
        }
520
    }
6✔
521
}
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

© 2025 Coveralls, Inc