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

ArkScript-lang / Ark / 16006312604

01 Jul 2025 05:39PM UTC coverage: 86.665% (+0.002%) from 86.663%
16006312604

push

github

SuperFola
fix(emscripten playground): better capture of console.log output

7279 of 8399 relevant lines covered (86.67%)

107687.0 hits per line

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

94.07
/src/arkscript/Formatter.cpp
1
#include <Ark/Constants.hpp>
2
#include <CLI/Formatter.hpp>
3

4
#include <fmt/core.h>
5
#include <fmt/color.h>
6

7
#include <Ark/Files.hpp>
8
#include <Ark/Exceptions.hpp>
9
#include <Ark/Compiler/Common.hpp>
10

11
using namespace Ark;
12
using namespace Ark::internal;
13

14
Formatter::Formatter(const bool dry_run) :
44 ✔
15
    m_dry_run(dry_run), m_parser(/* debug= */ 0, /* interpret= */ false), m_updated(false)
22 ✔
16
{}
44 ✔
17

18
Formatter::Formatter(std::string filename, const bool dry_run) :
44 ✔
19
    m_filename(std::move(filename)), m_dry_run(dry_run), m_parser(/* debug= */ 0, /* interpret= */ false), m_updated(false)
22 ✔
20
{}
44 ✔
21

22
void Formatter::run()
22 ✔
23
{
22 ✔
24
    try
25
    {
26
        const std::string code = Utils::readFile(m_filename);
22 ✔
27
        m_parser.process(m_filename, code);
22 ✔
28
        processAst(m_parser.ast());
22 ✔
29
        warnIfCommentsWereRemoved(code, ARK_NO_NAME_FILE);
22 ✔
30

31
        m_updated = code != m_output;
22 ✔
32
    }
22 ✔
33
    catch (const CodeError& e)
34
    {
35
        Diagnostics::generate(e);
×
36
    }
22 ✔
37
}
22 ✔
38

39
void Formatter::runWithString(const std::string& code)
22 ✔
40
{
22 ✔
41
    try
42
    {
43
        m_parser.process(ARK_NO_NAME_FILE, code);
22 ✔
44
        processAst(m_parser.ast());
22 ✔
45
        warnIfCommentsWereRemoved(code, ARK_NO_NAME_FILE);
22 ✔
46

47
        m_updated = code != m_output;
22 ✔
48
    }
22 ✔
49
    catch (const CodeError& e)
50
    {
51
        Diagnostics::generate(e);
×
52
    }
22 ✔
53
}
22 ✔
54

55
const std::string& Formatter::output() const
44 ✔
56
{
44 ✔
57
    return m_output;
44 ✔
58
}
59

60
bool Formatter::codeModified() const
×
61
{
×
62
    return m_updated;
×
63
}
64

65
void Formatter::processAst(const Node& ast)
44 ✔
66
{
44 ✔
67
    // remove useless surrounding begin (generated by the parser)
68
    if (isBeginBlock(ast))
44 ✔
69
    {
70
        for (std::size_t i = 1, end = ast.constList().size(); i < end; ++i)
180 ✔
71
        {
72
            const Node node = ast.constList()[i];
136 ✔
73
            if (shouldAddNewLineBetweenNodes(ast, i) && !m_output.empty())
136 ✔
74
                m_output += "\n";
20 ✔
75
            m_output += format(node, 0, false) + "\n";
136 ✔
76
        }
136 ✔
77
    }
44 ✔
78
    else
79
        m_output = format(ast, 0, false);
×
80

81
    if (!m_dry_run)
44 ✔
82
    {
83
        std::ofstream stream(m_filename);
×
84
        stream << m_output;
×
85
    }
×
86
}
44 ✔
87

88
void Formatter::warnIfCommentsWereRemoved(const std::string& original_code, const std::string& filename)
44 ✔
89
{
44 ✔
90
    if (std::ranges::count(original_code, '#') != std::ranges::count(m_output, '#'))
44 ✔
91
    {
92
        fmt::println(
×
93
            "{}: one or more comments from the original source code seem to have been removed by mistake while formatting {}",
×
94
            fmt::styled("Warning", fmt::fg(fmt::color::dark_orange)),
×
95
            filename != ARK_NO_NAME_FILE ? filename : "file");
×
96
        fmt::println("Please fill an issue on GitHub: https://github.com/ArkScript-lang/Ark");
×
97
    }
×
98
}
44 ✔
99

100
bool Formatter::isListStartingWithKeyword(const Node& node, const Keyword keyword)
294 ✔
101
{
294 ✔
102
    return node.isListLike() && !node.constList().empty() && node.constList()[0].nodeType() == NodeType::Keyword && node.constList()[0].keyword() == keyword;
294 ✔
103
}
104

105
bool Formatter::isBeginBlock(const Node& node)
210 ✔
106
{
210 ✔
107
    return isListStartingWithKeyword(node, Keyword::Begin);
210 ✔
108
}
109

110
bool Formatter::isFuncDef(const Node& node)
64 ✔
111
{
64 ✔
112
    return isListStartingWithKeyword(node, Keyword::Fun);
64 ✔
113
}
114

115
bool Formatter::isFuncCall(const Node& node)
142 ✔
116
{
142 ✔
117
    return node.isListLike() && !node.constList().empty() && node.constList()[0].nodeType() == NodeType::Symbol;
142 ✔
118
}
119

120
std::size_t Formatter::lineOfLastNodeIn(const Node& node)
322 ✔
121
{
322 ✔
122
    if (node.isListLike() && !node.constList().empty())
322 ✔
123
    {
124
        std::size_t child_line = lineOfLastNodeIn(node.constList().back());
186 ✔
125
        if (child_line < node.line())
186 ✔
126
            return node.line();
×
127
        return child_line;
186 ✔
128
    }
186 ✔
129
    return node.line();
136 ✔
130
}
322 ✔
131

132
bool Formatter::shouldSplitOnNewline(const Node& node)
176 ✔
133
{
176 ✔
134
    const std::string formatted = format(node, 0, false);
176 ✔
135
    const std::string::size_type sz = formatted.find_first_of('\n');
176 ✔
136

137
    const bool is_long_line = !((sz < FormatterConfig::LongLineLength || (sz == std::string::npos && formatted.size() < FormatterConfig::LongLineLength)));
176 ✔
138
    if (node.comment().empty() && (isBeginBlock(node) || isFuncCall(node)))
176 ✔
139
        return false;
64 ✔
140
    if (is_long_line || (node.isListLike() && node.constList().size() > 1) || !node.comment().empty())
112 ✔
141
        return true;
24 ✔
142
    return false;
88 ✔
143
}
176 ✔
144

145
bool Formatter::shouldAddNewLineBetweenNodes(const Node& node, const std::size_t at)
230 ✔
146
{
230 ✔
147
    if (at <= 1)
230 ✔
148
        return false;
94 ✔
149

150
    const auto& list = node.constList();
136 ✔
151
    std::size_t previous_line = lineOfLastNodeIn(list[at - 1]);
136 ✔
152

153
    const auto& child = list[at];
136 ✔
154

155
    // If we have a node before the current one,
156
    // and the line count between the two nodes is more than 1,
157
    // maybe we should add a new line to preserve user spacing.
158
    // However, if the current node has a comment, do not add a new line, this is causing the spacing.
159
    if (child.line() - previous_line > 1 && child.comment().empty())
136 ✔
160
        return true;
18 ✔
161
    // If we do have a comment but the spacing is more than 2,
162
    // then add a newline to preserve user spacing.
163
    if (child.line() - previous_line > 2 && !child.comment().empty())
118 ✔
164
        return true;
2 ✔
165
    return false;
116 ✔
166
}
230 ✔
167

168
std::string Formatter::format(const Node& node, std::size_t indent, bool after_newline)
1,510 ✔
169
{
1,510 ✔
170
    std::string result;
1,510 ✔
171
    if (!node.comment().empty())
1,510 ✔
172
    {
173
        result += formatComment(node.comment(), indent);
66 ✔
174
        after_newline = true;
66 ✔
175
    }
66 ✔
176
    if (after_newline)
1,510 ✔
177
        result += prefix(indent);
262 ✔
178

179
    switch (node.nodeType())
1,510 ✔
180
    {
702 ✔
181
        case NodeType::Symbol:
182
            result += node.string();
702 ✔
183
            break;
780 ✔
184
        case NodeType::Capture:
185
            result += "&" + node.string();
78 ✔
186
            break;
78 ✔
187
        case NodeType::Keyword:
188
            result += std::string(keywords[static_cast<std::size_t>(node.keyword())]);
×
189
            break;
42 ✔
190
        case NodeType::String:
191
            result += fmt::format("\"{}\"", node.string());
42 ✔
192
            break;
206 ✔
193
        case NodeType::Number:
194
            result += fmt::format("{}", node.number());
164 ✔
195
            break;
636 ✔
196
        case NodeType::List:
197
            result += formatBlock(node, indent, after_newline);
472 ✔
198
            break;
486 ✔
199
        case NodeType::Spread:
200
            result += fmt::format("...{}", node.string());
14 ✔
201
            break;
32 ✔
202
        case NodeType::Field:
203
        {
204
            std::string field = format(node.constList()[0], indent, false);
18 ✔
205
            for (std::size_t i = 1, end = node.constList().size(); i < end; ++i)
62 ✔
206
                field += "." + format(node.constList()[i], indent, false);
44 ✔
207
            result += field;
18 ✔
208
            break;
209
        }
38 ✔
210
        case NodeType::Macro:
211
            result += formatMacro(node, indent);
20 ✔
212
            break;
20 ✔
213
        // not handling Namespace nor Unused node types as those can not be generated by the parser
214
        case NodeType::Namespace:
215
            [[fallthrough]];
216
        case NodeType::Unused:
217
            break;
×
218
    }
1,510 ✔
219

220
    if (!node.commentAfter().empty())
1,510 ✔
221
        result += " " + formatComment(node.commentAfter(), /* indent= */ 0);
40 ✔
222

223
    return result;
1,510 ✔
224
}
1,510 ✔
225

226
std::string Formatter::formatComment(const std::string& comment, const std::size_t indent) const
118 ✔
227
{
118 ✔
228
    std::string result = prefix(indent);
118 ✔
229
    for (std::size_t i = 0, end = comment.size(); i < end; ++i)
1,724 ✔
230
    {
231
        result += comment[i];
1,606 ✔
232
        if (comment[i] == '\n' && i != end - 1)
1,606 ✔
233
            result += prefix(indent);
6 ✔
234
    }
1,606 ✔
235

236
    return result;
118 ✔
237
}
118 ✔
238

239
std::string Formatter::formatBlock(const Node& node, const std::size_t indent, const bool after_newline)
472 ✔
240
{
472 ✔
241
    if (node.constList().empty())
472 ✔
242
        return "()";
24 ✔
243

244
    const Node first = node.constList().front();
448 ✔
245
    if (first.nodeType() == NodeType::Keyword)
448 ✔
246
    {
247
        switch (first.keyword())
230 ✔
248
        {
32 ✔
249
            case Keyword::Fun:
250
                return formatFunction(node, indent);
96 ✔
251
            case Keyword::Let:
252
                [[fallthrough]];
253
            case Keyword::Mut:
254
                [[fallthrough]];
255
            case Keyword::Set:
256
                return formatVariable(node, indent);
106 ✔
257
            case Keyword::If:
258
                return formatCondition(node, indent);
52 ✔
259
            case Keyword::While:
260
                return formatLoop(node, indent);
68 ✔
261
            case Keyword::Begin:
262
                return formatBegin(node, indent, after_newline);
78 ✔
263
            case Keyword::Import:
264
                return formatImport(node, indent);
24 ✔
265
            case Keyword::Del:
266
                return formatDel(node, indent);
4 ✔
267
        }
×
268
        // HACK: should never reach, but the compiler insists that the function doesn't return in every code path
269
        return "";
×
270
    }
271
    return formatCall(node, indent);
218 ✔
272
}
472 ✔
273

274
std::string Formatter::formatFunction(const Node& node, const std::size_t indent)
32 ✔
275
{
32 ✔
276
    const Node args_node = node.constList()[1];
32 ✔
277
    const Node body_node = node.constList()[2];
32 ✔
278

279
    std::string formatted_args;
32 ✔
280

281
    if (!args_node.comment().empty())
32 ✔
282
    {
283
        formatted_args += "\n";
2 ✔
284
        formatted_args += formatComment(args_node.comment(), indent + 1);
2 ✔
285
        formatted_args += prefix(indent + 1);
2 ✔
286
    }
2 ✔
287
    else
288
        formatted_args += " ";
30 ✔
289

290
    if (args_node.isListLike())
32 ✔
291
    {
292
        bool comment_in_args = false;
30 ✔
293
        std::string args;
30 ✔
294
        const bool split = shouldSplitOnNewline(args_node);
30 ✔
295

296
        for (std::size_t i = 0, end = args_node.constList().size(); i < end; ++i)
90 ✔
297
        {
298
            const Node arg_i = args_node.constList()[i];
60 ✔
299
            if (!arg_i.comment().empty())
60 ✔
300
                comment_in_args = true;
4 ✔
301

302
            args += format(arg_i, indent + ((comment_in_args || split) ? 1 : 0), i > 0 && (comment_in_args || split));
102 ✔
303
            if (i != end - 1)
60 ✔
304
                args += (comment_in_args || split) ? '\n' : ' ';
42 ✔
305
        }
60 ✔
306

307
        formatted_args += fmt::format("({}{})", (comment_in_args ? "\n" : ""), args);
30 ✔
308
    }
30 ✔
309
    else
310
        formatted_args += format(args_node, indent, false);
2 ✔
311

312
    if (!shouldSplitOnNewline(body_node) && args_node.comment().empty())
32 ✔
313
        return fmt::format("(fun{} {})", formatted_args, format(body_node, indent + 1, false));
20 ✔
314
    return fmt::format("(fun{}\n{})", formatted_args, format(body_node, indent + 1, true));
12 ✔
315
}
32 ✔
316

317
std::string Formatter::formatVariable(const Node& node, const std::size_t indent)
64 ✔
318
{
64 ✔
319
    std::string keyword = std::string(keywords[static_cast<std::size_t>(node.constList()[0].keyword())]);
64 ✔
320

321
    const Node body_node = node.constList()[2];
64 ✔
322
    const std::string formatted_bind = format(node.constList()[1], indent, false);
64 ✔
323

324
    // we don't want to add another indentation level here, because it would result in a (let a (fun ()\n{indent+=4}...))
325
    if (isFuncDef(body_node))
64 ✔
326
        return fmt::format("({} {} {})", keyword, formatted_bind, format(body_node, indent, false));
6 ✔
327
    if (!shouldSplitOnNewline(body_node))
58 ✔
328
        return fmt::format("({} {} {})", keyword, formatted_bind, format(body_node, indent + 1, false));
56 ✔
329
    return fmt::format("({} {}\n{})", keyword, formatted_bind, format(body_node, indent + 1, true));
2 ✔
330
}
64 ✔
331

332
std::string Formatter::formatCondition(const Node& node, const std::size_t indent, const bool is_macro)
48 ✔
333
{
48 ✔
334
    const Node cond_node = node.constList()[1];
48 ✔
335
    const Node then_node = node.constList()[2];
48 ✔
336

337
    bool cond_on_newline = false;
48 ✔
338
    std::string formatted_cond = format(cond_node, indent + 1, false);
48 ✔
339
    if (formatted_cond.find('\n') != std::string::npos)
48 ✔
340
        cond_on_newline = true;
2 ✔
341

342
    std::string if_cond_formatted = fmt::format(
96 ✔
343
        "({}if{}{}",
48 ✔
344
        is_macro ? "$" : "",
48 ✔
345
        cond_on_newline ? "\n" : " ",
48 ✔
346
        formatted_cond);
347

348
    const bool split_then_newline = shouldSplitOnNewline(then_node);
48 ✔
349

350
    // (if cond then)
351
    if (node.constList().size() == 3)
48 ✔
352
    {
353
        if (cond_on_newline || split_then_newline)
22 ✔
354
            return fmt::format("{}\n{})", if_cond_formatted, format(then_node, indent + 1, true));
2 ✔
355
        return fmt::format("{} {})", if_cond_formatted, format(then_node, indent + 1, false));
20 ✔
356
    }
357
    // (if cond then else)
358
    return fmt::format(
26 ✔
359
        "{}\n{}\n{}{})",
26 ✔
360
        if_cond_formatted,
361
        format(then_node, indent + 1, true),
26 ✔
362
        format(node.constList()[3], indent + 1, true),
26 ✔
363
        node.constList()[3].commentAfter().empty() ? "" : ("\n" + prefix(indent)));
26 ✔
364
}
48 ✔
365

366
std::string Formatter::formatLoop(const Node& node, const std::size_t indent)
10 ✔
367
{
10 ✔
368
    const Node cond_node = node.constList()[1];
10 ✔
369
    const Node body_node = node.constList()[2];
10 ✔
370

371
    bool cond_on_newline = false;
10 ✔
372
    std::string formatted_cond = format(cond_node, indent + 1, false);
10 ✔
373
    if (formatted_cond.find('\n') != std::string::npos)
10 ✔
374
        cond_on_newline = true;
2 ✔
375

376
    if (cond_on_newline || shouldSplitOnNewline(body_node))
10 ✔
377
        return fmt::format(
8 ✔
378
            "(while{}{}\n{})",
4 ✔
379
            cond_on_newline ? "\n" : " ",
4 ✔
380
            formatted_cond,
381
            format(body_node, indent + 1, true));
4 ✔
382
    return fmt::format(
6 ✔
383
        "(while {} {})",
6 ✔
384
        formatted_cond,
385
        format(body_node, indent + 1, false));
6 ✔
386
}
10 ✔
387

388
std::string Formatter::formatBegin(const Node& node, const std::size_t indent, const bool after_newline)
58 ✔
389
{
58 ✔
390
    // only the keyword begin is present
391
    if (node.constList().size() == 1)
58 ✔
392
        return "{}";
8 ✔
393

394
    // after a new line, we need to increment our indentation level
395
    // if the block is a top level one, we also need to increment indentation level
396
    const std::size_t inner_indentation = indent + (after_newline ? 1 : 0) + (indent == 0 ? 1 : 0);
50 ✔
397

398
    std::string result = "{\n";
50 ✔
399
    // skip begin keyword
400
    for (std::size_t i = 1, end = node.constList().size(); i < end; ++i)
144 ✔
401
    {
402
        const Node child = node.constList()[i];
94 ✔
403
        // we want to preserve the node grouping by the user, but remove useless duplicate new line
404
        // but that shouldn't apply to the first node of the block
405
        if (shouldAddNewLineBetweenNodes(node, i) && i > 1)
94 ✔
406
            result += "\n";
×
407

408
        result += format(child, inner_indentation, true);
94 ✔
409
        if (i != end - 1)
94 ✔
410
            result += "\n";
44 ✔
411
    }
94 ✔
412

413
    // if the last node has a comment, add a new line
414
    if (!node.constList().empty() && !node.constList().back().commentAfter().empty())
50 ✔
415
        result += "\n" + prefix(indent) + "}";
2 ✔
416
    else
417
        result += " }";
48 ✔
418
    return result;
50 ✔
419
}
58 ✔
420

421
std::string Formatter::formatImport(const Node& node, const std::size_t indent)
20 ✔
422
{
20 ✔
423
    const Node package_node = node.constList()[1];
20 ✔
424
    std::string package;
20 ✔
425

426
    if (!package_node.comment().empty())
20 ✔
427
        package += "\n" + formatComment(package_node.comment(), indent + 1) + prefix(indent + 1);
2 ✔
428
    else
429
        package += " ";
18 ✔
430

431
    for (std::size_t i = 0, end = package_node.constList().size(); i < end; ++i)
58 ✔
432
    {
433
        package += format(package_node.constList()[i], indent + 1, false);
38 ✔
434
        if (i != end - 1)
38 ✔
435
            package += ".";
18 ✔
436
    }
38 ✔
437

438
    const Node symbols = node.constList()[2];
20 ✔
439
    if (symbols.nodeType() == NodeType::Symbol && symbols.string() == "*")
20 ✔
440
        package += ":*";
2 ✔
441
    else  // symbols is a list
442
    {
443
        if (const auto& sym_list = symbols.constList(); !sym_list.empty())
28 ✔
444
        {
445
            const bool comment_after_last = !sym_list.back().commentAfter().empty();
10 ✔
446

447
            for (const auto& sym : sym_list)
24 ✔
448
            {
449
                if (sym.comment().empty())
14 ✔
450
                {
451
                    if (comment_after_last)
8 ✔
452
                        package += "\n" + prefix(indent + 1) + ":" + sym.string();
2 ✔
453
                    else
454
                        package += " :" + sym.string();
6 ✔
455
                }
8 ✔
456
                else
457
                    package += "\n" + formatComment(sym.comment(), indent + 1) + prefix(indent + 1) + ":" + sym.string();
6 ✔
458
            }
14 ✔
459

460
            if (comment_after_last)
10 ✔
461
            {
462
                package += " " + formatComment(sym_list.back().commentAfter(), /* indent= */ 0);
2 ✔
463
                package += "\n" + prefix(indent + 1);
2 ✔
464
            }
2 ✔
465
        }
10 ✔
466
    }
467

468
    return fmt::format("(import{})", package);
20 ✔
469
}
20 ✔
470

471
std::string Formatter::formatDel(const Node& node, const std::size_t indent)
4 ✔
472
{
4 ✔
473
    std::string formatted_sym = format(node.constList()[1], indent + 1, false);
4 ✔
474
    if (formatted_sym.find('\n') != std::string::npos)
4 ✔
475
        return fmt::format("(del\n{})", formatted_sym);
2 ✔
476
    return fmt::format("(del {})", formatted_sym);
2 ✔
477
}
4 ✔
478

479
std::string Formatter::formatCall(const Node& node, const std::size_t indent)
218 ✔
480
{
218 ✔
481
    bool is_list = false;
218 ✔
482
    if (!node.constList().empty() && node.constList().front().nodeType() == NodeType::Symbol &&
408 ✔
483
        node.constList().front().string() == "list")
190 ✔
484
        is_list = true;
4 ✔
485

486
    bool is_multiline = false;
218 ✔
487

488
    std::vector<std::string> formatted_args;
218 ✔
489
    for (std::size_t i = 1, end = node.constList().size(); i < end; ++i)
576 ✔
490
    {
491
        formatted_args.push_back(format(node.constList()[i], indent, false));
358 ✔
492
        // if we have at least one argument taking multiple lines, split them all on their own line
493
        if (formatted_args.back().find('\n') != std::string::npos || !node.constList()[i].commentAfter().empty())
358 ✔
494
            is_multiline = true;
14 ✔
495
    }
358 ✔
496

497
    std::string result = is_list ? "[" : ("(" + format(node.constList()[0], indent, false));
218 ✔
498
    for (std::size_t i = 0, end = formatted_args.size(); i < end; ++i)
576 ✔
499
    {
500
        const std::string formatted_node = formatted_args[i];
358 ✔
501
        if (is_multiline)
358 ✔
502
            result += "\n" + format(node.constList()[i + 1], indent + 1, true);
24 ✔
503
        else
504
            result += (is_list && i == 0 ? "" : " ") + formatted_node;
334 ✔
505
    }
358 ✔
506
    if (!node.constList().back().commentAfter().empty())
218 ✔
507
        result += "\n" + prefix(indent);
6 ✔
508
    result += is_list ? "]" : ")";
218 ✔
509
    return result;
218 ✔
510
}
218 ✔
511

512
std::string Formatter::formatMacro(const Node& node, const std::size_t indent)
20 ✔
513
{
20 ✔
514
    if (isListStartingWithKeyword(node, Keyword::If))
20 ✔
515
        return formatCondition(node, indent, /* is_macro= */ true);
6 ✔
516

517
    std::string result = "(macro ";
14 ✔
518
    bool after_newline = false;
14 ✔
519

520
    for (std::size_t i = 0, end = node.constList().size(); i < end; ++i)
54 ✔
521
    {
522
        result += format(node.constList()[i], indent + 1, after_newline);
40 ✔
523
        after_newline = false;
40 ✔
524

525
        if (!node.constList()[i].commentAfter().empty())
40 ✔
526
        {
527
            result += "\n";
4 ✔
528
            after_newline = true;
4 ✔
529
        }
4 ✔
530
        else if (i != end - 1)
36 ✔
531
            result += " ";
24 ✔
532
    }
40 ✔
533

534
    return result + ")";
14 ✔
535
}
20 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc