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

codenotary / immudb / 18658094170

20 Oct 2025 04:13PM UTC coverage: 89.267% (+0.002%) from 89.265%
18658094170

Pull #2076

gh-ci

els-tmiller
fix spacing
Pull Request #2076: S3 Storage - Fargate Credentials

16 of 17 new or added lines in 4 files covered. (94.12%)

452 existing lines in 4 files now uncovered.

37974 of 42540 relevant lines covered (89.27%)

149951.18 hits per line

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

83.21
/embedded/sql/parser.go
1
/*
2
Copyright 2025 Codenotary Inc. All rights reserved.
3

4
SPDX-License-Identifier: BUSL-1.1
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    https://mariadb.com/bsl11/
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package sql
18

19
import (
20
        "bytes"
21
        "encoding/hex"
22
        "errors"
23
        "fmt"
24
        "io"
25
        "strconv"
26
        "strings"
27
)
28

29
//go:generate go run golang.org/x/tools/cmd/goyacc -l -o sql_parser.go sql_grammar.y
30

31
var reservedWords = map[string]int{
32
        "CREATE":         CREATE,
33
        "DROP":           DROP,
34
        "USE":            USE,
35
        "DATABASE":       DATABASE,
36
        "SNAPSHOT":       SNAPSHOT,
37
        "HISTORY":        HISTORY,
38
        "OF":             OF,
39
        "SINCE":          SINCE,
40
        "AFTER":          AFTER,
41
        "BEFORE":         BEFORE,
42
        "UNTIL":          UNTIL,
43
        "TABLE":          TABLE,
44
        "PRIMARY":        PRIMARY,
45
        "KEY":            KEY,
46
        "UNIQUE":         UNIQUE,
47
        "INDEX":          INDEX,
48
        "ON":             ON,
49
        "ALTER":          ALTER,
50
        "ADD":            ADD,
51
        "RENAME":         RENAME,
52
        "TO":             TO,
53
        "COLUMN":         COLUMN,
54
        "INSERT":         INSERT,
55
        "CONFLICT":       CONFLICT,
56
        "DO":             DO,
57
        "NOTHING":        NOTHING,
58
        "RETURNING":      RETURNING,
59
        "UPSERT":         UPSERT,
60
        "INTO":           INTO,
61
        "VALUES":         VALUES,
62
        "UPDATE":         UPDATE,
63
        "SET":            SET,
64
        "DELETE":         DELETE,
65
        "BEGIN":          BEGIN,
66
        "TRANSACTION":    TRANSACTION,
67
        "COMMIT":         COMMIT,
68
        "ROLLBACK":       ROLLBACK,
69
        "SELECT":         SELECT,
70
        "DISTINCT":       DISTINCT,
71
        "FROM":           FROM,
72
        "UNION":          UNION,
73
        "ALL":            ALL,
74
        "TX":             TX,
75
        "JOIN":           JOIN,
76
        "HAVING":         HAVING,
77
        "WHERE":          WHERE,
78
        "GROUP":          GROUP,
79
        "BY":             BY,
80
        "LIMIT":          LIMIT,
81
        "OFFSET":         OFFSET,
82
        "ORDER":          ORDER,
83
        "AS":             AS,
84
        "ASC":            ASC,
85
        "DESC":           DESC,
86
        "AND":            AND,
87
        "OR":             OR,
88
        "NOT":            NOT,
89
        "LIKE":           LIKE,
90
        "EXISTS":         EXISTS,
91
        "BETWEEN":        BETWEEN,
92
        "IN":             IN,
93
        "AUTO_INCREMENT": AUTO_INCREMENT,
94
        "NULL":           NULL,
95
        "IF":             IF,
96
        "IS":             IS,
97
        "CAST":           CAST,
98
        "::":             SCAST,
99
        "SHOW":           SHOW,
100
        "DATABASES":      DATABASES,
101
        "TABLES":         TABLES,
102
        "USERS":          USERS,
103
        "USER":           USER,
104
        "WITH":           WITH,
105
        "PASSWORD":       PASSWORD,
106
        "READ":           READ,
107
        "READWRITE":      READWRITE,
108
        "ADMIN":          ADMIN,
109
        "GRANT":          GRANT,
110
        "REVOKE":         REVOKE,
111
        "GRANTS":         GRANTS,
112
        "FOR":            FOR,
113
        "PRIVILEGES":     PRIVILEGES,
114
        "CHECK":          CHECK,
115
        "CONSTRAINT":     CONSTRAINT,
116
        "CASE":           CASE,
117
        "WHEN":           WHEN,
118
        "THEN":           THEN,
119
        "ELSE":           ELSE,
120
        "END":            END,
121
}
122

123
var joinTypes = map[string]JoinType{
124
        "INNER": InnerJoin,
125
        "LEFT":  LeftJoin,
126
        "RIGHT": RightJoin,
127
}
128

129
var types = map[string]SQLValueType{
130
        "INTEGER":   IntegerType,
131
        "BOOLEAN":   BooleanType,
132
        "VARCHAR":   VarcharType,
133
        "UUID":      UUIDType,
134
        "BLOB":      BLOBType,
135
        "TIMESTAMP": TimestampType,
136
        "FLOAT":     Float64Type,
137
        "JSON":      JSONType,
138
}
139

140
var aggregateFns = map[string]AggregateFn{
141
        "COUNT": COUNT,
142
        "SUM":   SUM,
143
        "MAX":   MAX,
144
        "MIN":   MIN,
145
        "AVG":   AVG,
146
}
147

148
var boolValues = map[string]bool{
149
        "TRUE":  true,
150
        "FALSE": false,
151
}
152

153
var cmpOps = map[string]CmpOperator{
154
        "=":  EQ,
155
        "!=": NE,
156
        "<>": NE,
157
        "<":  LT,
158
        "<=": LE,
159
        ">":  GT,
160
        ">=": GE,
161
}
162

163
var ErrEitherNamedOrUnnamedParams = errors.New("either named or unnamed params")
164
var ErrEitherPosOrNonPosParams = errors.New("either positional or non-positional named params")
165
var ErrInvalidPositionalParameter = errors.New("invalid positional parameter")
166

167
type positionalParamType int
168

169
const (
170
        NamedNonPositionalParamType positionalParamType = iota + 1
171
        NamedPositionalParamType
172
        UnnamedParamType
173
)
174

175
type lexer struct {
176
        r               *aheadByteReader
177
        err             error
178
        namedParamsType positionalParamType
179
        paramsCount     int
180
        result          []SQLStmt
181
}
182

183
type aheadByteReader struct {
184
        nextChar  byte
185
        nextErr   error
186
        r         io.ByteReader
187
        readCount int
188
}
189

190
func newAheadByteReader(r io.ByteReader) *aheadByteReader {
191
        ar := &aheadByteReader{r: r}
192
        ar.nextChar, ar.nextErr = r.ReadByte()
193
        return ar
194
}
4,308✔
195

4,308✔
196
func (ar *aheadByteReader) ReadByte() (byte, error) {
4,308✔
197
        defer func() {
4,308✔
198
                if ar.nextErr == nil {
4,308✔
199
                        ar.nextChar, ar.nextErr = ar.r.ReadByte()
200
                }
310,753✔
201
        }()
621,506✔
202

617,445✔
203
        ar.readCount++
306,692✔
204

306,692✔
205
        return ar.nextChar, ar.nextErr
206
}
207

310,753✔
208
func (ar *aheadByteReader) ReadCount() int {
310,753✔
209
        return ar.readCount
310,753✔
210
}
211

212
func (ar *aheadByteReader) NextByte() (byte, error) {
151✔
213
        return ar.nextChar, ar.nextErr
151✔
214
}
151✔
215

216
func ParseSQLString(sql string) ([]SQLStmt, error) {
246,910✔
217
        return ParseSQL(strings.NewReader(sql))
246,910✔
218
}
246,910✔
219

220
func ParseSQL(r io.ByteReader) ([]SQLStmt, error) {
1,150✔
221
        lexer := newLexer(r)
1,150✔
222

1,150✔
223
        yyParse(lexer)
224

4,308✔
225
        return lexer.result, lexer.err
4,308✔
226
}
4,308✔
227

4,308✔
228
func ParseExpFromString(exp string) (ValueExp, error) {
4,308✔
229
        stmt := fmt.Sprintf("SELECT * FROM t WHERE %s", exp)
4,308✔
230

4,308✔
231
        res, err := ParseSQLString(stmt)
232
        if err != nil {
216✔
233
                return nil, err
216✔
234
        }
216✔
235

216✔
236
        s := res[0].(*SelectStmt)
216✔
UNCOV
237
        return s.where, nil
×
UNCOV
238
}
×
239

240
func newLexer(r io.ByteReader) *lexer {
216✔
241
        return &lexer{
216✔
242
                r:   newAheadByteReader(r),
243
                err: nil,
244
        }
4,308✔
245
}
4,308✔
246

4,308✔
247
func (l *lexer) Lex(lval *yySymType) int {
4,308✔
248
        var ch byte
4,308✔
249
        var err error
4,308✔
250

251
        for {
69,139✔
252
                ch, err = l.r.ReadByte()
69,139✔
253
                if err == io.EOF {
69,139✔
254
                        return 0
69,139✔
255
                }
177,617✔
256
                if err != nil {
108,478✔
257
                        lval.err = err
112,539✔
258
                        return ERROR
4,061✔
259
                }
4,061✔
260

104,417✔
UNCOV
261
                if ch == '\t' {
×
UNCOV
262
                        continue
×
UNCOV
263
                }
×
264

265
                if ch == '/' && l.r.nextChar == '*' {
108,930✔
266
                        l.r.ReadByte()
4,513✔
267

268
                        for {
269
                                ch, err := l.r.ReadByte()
99,907✔
270
                                if err == io.EOF {
3✔
271
                                        break
3✔
272
                                }
102✔
273
                                if err != nil {
99✔
274
                                        lval.err = err
99✔
275
                                        return ERROR
×
276
                                }
277

99✔
UNCOV
278
                                if ch == '*' && l.r.nextChar == '/' {
×
UNCOV
279
                                        l.r.ReadByte() // consume closing slash
×
UNCOV
280
                                        break
×
281
                                }
282
                        }
102✔
283

3✔
284
                        continue
3✔
285
                }
286

287
                if isLineBreak(ch) {
288
                        if ch == '\r' && l.r.nextChar == '\n' {
3✔
289
                                l.r.ReadByte()
290
                        }
291
                        continue
101,237✔
292
                }
1,337✔
293

1✔
294
                if !isSpace(ch) {
1✔
295
                        break
1,336✔
296
                }
297
        }
298

163,643✔
299
        if isSeparator(ch) {
65,078✔
300
                return STMT_SEPARATOR
301
        }
302

303
        if ch == '-' && l.r.nextChar == '>' {
65,559✔
304
                l.r.ReadByte()
481✔
305
                return ARROW
481✔
306
        }
307

64,655✔
308
        if isBLOBPrefix(ch) && isQuote(l.r.nextChar) {
58✔
309
                l.r.ReadByte() // consume starting quote
58✔
310

58✔
311
                tail, err := l.readString()
312
                if err != nil {
64,633✔
313
                        lval.err = err
94✔
314
                        return ERROR
94✔
315
                }
94✔
316

94✔
UNCOV
317
                val, err := hex.DecodeString(tail)
×
UNCOV
318
                if err != nil {
×
319
                        lval.err = err
×
320
                        return ERROR
321
                }
94✔
322

94✔
UNCOV
323
                lval.blob = val
×
UNCOV
324
                return BLOB
×
UNCOV
325
        }
×
326

327
        if isLetter(ch) {
94✔
328
                tail, err := l.readWord()
94✔
329
                if err != nil {
330
                        lval.err = err
331
                        return ERROR
95,901✔
332
                }
31,456✔
333

31,456✔
UNCOV
334
                w := fmt.Sprintf("%c%s", ch, tail)
×
UNCOV
335
                tid := strings.ToUpper(w)
×
UNCOV
336

×
337
                sqlType, ok := types[tid]
338
                if ok {
31,456✔
339
                        lval.sqlType = sqlType
31,456✔
340
                        return TYPE
31,456✔
341
                }
31,456✔
342

31,700✔
343
                val, ok := boolValues[tid]
244✔
344
                if ok {
244✔
345
                        lval.boolean = val
244✔
346
                        return BOOLEAN
347
                }
31,212✔
348

31,345✔
349
                afn, ok := aggregateFns[tid]
133✔
350
                if ok {
133✔
351
                        lval.aggFn = afn
133✔
352
                        return AGGREGATE_FUNC
353
                }
31,079✔
354

31,100✔
355
                join, ok := joinTypes[tid]
21✔
356
                if ok {
21✔
357
                        lval.joinType = join
21✔
358
                        return JOINTYPE
359
                }
31,058✔
360

48,130✔
361
                tkn, ok := reservedWords[tid]
17,072✔
362
                if ok {
17,072✔
363
                        return tkn
17,072✔
364
                }
365

13,986✔
366
                lval.id = strings.ToLower(w)
13,986✔
367

368
                return IDENTIFIER
369
        }
32,996✔
370

7✔
371
        if isDoubleQuote(ch) {
7✔
UNCOV
372
                tail, err := l.readWord()
×
UNCOV
373
                if err != nil {
×
374
                        lval.err = err
×
375
                        return ERROR
376
                }
8✔
377

1✔
378
                if !isDoubleQuote(l.r.nextChar) {
1✔
379
                        lval.err = fmt.Errorf("double quote expected")
1✔
380
                        return ERROR
381
                }
6✔
382

6✔
383
                l.r.ReadByte() // consume ending quote
6✔
384

6✔
385
                lval.id = strings.ToLower(tail)
386
                return IDENTIFIER
387
        }
34,052✔
388

1,070✔
389
        if isNumber(ch) {
1,070✔
UNCOV
390
                tail, err := l.readNumber()
×
UNCOV
391
                if err != nil {
×
392
                        lval.err = err
×
393
                        return ERROR
394
                }
1,136✔
395
                // looking for a float
66✔
396
                if isDot(l.r.nextChar) {
66✔
397
                        l.r.ReadByte() // consume dot
66✔
398

66✔
UNCOV
399
                        decimalPart, err := l.readNumber()
×
UNCOV
400
                        if err != nil {
×
401
                                lval.err = err
×
402
                                return ERROR
403
                        }
66✔
404

67✔
405
                        val, err := strconv.ParseFloat(fmt.Sprintf("%c%s.%s", ch, tail, decimalPart), 64)
1✔
406
                        if err != nil {
1✔
407
                                lval.err = err
1✔
408
                                return ERROR
409
                        }
65✔
410

65✔
411
                        lval.float = val
412
                        return FLOAT
413
                }
1,004✔
414

1,004✔
UNCOV
415
                val, err := strconv.ParseUint(fmt.Sprintf("%c%s", ch, tail), 10, 64)
×
UNCOV
416
                if err != nil {
×
417
                        lval.err = err
×
418
                        return ERROR
419
                }
1,004✔
420

1,004✔
421
                lval.integer = val
422
                return INTEGER
423
        }
32,385✔
424

473✔
425
        if isComparison(ch) {
473✔
UNCOV
426
                tail, err := l.readComparison()
×
UNCOV
427
                if err != nil {
×
428
                        lval.err = err
×
429
                        return ERROR
430
                }
473✔
431

474✔
432
                op := fmt.Sprintf("%c%s", ch, tail)
1✔
433
                if op == "!~" {
1✔
434
                        return NOT_MATCHES_OP
435
                }
472✔
436

472✔
UNCOV
437
                cmpOp, ok := cmpOps[op]
×
UNCOV
438
                if !ok {
×
439
                        lval.err = fmt.Errorf("invalid comparison operator %s", op)
×
440
                        return ERROR
441
                }
472✔
442

472✔
443
                lval.cmpOp = cmpOp
444
                return CMPOP
445
        }
32,334✔
446

895✔
447
        if isQuote(ch) {
895✔
UNCOV
448
                tail, err := l.readString()
×
UNCOV
449
                if err != nil {
×
450
                        lval.err = err
×
451
                        return ERROR
452
                }
895✔
453

895✔
454
                lval.str = tail
455
                return VARCHAR
456
        }
30,570✔
457

26✔
458
        if ch == ':' {
26✔
UNCOV
459
                ch, err := l.r.ReadByte()
×
UNCOV
460
                if err != nil {
×
461
                        lval.err = err
×
462
                        return ERROR
463
                }
26✔
UNCOV
464

×
UNCOV
465
                if ch != ':' {
×
466
                        lval.err = fmt.Errorf("colon expected")
×
467
                        return ERROR
468
                }
26✔
469

470
                return SCAST
471
        }
36,458✔
472

5,941✔
473
        if ch == '@' {
1✔
474
                if l.namedParamsType == UnnamedParamType {
1✔
475
                        lval.err = ErrEitherNamedOrUnnamedParams
1✔
476
                        return ERROR
477
                }
5,940✔
478

1✔
479
                if l.namedParamsType == NamedPositionalParamType {
1✔
480
                        lval.err = ErrEitherPosOrNonPosParams
1✔
481
                        return ERROR
482
                }
5,938✔
483

5,938✔
484
                l.namedParamsType = NamedNonPositionalParamType
5,938✔
485

5,938✔
UNCOV
486
                ch, err := l.r.NextByte()
×
UNCOV
487
                if err != nil {
×
488
                        lval.err = err
×
489
                        return ERROR
490
                }
5,938✔
UNCOV
491

×
UNCOV
492
                if !isLetter(ch) {
×
493
                        return ERROR
494
                }
5,938✔
495

5,938✔
UNCOV
496
                id, err := l.readWord()
×
UNCOV
497
                if err != nil {
×
498
                        lval.err = err
×
499
                        return ERROR
500
                }
5,938✔
501

5,938✔
502
                lval.id = strings.ToLower(id)
5,938✔
503

504
                return NPARAM
505
        }
24,620✔
506

43✔
507
        if ch == '$' {
1✔
508
                if l.namedParamsType == UnnamedParamType {
1✔
509
                        lval.err = ErrEitherNamedOrUnnamedParams
1✔
510
                        return ERROR
511
                }
42✔
512

1✔
513
                if l.namedParamsType == NamedNonPositionalParamType {
1✔
514
                        lval.err = ErrEitherPosOrNonPosParams
1✔
515
                        return ERROR
516
                }
40✔
517

40✔
UNCOV
518
                id, err := l.readNumber()
×
UNCOV
519
                if err != nil {
×
520
                        lval.err = err
×
521
                        return ERROR
522
                }
40✔
523

41✔
524
                pid, err := strconv.Atoi(id)
1✔
525
                if err != nil {
1✔
526
                        lval.err = err
1✔
527
                        return ERROR
528
                }
40✔
529

1✔
530
                if pid < 1 {
1✔
531
                        lval.err = ErrInvalidPositionalParameter
1✔
532
                        return ERROR
533
                }
38✔
534

38✔
535
                lval.pparam = pid
38✔
536

38✔
537
                l.namedParamsType = NamedPositionalParamType
38✔
538

539
                return PPARAM
540
        }
24,675✔
541

141✔
542
        if ch == '?' {
2✔
543
                if l.namedParamsType == NamedNonPositionalParamType || l.namedParamsType == NamedPositionalParamType {
2✔
544
                        lval.err = ErrEitherNamedOrUnnamedParams
2✔
545
                        return ERROR
546
                }
137✔
547

137✔
548
                l.paramsCount++
137✔
549
                lval.pparam = l.paramsCount
137✔
550

137✔
551
                l.namedParamsType = UnnamedParamType
137✔
552

553
                return PPARAM
554
        }
24,625✔
555

233✔
556
        if isDot(ch) {
5✔
557
                if isNumber(l.r.nextChar) { // looking for  a float
5✔
UNCOV
558
                        decimalPart, err := l.readNumber()
×
UNCOV
559
                        if err != nil {
×
560
                                lval.err = err
×
561
                                return ERROR
5✔
562
                        }
5✔
UNCOV
563
                        val, err := strconv.ParseFloat(fmt.Sprintf("%d.%s", 0, decimalPart), 64)
×
UNCOV
564
                        if err != nil {
×
565
                                lval.err = err
×
566
                                return ERROR
5✔
567
                        }
5✔
568
                        lval.float = val
569
                        return FLOAT
223✔
570
                }
571
                return DOT
572
        }
24,169✔
573

574
        return int(ch)
575
}
151✔
576

151✔
577
func (l *lexer) Error(err string) {
151✔
578
        l.err = fmt.Errorf("%s at position %d", err, l.r.ReadCount())
579
}
37,401✔
580

239,615✔
581
func (l *lexer) readWord() (string, error) {
202,214✔
582
        return l.readWhile(func(ch byte) bool {
202,214✔
583
                return isLetter(ch) || isNumber(ch)
584
        })
585
}
1,181✔
586

1,181✔
587
func (l *lexer) readNumber() (string, error) {
1,181✔
588
        return l.readWhile(isNumber)
589
}
989✔
590

989✔
591
func (l *lexer) readString() (string, error) {
989✔
592
        var b bytes.Buffer
26,469✔
593

25,480✔
594
        for {
25,480✔
UNCOV
595
                ch, err := l.r.ReadByte()
×
UNCOV
596
                if err != nil {
×
597
                        return "", err
598
                }
25,480✔
599

25,480✔
600
                nextCh, _ := l.r.NextByte()
26,471✔
601

993✔
602
                if isQuote(ch) {
2✔
603
                        if isQuote(nextCh) {
991✔
604
                                l.r.ReadByte() // consume escaped quote
989✔
605
                        } else {
606
                                break // string completely read
607
                        }
608
                }
24,491✔
609

610
                b.WriteByte(ch)
611
        }
989✔
612

613
        return b.String(), nil
614
}
473✔
615

1,086✔
616
func (l *lexer) readComparison() (string, error) {
613✔
617
        return l.readWhile(func(ch byte) bool {
613✔
618
                return isComparison(ch)
619
        })
620
}
39,055✔
621

39,055✔
622
func (l *lexer) readWhile(condFn func(b byte) bool) (string, error) {
39,055✔
623
        var b bytes.Buffer
254,547✔
624

215,492✔
625
        for {
216,429✔
626
                ch, err := l.r.NextByte()
937✔
627
                if err == io.EOF {
628
                        break
214,555✔
UNCOV
629
                }
×
UNCOV
630
                if err != nil {
×
631
                        return "", err
632
                }
252,673✔
633

38,118✔
634
                if !condFn(ch) {
635
                        break
636
                }
176,437✔
637

176,437✔
638
                ch, _ = l.r.ReadByte()
639
                b.WriteByte(ch)
640
        }
39,055✔
641

642
        return b.String(), nil
643
}
64,539✔
644

64,539✔
645
func isBLOBPrefix(ch byte) bool {
64,539✔
646
        return ch == 'x'
647
}
65,078✔
648

65,078✔
649
func isSeparator(ch byte) bool {
65,078✔
650
        return ch == ';'
651
}
99,901✔
652

99,901✔
653
func isLineBreak(ch byte) bool {
99,901✔
654
        return ch == '\r' || ch == '\n'
655
}
98,565✔
656

98,565✔
657
func isSpace(ch byte) bool {
98,565✔
658
        return ch == 32 || ch == 9 //SPACE or TAB
659
}
84,450✔
660

84,450✔
661
func isNumber(ch byte) bool {
84,450✔
662
        return '0' <= ch && ch <= '9'
663
}
272,597✔
664

272,597✔
665
func isLetter(ch byte) bool {
272,597✔
666
        return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'
667
}
32,525✔
668

32,525✔
669
func isComparison(ch byte) bool {
32,525✔
670
        return ch == '!' || ch == '<' || ch == '=' || ch == '>' || ch == '~'
671
}
58,007✔
672

58,007✔
673
func isQuote(ch byte) bool {
58,007✔
674
        return ch == 0x27
675
}
32,996✔
676

32,996✔
677
func isDoubleQuote(ch byte) bool {
32,996✔
678
        return ch == 0x22
679
}
25,467✔
680

25,467✔
681
func isDot(ch byte) bool {
25,467✔
682
        return ch == '.'
683
}
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