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

ahamez / protox / 4f4324cb5c77a27adb46b9935e5d048f43a443e6

11 Feb 2025 03:25PM UTC coverage: 94.639% (-0.5%) from 95.188%
4f4324cb5c77a27adb46b9935e5d048f43a443e6

push

github

ahamez
refactor: inline encoding of oneof childrean

Benchmark show a slight speed increase for most of the cases

12 of 15 new or added lines in 1 file covered. (80.0%)

11 existing lines in 3 files now uncovered.

812 of 858 relevant lines covered (94.64%)

12441.21 hits per line

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

96.03
/lib/protox/define_decoder.ex
1
defmodule Protox.DefineDecoder do
2
  @moduledoc false
3
  # Internal. Generates the decoder of a message.
4

5
  alias Protox.{Field, OneOf, Scalar}
6
  use Protox.{Float, WireTypes}
7

8
  def define(msg_name, fields, opts \\ []) do
9
    vars = %{
90✔
10
      bytes: Macro.var(:bytes, __MODULE__),
11
      delimited: Macro.var(:delimited, __MODULE__),
12
      field: Macro.var(:field, __MODULE__),
13
      msg: Macro.var(:msg, __MODULE__),
14
      rest: Macro.var(:rest, __MODULE__),
15
      set_fields: Macro.var(:set_fields, __MODULE__),
16
      value: Macro.var(:value, __MODULE__)
17
    }
18

19
    # The public function to decode the binary protobuf.
20
    decode_fun = make_decode_fun(msg_name, vars)
90✔
21

22
    # The function that decodes the binary protobuf and possibly dispatches to other decoding
23
    # functions.
24
    parse_key_value_fun = make_parse_key_value_fun(fields, vars, opts)
90✔
25

26
    # The functions that decodes maps.
27
    parse_map_entries = make_parse_map_entries_funs(vars, fields)
90✔
28

29
    quote do
30
      unquote(decode_fun)
31
      unquote(parse_key_value_fun)
32
      unquote_splicing(parse_map_entries)
33
    end
34
  end
35

36
  defp make_decode_fun(msg_name, vars) do
37
    decode_bang_fun = make_decode_bang_fun(msg_name, vars)
90✔
38

39
    quote do
40
      @spec decode(binary()) :: {:ok, t()} | {:error, any()}
41
      def decode(bytes) do
42
        try do
43
          {:ok, decode!(bytes)}
44
        rescue
45
          e in [Protox.DecodingError, Protox.IllegalTagError, Protox.RequiredFieldsError] ->
46
            {:error, e}
47
        end
48
      end
49

50
      unquote(decode_bang_fun)
51
    end
52
  end
53

54
  defp make_decode_bang_fun(msg_name, _vars) do
55
    quote do
56
      @spec decode!(binary()) :: t() | no_return()
57
      def decode!(bytes) do
58
        parse_key_value(bytes, struct(unquote(msg_name)))
59
      end
60
    end
61
  end
62

63
  defp make_parse_key_value_fun(fields, vars, opts) do
64
    parse_key_value_body =
90✔
65
      make_parse_key_value_body(fields, vars, opts)
66

67
    quote do
68
      @spec parse_key_value(binary(), struct()) :: struct()
69
      defp parse_key_value(<<>>, msg), do: msg
70

71
      defp parse_key_value(bytes, msg), do: unquote(parse_key_value_body)
72
    end
73
  end
74

75
  defp make_parse_key_value_body(fields, vars, opts) do
76
    # Fragment to parse unknown fields. Those are identified with an unknown tag.
77
    unknown_tag_clause =
90✔
78
      make_parse_key_value_unknown(vars, Keyword.fetch!(opts, :unknown_fields_name))
79

80
    # Fragment to parse all regular fields.
81
    all_fields_clause = make_parse_key_value_known(vars, fields)
90✔
82

83
    all_clauses =
90✔
84
      make_parse_key_value_invalid_varint() ++
85
        make_parse_key_value_tag_0() ++
86
        all_fields_clause ++
87
        unknown_tag_clause
88

89
    # Note we directly pattern-match against the bytes: we don't decode the tag
90
    # and the wire type using Varint.decode. Indeed, as we know the varint encoding
91
    # at compile time, we can generate the appropriate clauses.
92
    # This has the benefit of a small speedup (~1%-10%) and a decrease in memory usage (~10%) from
93
    # the Varint.decode version.
94
    quote do
95
      {unquote(vars.field), rest} =
90✔
96
        case bytes, do: unquote(all_clauses)
97

98
      msg_updated = struct(unquote(vars.msg), unquote(vars.field))
90✔
99
      parse_key_value(rest, msg_updated)
100
    end
101
  end
102

103
  defp make_parse_key_value_tag_0() do
90✔
104
    quote do
105
      <<0::5, _::3, _rest::binary>> -> raise %Protox.IllegalTagError{}
106
    end
107
  end
108

109
  defp make_parse_key_value_invalid_varint() do
90✔
110
    quote do
111
      <<_::5, 3::3, _rest::binary>> ->
112
        raise Protox.DecodingError.new(bytes, "invalid wire type 3")
113

114
      <<_::5, 4::3, _rest::binary>> ->
115
        raise Protox.DecodingError.new(bytes, "invalid wire type 4")
116

117
      <<_::5, 6::3, _rest::binary>> ->
118
        raise Protox.DecodingError.new(bytes, "invalid wire type 6")
119

120
      <<_::5, 7::3, _rest::binary>> ->
121
        raise Protox.DecodingError.new(bytes, "invalid wire type 7")
122
    end
123
  end
124

125
  defp make_parse_key_value_known(vars, fields) do
126
    Enum.flat_map(fields, fn %Field{} = field ->
90✔
127
      single = make_single_case(vars, field)
975✔
128

129
      single_generated = single != []
975✔
130
      delimited = make_delimited_case(vars, single_generated, field)
975✔
131

132
      delimited ++ single
975✔
133
    end)
134
  end
135

136
  defp make_parse_key_value_unknown(vars, unknown_fields_name) do
137
    body =
90✔
138
      quote do
139
        {
140
          unquote(unknown_fields_name),
141
          # Order is important here, we want to keep the order of the unknown fields.
142
          unquote(vars.msg).unquote(unknown_fields_name) ++ [unquote(vars.value)]
90✔
143
        }
144
      end
145

146
    quote do
147
      <<unquote(vars.bytes)::binary>> ->
90✔
148
        {tag, wire_type, rest} = Protox.Decode.parse_key(unquote(vars.bytes))
90✔
149
        {unquote(vars.value), rest} = Protox.Decode.parse_unknown(tag, wire_type, rest)
90✔
150

151
        {[unquote(body)], rest}
152
    end
153
  end
154

155
  defp make_single_case(_vars, %Field{type: {:message, _}}), do: quote(do: [])
225✔
156
  defp make_single_case(_vars, %Field{type: :string}), do: quote(do: [])
105✔
157
  defp make_single_case(_vars, %Field{type: :bytes}), do: quote(do: [])
20✔
158
  defp make_single_case(_vars, %Field{type: {x, _}}) when x != :enum, do: quote(do: [])
95✔
159

160
  defp make_single_case(vars, %Field{} = field) do
161
    parse_single = make_parse_single(vars.bytes, field.type)
530✔
162
    update_field = make_update_field(vars.value, field, vars, _wrap_value = true)
530✔
163

164
    key_bytes = make_key_bytes(field)
530✔
165

166
    # The last 3 bits of the first byte are the wire type, which we can to ignore here as we know beforehand
167
    # how the field is encoded.
168
    <<first_byte::5, _wire_type::3, tail::binary>> = key_bytes
530✔
169

170
    clause =
530✔
171
      case tail do
172
        "" ->
173
          quote do
174
            <<unquote(first_byte)::5, _wire_type::3, unquote(vars.bytes)::binary>>
135✔
175
          end
176

177
        _ ->
178
          quote do
179
            <<unquote(first_byte)::5, _wire_type::3, unquote(tail), unquote(vars.bytes)::binary>>
395✔
180
          end
181
      end
182

183
    quote do
184
      unquote(clause) ->
185
        {value, rest} = unquote(parse_single)
186
        {[unquote(update_field)], rest}
187
    end
188
  end
189

190
  defp make_delimited_case(vars, single_generated, %Field{type: {:message, _}} = field) do
191
    make_delimited_case_impl(vars, single_generated, field)
225✔
192
  end
193

194
  defp make_delimited_case(vars, single_generated, %Field{type: :bytes} = field) do
195
    make_delimited_case_impl(vars, single_generated, field)
20✔
196
  end
197

198
  defp make_delimited_case(vars, single_generated, %Field{type: :string} = field) do
199
    make_delimited_case_impl(vars, single_generated, field)
105✔
200
  end
201

202
  defp make_delimited_case(_vars, _single_generated, %Field{kind: %Scalar{}}) do
280✔
203
    []
204
  end
205

206
  defp make_delimited_case(_vars, _single_generated, %Field{kind: %OneOf{}}) do
35✔
207
    []
208
  end
209

210
  defp make_delimited_case(vars, single_generated, %Field{} = field) do
211
    make_delimited_case_impl(vars, single_generated, field)
310✔
212
  end
213

214
  defp make_delimited_case_impl(vars, single_generated, %Field{} = field) do
215
    # If the case to decode single occurrences of repeated elements has been generated,
216
    # it means that it's a repeated field of scalar elements (as non-scalar cannot be packed,
217
    # see https://developers.google.com/protocol-buffers/docs/encoding#optional).
218
    # Thus, it's useless to wrap in a list the result of the decoding as it means
219
    # we're using a parse_repeated_* function that always returns a list.
220
    update_field =
660✔
221
      if field.type == :bytes do
660✔
222
        make_update_field(vars.delimited, field, vars, _wrap_value = !single_generated)
20✔
223
      else
224
        parse_delimited = make_parse_delimited(vars.delimited, field.type)
640✔
225
        make_update_field(parse_delimited, field, vars, _wrap_value = !single_generated)
640✔
226
      end
227

228
    key_bytes = make_key_bytes(%Field{field | kind: :packed})
660✔
229

230
    clause =
660✔
231
      if single_generated do
232
        # If the single clause was not generated for this field, we don't need the wire type
233
        # discrimant as there is only one clause matching for this field.
234
        quote do
235
          <<unquote(key_bytes), unquote(vars.bytes)::binary>>
215✔
236
        end
237
      else
238
        <<first_byte::5, _wire_type::3, tail::binary>> = key_bytes
445✔
239

240
        case tail do
445✔
241
          "" ->
242
            quote do
243
              <<unquote(first_byte)::5, _wire_type::3, unquote(vars.bytes)::binary>>
75✔
244
            end
245

246
          _ ->
247
            quote do
248
              <<unquote(first_byte)::5, _wire_type::3, unquote(tail),
249
                unquote(vars.bytes)::binary>>
370✔
250
            end
251
        end
252
      end
253

254
    quote do
255
      unquote(clause) ->
256
        {len, unquote(vars.bytes)} = Protox.Varint.decode(unquote(vars.bytes))
660✔
257

258
        {unquote(vars.delimited), rest} = Protox.Decode.parse_delimited(unquote(vars.bytes), len)
660✔
259
        {[unquote(update_field)], rest}
260
    end
261
  end
262

263
  defp make_update_field(value, %Field{kind: :map} = field, vars, _wrap_value) do
264
    quote do
265
      {entry_key, entry_value} = unquote(value)
266

267
      {unquote(field.name),
95✔
268
       Map.put(unquote(vars.msg).unquote(field.name), entry_key, entry_value)}
95✔
269
    end
270
  end
271

272
  defp make_update_field(
273
         value,
274
         %Field{kind: %OneOf{}, type: {:message, _}} = field,
275
         vars,
276
         _wrap_value
277
       ) do
278
    case field.label do
5✔
UNCOV
279
      :proto3_optional ->
×
280
        quote do
281
          # It's unclear if we should merge the value here or not. For now, conformance tests
282
          # pass without this.
UNCOV
283
          {unquote(field.name), unquote(value)}
×
284
        end
285

286
      _ ->
287
        quote do
288
          case unquote(vars.msg).unquote(field.kind.parent) do
5✔
289
            {unquote(field.name), previous_value} ->
5✔
290
              {unquote(field.kind.parent),
5✔
291
               {unquote(field.name), Protox.MergeMessage.merge(previous_value, unquote(value))}}
5✔
292

293
            _ ->
294
              {unquote(field.kind.parent), {unquote(field.name), unquote(value)}}
5✔
295
          end
296
        end
297
    end
298
  end
299

300
  defp make_update_field(value, %Field{kind: %OneOf{}} = field, _vars, _wrap_value) do
301
    case field.label do
45✔
UNCOV
302
      :proto3_optional ->
×
UNCOV
303
        quote(do: {unquote(field.name), unquote(value)})
×
304

305
      _ ->
45✔
306
        quote(do: {unquote(field.kind.parent), {unquote(field.name), unquote(value)}})
45✔
307
    end
308
  end
309

310
  defp make_update_field(
120✔
311
         value,
312
         %Field{kind: %Scalar{}, type: {:message, _}} = field,
313
         vars,
314
         _wrap_value
315
       ) do
316
    quote do
317
      {
318
        unquote(field.name),
120✔
319
        Protox.MergeMessage.merge(unquote(vars.msg).unquote(field.name), unquote(value))
120✔
320
      }
321
    end
322
  end
323

324
  defp make_update_field(value, %Field{kind: %Scalar{}} = field, _vars, _wrap_value) do
375✔
325
    quote(do: {unquote(field.name), unquote(value)})
375✔
326
  end
327

328
  defp make_update_field(value, %Field{} = field, vars, true = _wrap_value) do
335✔
329
    quote do
330
      {unquote(field.name), unquote(vars.msg).unquote(field.name) ++ [unquote(value)]}
335✔
331
    end
332
  end
333

334
  defp make_update_field(value, %Field{} = field, vars, false = _wrap_value) do
215✔
335
    quote do
336
      {unquote(field.name), unquote(vars.msg).unquote(field.name) ++ unquote(value)}
215✔
337
    end
338
  end
339

340
  defp make_parse_delimited(bytes_var, :bytes) do
341
    quote(do: unquote(bytes_var))
5✔
342
  end
343

344
  defp make_parse_delimited(bytes_var, :string) do
345
    quote(do: Protox.Decode.validate_string!(unquote(bytes_var)))
346
  end
347

348
  defp make_parse_delimited(bytes_var, {:enum, mod}) do
349
    quote(do: Protox.Decode.parse_repeated_enum([], unquote(bytes_var), unquote(mod)))
350
  end
351

352
  defp make_parse_delimited(bytes_var, {:message, mod}) do
353
    quote(do: unquote(mod).decode!(unquote(bytes_var)))
354
  end
355

356
  defp make_parse_delimited(bytes_var, :bool) do
357
    quote(do: Protox.Decode.parse_repeated_bool([], unquote(bytes_var)))
358
  end
359

360
  defp make_parse_delimited(bytes_var, :int32) do
361
    quote(do: Protox.Decode.parse_repeated_int32([], unquote(bytes_var)))
362
  end
363

364
  defp make_parse_delimited(bytes_var, :uint32) do
365
    quote(do: Protox.Decode.parse_repeated_uint32([], unquote(bytes_var)))
366
  end
367

368
  defp make_parse_delimited(bytes_var, :sint32) do
369
    quote(do: Protox.Decode.parse_repeated_sint32([], unquote(bytes_var)))
370
  end
371

372
  defp make_parse_delimited(bytes_var, :int64) do
373
    quote(do: Protox.Decode.parse_repeated_int64([], unquote(bytes_var)))
374
  end
375

376
  defp make_parse_delimited(bytes_var, :uint64) do
377
    quote(do: Protox.Decode.parse_repeated_uint64([], unquote(bytes_var)))
378
  end
379

380
  defp make_parse_delimited(bytes_var, :sint64) do
381
    quote(do: Protox.Decode.parse_repeated_sint64([], unquote(bytes_var)))
382
  end
383

384
  defp make_parse_delimited(bytes_var, :fixed32) do
385
    quote(do: Protox.Decode.parse_repeated_fixed32([], unquote(bytes_var)))
386
  end
387

388
  defp make_parse_delimited(bytes_var, :fixed64) do
389
    quote(do: Protox.Decode.parse_repeated_fixed64([], unquote(bytes_var)))
390
  end
391

392
  defp make_parse_delimited(bytes_var, :sfixed32) do
393
    quote(do: Protox.Decode.parse_repeated_sfixed32([], unquote(bytes_var)))
394
  end
395

396
  defp make_parse_delimited(bytes_var, :sfixed64) do
397
    quote(do: Protox.Decode.parse_repeated_sfixed64([], unquote(bytes_var)))
398
  end
399

400
  defp make_parse_delimited(bytes_var, :float) do
401
    quote(do: Protox.Decode.parse_repeated_float([], unquote(bytes_var)))
402
  end
403

404
  defp make_parse_delimited(bytes_var, :double) do
405
    quote(do: Protox.Decode.parse_repeated_double([], unquote(bytes_var)))
406
  end
407

408
  defp make_parse_delimited(bytes_var, {key_type, value_type}) do
409
    unset_map_value =
95✔
410
      case value_type do
411
        {:message, msg_type} -> quote(do: struct(unquote(msg_type)))
412
        _ -> quote(do: Protox.Default.default(unquote(value_type)))
413
      end
414

415
    parser_fun_name = make_map_decode_fun_name(key_type, value_type)
95✔
416

417
    quote do
418
      {map_key, map_value} = unquote(parser_fun_name)({:unset, :unset}, unquote(bytes_var))
419

420
      map_key =
421
        case map_key do
422
          :unset -> Protox.Default.default(unquote(key_type))
423
          _ -> map_key
424
        end
425

426
      map_value =
427
        case map_value do
428
          :unset -> unquote(unset_map_value)
429
          _ -> map_value
430
        end
431

432
      {map_key, map_value}
433
    end
434
  end
435

436
  defp make_parse_single(bytes_var, :double) do
437
    quote(do: Protox.Decode.parse_double(unquote(bytes_var)))
438
  end
439

440
  defp make_parse_single(bytes_var, :float) do
441
    quote(do: Protox.Decode.parse_float(unquote(bytes_var)))
442
  end
443

444
  defp make_parse_single(bytes_var, :sfixed64) do
445
    quote(do: Protox.Decode.parse_sfixed64(unquote(bytes_var)))
446
  end
447

448
  defp make_parse_single(bytes_var, :fixed64) do
449
    quote(do: Protox.Decode.parse_fixed64(unquote(bytes_var)))
450
  end
451

452
  defp make_parse_single(bytes_var, :sfixed32) do
453
    quote(do: Protox.Decode.parse_sfixed32(unquote(bytes_var)))
454
  end
455

456
  defp make_parse_single(bytes_var, :fixed32) do
457
    quote(do: Protox.Decode.parse_fixed32(unquote(bytes_var)))
458
  end
459

460
  defp make_parse_single(bytes_var, :bool) do
461
    quote(do: Protox.Decode.parse_bool(unquote(bytes_var)))
462
  end
463

464
  defp make_parse_single(bytes_var, :sint32) do
465
    quote(do: Protox.Decode.parse_sint32(unquote(bytes_var)))
466
  end
467

468
  defp make_parse_single(bytes_var, :sint64) do
469
    quote(do: Protox.Decode.parse_sint64(unquote(bytes_var)))
470
  end
471

472
  defp make_parse_single(bytes_var, :uint32) do
473
    quote(do: Protox.Decode.parse_uint32(unquote(bytes_var)))
474
  end
475

476
  defp make_parse_single(bytes_var, :uint64) do
477
    quote(do: Protox.Decode.parse_uint64(unquote(bytes_var)))
478
  end
479

480
  defp make_parse_single(bytes_var, :int32) do
481
    quote(do: Protox.Decode.parse_int32(unquote(bytes_var)))
482
  end
483

484
  defp make_parse_single(bytes_var, :int64) do
485
    quote(do: Protox.Decode.parse_int64(unquote(bytes_var)))
486
  end
487

488
  defp make_parse_single(bytes_var, {:enum, mod}) do
489
    quote(do: Protox.Decode.parse_enum(unquote(bytes_var), unquote(mod)))
490
  end
491

492
  defp make_parse_map_entries_funs(vars, fields) do
493
    {maps, _other_fields} = Protox.Defs.split_maps(fields)
90✔
494

495
    maps
496
    |> Enum.map(fn %Field{kind: :map} = field ->
497
      key_type = elem(field.type, 0)
95✔
498
      value_type = elem(field.type, 1)
95✔
499

500
      fun_name = make_map_decode_fun_name(key_type, value_type)
95✔
501

502
      key_parser = make_parse_map_entry(vars, key_type)
95✔
503
      value_parser = make_parse_map_entry(vars, value_type)
95✔
504

505
      code =
95✔
506
        quote do
507
          defp unquote(fun_name)(map_entry, <<>>) do
508
            map_entry
509
          end
510

511
          # https://developers.google.com/protocol-buffers/docs/proto3#backwards-compatibility
512
          # Maps are equivalent to:
513
          #   message MapFieldEntry {
514
          #     key_type key = 1;
515
          #     value_type value = 2;
516
          #   }
517
          # repeated MapFieldEntry map_field = N;
518
          #
519
          defp unquote(fun_name)({entry_key, entry_value}, unquote(vars.bytes)) do
95✔
520
            {map_entry, unquote(vars.rest)} =
95✔
521
              case Protox.Decode.parse_key(unquote(vars.bytes)) do
95✔
522
                # key
523
                {1, _, unquote(vars.rest)} ->
95✔
524
                  {res, unquote(vars.rest)} = unquote(key_parser)
95✔
525
                  {{res, entry_value}, unquote(vars.rest)}
95✔
526

527
                # value
528
                {2, _, unquote(vars.rest)} ->
95✔
529
                  {res, unquote(vars.rest)} = unquote(value_parser)
95✔
530
                  {{entry_key, res}, unquote(vars.rest)}
95✔
531

532
                {tag, wire_type, unquote(vars.rest)} ->
95✔
533
                  {_, unquote(vars.rest)} =
95✔
534
                    Protox.Decode.parse_unknown(tag, wire_type, unquote(vars.rest))
95✔
535

536
                  {{entry_key, entry_value}, unquote(vars.rest)}
95✔
537
              end
538

539
            unquote(fun_name)(map_entry, unquote(vars.rest))
95✔
540
          end
541
        end
542

543
      {fun_name, code}
544
    end)
545
    |> Enum.sort(fn {lhs_fun_name, _}, {rhs_fun_name, _} -> lhs_fun_name < rhs_fun_name end)
310✔
546
    |> Enum.dedup_by(fn {fun_name, _} -> fun_name end)
95✔
547
    |> Enum.map(fn {_, code} -> code end)
90✔
548
  end
549

550
  defp make_map_decode_fun_name(key_type, value_type) do
551
    value_name =
190✔
552
      case value_type do
553
        {:message, sub_msg} -> "msg_#{Atom.to_string(sub_msg)}"
20✔
554
        {:enum, enum} -> "enum_#{Atom.to_string(enum)}"
20✔
555
        ty -> "#{Atom.to_string(ty)}"
150✔
556
      end
557

558
    value_name =
190✔
559
      value_name
560
      |> Macro.underscore()
561
      |> String.replace("/", "_")
562

563
    String.to_atom("parse_#{Atom.to_string(key_type)}_#{value_name}")
190✔
564
  end
565

566
  defp make_parse_map_entry(vars, type) do
567
    parse_delimited =
190✔
568
      quote do
569
        {len, new_rest} = Protox.Varint.decode(unquote(vars.rest))
190✔
570
        {unquote(vars.delimited), new_rest} = Protox.Decode.parse_delimited(new_rest, len)
190✔
571

572
        {unquote(make_parse_delimited(vars.delimited, type)), new_rest}
190✔
573
      end
574

575
    case type do
190✔
576
      :string -> parse_delimited
35✔
577
      :bytes -> parse_delimited
5✔
578
      {:message, _} -> parse_delimited
10✔
579
      _ -> make_parse_single(vars.rest, type)
140✔
580
    end
581
  end
582

583
  # Compute at compile time the varint representation of a field tag and wire type.
584
  defp make_key_bytes(%Field{} = field) do
585
    # We need to convert the type to something recognized
586
    # by Protox.Encode.make_key_bytes/2.
587
    ty =
1,190✔
588
      case field.kind do
1,190✔
UNCOV
589
        :map -> :map_entry
×
590
        :packed -> :packed
805✔
591
        _ -> field.type
385✔
592
      end
593

594
    Protox.Encode.make_key_bytes(field.tag, ty) |> elem(0) |> IO.iodata_to_binary()
1,190✔
595
  end
596
end
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