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

luttje / glua-api-snippets / 12908304340

22 Jan 2025 12:34PM UTC coverage: 78.069% (+0.09%) from 77.981%
12908304340

push

github

luttje
Fix #69 show realm and wiki link on structs, enums, classes + fix inconsistent realm texts + workaround duplicate STENCIL enum definition on wiki

320 of 388 branches covered (82.47%)

Branch coverage included in aggregate %.

100 of 110 new or added lines in 2 files covered. (90.91%)

2 existing lines in 1 file now uncovered.

1645 of 2129 relevant lines covered (77.27%)

461.05 hits per line

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

84.01
/src/api-writer/glua-api-writer.ts
1
import { ClassFunction, Enum, Function, HookFunction, LibraryFunction, TypePage, Panel, PanelFunction, Realm, Struct, WikiPage, isPanel, FunctionArgument, FunctionCallback } from '../scrapers/wiki-page-markup-scraper.js';
1✔
2
import { escapeSingleQuotes, indentText, putCommentBeforeEachLine, removeNewlines, safeFileName, toLowerCamelCase } from '../utils/string.js';
1✔
3
import {
1✔
4
  isClassFunction,
1✔
5
  isHookFunction,
1✔
6
  isLibraryFunction,
1✔
7
  isLibrary,
1✔
8
  isClass,
1✔
9
  isPanelFunction,
1✔
10
  isStruct,
1✔
11
  isEnum,
1✔
12
} from '../scrapers/wiki-page-markup-scraper.js';
1✔
13
import fs from 'fs';
1✔
14

1✔
15
export const RESERVERD_KEYWORDS = new Set([
1✔
16
  'and',
1✔
17
  'break',
1✔
18
  'continue',
1✔
19
  'do',
1✔
20
  'else',
1✔
21
  'elseif',
1✔
22
  'end',
1✔
23
  'false',
1✔
24
  'for',
1✔
25
  'function',
1✔
26
  'goto',
1✔
27
  'if',
1✔
28
  'in',
1✔
29
  'local',
1✔
30
  'nil',
1✔
31
  'not',
1✔
32
  'or',
1✔
33
  'repeat',
1✔
34
  'return',
1✔
35
  'then',
1✔
36
  'true',
1✔
37
  'until',
1✔
38
  'while'
1✔
39
]);
1✔
40

1✔
41
type IndexedWikiPage = {
1✔
42
  index: number;
1✔
43
  page: WikiPage;
1✔
44
};
1✔
45

1✔
46
export class GluaApiWriter {
1✔
47
  private readonly writtenClasses: Set<string> = new Set();
1✔
48
  private readonly writtenEnums: Set<string> = new Set();
1✔
49
  private readonly writtenLibraryGlobals: Set<string> = new Set();
1✔
50
  private readonly pageOverrides: Map<string, string> = new Map();
1✔
51

1✔
52
  private readonly files: Map<string, IndexedWikiPage[]> = new Map();
1✔
53

1✔
54
  constructor() { }
1✔
55

1✔
56
  public static safeName(name: string) {
1✔
57
    if (name.includes('/'))
95✔
58
      name = name.replace(/\//g, ' or ');
95✔
59

95✔
60
    if (name.includes('='))
95✔
61
      name = name.split('=')[0];
95✔
62

95✔
63
    if (name.includes(' '))
95✔
64
      name = toLowerCamelCase(name);
95✔
65

95✔
66
    // Remove any remaining characters not valid in a Lua variable/function name.
95✔
67
    name = name.replace(/[^A-Za-z\d_.]/g, '');
95✔
68

95✔
69
    if (RESERVERD_KEYWORDS.has(name))
95✔
70
      return `_${name}`;
95✔
71

72✔
72
    return name;
72✔
73
  }
72✔
74

1✔
75
  public addOverride(pageAddress: string, override: string) {
1✔
76
    this.pageOverrides.set(safeFileName(pageAddress, '.'), override);
2✔
77
  }
2✔
78

1✔
79
  public writePage(page: WikiPage) {
1✔
80
    const fileSafeAddress = safeFileName(page.address, '.');
19✔
81
    if (this.pageOverrides.has(fileSafeAddress)) {
19✔
82
      let api = '';
1✔
83

1✔
84
      if (isClassFunction(page))
1✔
85
        api += this.writeClassStart(page.parent, undefined, undefined, undefined, page.deprecated);
1!
86
      else if (isLibraryFunction(page))
1✔
87
        api += this.writeLibraryGlobalFallback(page);
1!
88

1✔
89
      api += this.pageOverrides.get(fileSafeAddress);
1✔
90

1✔
91
      return `${api}\n\n`;
1✔
92
    } else if (isClassFunction(page))
19✔
93
      return this.writeClassFunction(page);
18!
94
    else if (isLibraryFunction(page))
18✔
95
      return this.writeLibraryFunction(page);
18✔
96
    else if (isHookFunction(page))
6✔
97
      return this.writeHookFunction(page);
6✔
98
    else if (isPanel(page))
5✔
99
      return this.writePanel(page);
5✔
100
    else if (isPanelFunction(page))
4✔
101
      return this.writePanelFunction(page);
4!
102
    else if (isEnum(page))
4✔
103
      return this.writeEnum(page);
4✔
104
    else if (isStruct(page))
2✔
105
      return this.writeStruct(page);
2✔
106
    else if (isLibrary(page))
×
107
      return this.writeLibraryGlobal(page);
×
108
    else if (isClass(page))
×
109
      return this.writeClassGlobal(page);
×
110
  }
19✔
111

1✔
112
  private writeClassStart(className: string, realm?: Realm, url?: string, parent?: string, deprecated?: string, description?: string) {
1✔
113
    let api: string = '';
4✔
114

4✔
115
    if (!this.writtenClasses.has(className)) {
4✔
116
      const classOverride = `class.${className}`;
4✔
117
      if (this.pageOverrides.has(classOverride)) {
4✔
118
        api += this.pageOverrides.get(classOverride)!.replace(/\n$/g, '') + '\n\n';
1✔
119
      } else {
4✔
120
        if (realm) {
3!
NEW
121
          api += `---${this.formatRealm(realm)} ${description ? `${putCommentBeforeEachLine(description)}\n` : ''}\n`;
×
122
        } else {
3✔
123
          api += description ? `${putCommentBeforeEachLine(description, false)}\n` : '';
3✔
124
        }
3✔
125

3✔
126
        if (url) {
3✔
127
          api += `---\n---[View wiki](${url})\n`;
1✔
128
        }
1✔
129

3✔
130
        if (deprecated)
3✔
131
          api += `---@deprecated ${removeNewlines(deprecated)}\n`;
3✔
132

3✔
133
        api += `---@class ${className}`;
3✔
134

3✔
135
        if (parent)
3✔
136
          api += ` : ${parent}`;
3✔
137

3✔
138
        api += '\n';
3✔
139

3✔
140
        // for PLAYER, WEAPON, etc. we want to define globals
3✔
141
        if (className !== className.toUpperCase()) api += 'local ';
3✔
142
        api += `${className} = {}\n\n`;
3✔
143
      }
3✔
144

4✔
145
      this.writtenClasses.add(className);
4✔
146
    }
4✔
147

4✔
148
    return api;
4✔
149
  }
4✔
150

1✔
151
  private writeLibraryGlobalFallback(func: LibraryFunction) {
1✔
152
    if (!func.dontDefineParent && !this.writtenLibraryGlobals.has(func.parent)) {
12✔
153
      let api = '';
2✔
154

2✔
155
      api += `--- Missing description.\n`;
2✔
156
      api += `${func.parent} = {}\n\n`;
2✔
157

2✔
158
      this.writtenLibraryGlobals.add(func.parent);
2✔
159

2✔
160
      return api;
2✔
161
    }
2✔
162

10✔
163
    return '';
10✔
164
  }
10✔
165

1✔
166
  private writeLibraryGlobal(page: TypePage) {
1✔
167
    if (!this.writtenLibraryGlobals.has(page.name)) {
×
168
      let api = '';
×
169

×
170
      api += page.description ? `${putCommentBeforeEachLine(page.description.trim(), false)}\n` : '';
×
171

×
172
      if (page.deprecated)
×
173
        api += `---@deprecated ${removeNewlines(page.deprecated)}\n`;
×
174

×
175
      api += `${page.name} = {}\n\n`;
×
176

×
177
      this.writtenLibraryGlobals.add(page.name);
×
178

×
179
      return api;
×
180
    }
×
181

×
182
    return '';
×
183
  }
×
184

1✔
185
  private writeClassGlobal(page: TypePage) {
1✔
NEW
186
    return this.writeClassStart(page.name, page.realm, page.url, page.parent, page.deprecated, page.description);
×
187
  }
×
188

1✔
189
  private writeClassFunction(func: ClassFunction) {
1✔
190
    let api: string = this.writeClassStart(func.parent, undefined, undefined, undefined, func.deprecated);
1✔
191

1✔
192
    if (!func.arguments || func.arguments.length === 0) func.arguments = [{}];
1!
193
    for (const argSet of func.arguments) {
1✔
194
      api += this.writeFunctionLuaDocComment(func, argSet.args, func.realm);
1✔
195
      api += this.writeFunctionDeclaration(func, argSet.args, ':');
1✔
196
    }
1✔
197

1✔
198
    return api;
1✔
199
  }
1✔
200

1✔
201
  private writeLibraryFunction(func: LibraryFunction) {
1✔
202
    let api: string = this.writeLibraryGlobalFallback(func);
12✔
203

12✔
204
    if (!func.arguments || func.arguments.length === 0) func.arguments = [{}];
12✔
205
    for (const argSet of func.arguments) {
12✔
206
      api += this.writeFunctionLuaDocComment(func, argSet.args, func.realm);
12✔
207
      api += this.writeFunctionDeclaration(func, argSet.args);
12✔
208
    }
12✔
209

11✔
210
    return api;
11✔
211
  }
11✔
212

1✔
213
  private writeHookFunction(func: HookFunction) {
1✔
214
    return this.writeClassFunction(func);
1✔
215
  }
1✔
216

1✔
217
  private writePanel(panel: Panel) {
1✔
218
    let api: string = this.writeClassStart(panel.name, undefined, undefined, panel.parent, panel.deprecated, panel.description);
1✔
219

1✔
220
    return api;
1✔
221
  }
1✔
222

1✔
223
  private writePanelFunction(func: PanelFunction) {
1✔
224
    let api: string = '';
×
225

×
226
    if (!func.arguments || func.arguments.length === 0) func.arguments = [{}];
×
227
    for (const argSet of func.arguments) {
×
228
      api += this.writeFunctionLuaDocComment(func, argSet.args, func.realm);
×
229
      api += this.writeFunctionDeclaration(func, argSet.args, ':');
×
230
    }
×
231

×
232
    return api;
×
233
  }
×
234

1✔
235
  private writeEnum(_enum: Enum) {
1✔
236
    let api: string = '';
2✔
237

2✔
238
    // If the first key is empty (like SCREENFADE has), check the second key
2✔
239
    const isContainedInTable =
2✔
240
      _enum.items[0]?.key === ''
2!
241
        ? _enum.items[1]?.key.includes('.')
2!
242
        : _enum.items[0]?.key.includes('.');
2!
243

2✔
244
    api += `---${this.formatRealm(_enum.realm)} ${_enum.description ? `${putCommentBeforeEachLine(_enum.description.trim())}` : ''}\n`;
2!
245

2✔
246
    if (_enum.deprecated)
2✔
247
      api += `---@deprecated ${removeNewlines(_enum.deprecated)}\n`;
2!
248

2✔
249
    if (isContainedInTable) {
2✔
250
      api += `---@enum ${_enum.name}\n`;
1✔
251
    } else {
1✔
252
      // TODO: Clean up this workaround when LuaLS supports global enumerations.
1✔
253
      // Until LuaLS supports global enumerations (https://github.com/LuaLS/lua-language-server/issues/2721) we
1✔
254
      // will use @alias as a workaround.
1✔
255
      // However since https://wiki.facepunch.com/gmod/Enums/STENCIL defines multiple enums in one page, we need to
1✔
256
      // be careful to only define an enum once.
1✔
257
      // TODO: This only works because both enum definitions for STENCIL have the values 1-8. If the latter enum
1✔
258
      // TODO: had different values, only the values of the first enum would be used.
1✔
259
      if (!this.writtenEnums.has(_enum.name)) {
1✔
260
        const validEnumerations = _enum.items.map(item => item.value)
1✔
261
          .filter(value => !isNaN(Number(value)))
1✔
262
          .join('|');
1✔
263
        api += `---@alias ${_enum.name} ${validEnumerations}\n`;
1✔
264
      }
1✔
265
    }
1✔
266

2✔
267
    this.writtenEnums.add(_enum.name);
2✔
268

2✔
269
    if (isContainedInTable) {
2✔
270
      api += `${_enum.name} = {\n`;
1✔
271
    }
1✔
272

2✔
273
    const writeItem = (key: string, item: typeof _enum.items[0]) => {
2✔
274
      if (key === '') {
10✔
275
        // Happens for SCREENFADE which has a blank key to describe what 0 does.
1✔
276
        return;
1✔
277
      }
1✔
278

9✔
279
      if (isNaN(Number(item.value.trim()))) {
10✔
280
        // Happens for TODO value in NAV_MESH_BLOCKED_LUA in https://wiki.facepunch.com/gmod/Enums/NAV_MESH
1✔
281
        console.warn(`Enum ${_enum.name} has a TODO value for key ${key}. Skipping.`);
1✔
282
        return;
1✔
283
      }
1✔
284

8✔
285
      if (isContainedInTable) {
10✔
286
        key = key.split('.')[1];
5✔
287

5✔
288
        if (item.description?.trim()) {
5✔
289
          api += `${indentText(putCommentBeforeEachLine(item.description.trim(), false), 2)}\n`;
4✔
290
        }
4✔
291

5✔
292
        api += `  ${key} = ${item.value},\n`;
5✔
293
      } else {
10✔
294
        api += item.description ? `${putCommentBeforeEachLine(item.description.trim(), false)}\n` : '';
3✔
295
        if (item.deprecated)
3✔
296
          api += `---@deprecated ${removeNewlines(item.deprecated)}\n`;
3!
297
        api += `${key} = ${item.value}\n`;
3✔
298
      }
3✔
299
    };
2✔
300

2✔
301
    for (const item of _enum.items)
2✔
302
      writeItem(item.key, item);
2✔
303

2✔
304
    if (isContainedInTable)
2✔
305
      api += '}';
2✔
306

2✔
307
    api += `\n\n`;
2✔
308

2✔
309
    return api;
2✔
310
  }
2✔
311

1✔
312
  private writeType(type: string, value: any) {
1✔
313
    if (type === 'string')
×
314
      return `'${escapeSingleQuotes(value)}'`;
×
315

×
316
    if (type === 'Vector')
×
317
      return `Vector${value}`;
×
318

×
319
    return value;
×
320
  }
×
321

1✔
322
  private writeStruct(struct: Struct) {
1✔
323
    let api: string = this.writeClassStart(struct.name, struct.realm, struct.url, undefined, struct.deprecated, struct.description);
2✔
324

2✔
325
    for (const field of struct.fields) {
2✔
326
      if (field.deprecated)
34✔
327
        api += `---@deprecated ${removeNewlines(field.deprecated)}\n`;
34!
328

34✔
329
      api += `---${putCommentBeforeEachLine(field.description.trim())}\n`;
34✔
330

34✔
331
      const type = this.transformType(field.type, field.callback);
34✔
332
      api += `---@type ${type}\n`;
34✔
333
      api += `${struct.name}.${GluaApiWriter.safeName(field.name)} = ${field.default ? this.writeType(type, field.default) : 'nil'}\n\n`;
34!
334
    }
34✔
335

2✔
336
    return api;
2✔
337
  }
2✔
338

1✔
339
  public writePages(pages: WikiPage[], filePath: string, index: number = 0) {
1✔
340
    if (!this.files.has(filePath)) this.files.set(filePath, []);
7✔
341

7✔
342
    pages.forEach(page => {
7✔
343
      this.files.get(filePath)!.push({ index: index, page: page });
7✔
344
    });
7✔
345
  }
7✔
346

1✔
347
  public getPages(filePath: string) {
1✔
348
    return this.files.get(filePath) ?? [];
7!
349
  }
7✔
350

1✔
351
  public makeApiFromPages(pages: IndexedWikiPage[]) {
1✔
352
    let api = "";
7✔
353

7✔
354
    pages.sort((a, b) => a.index - b.index);
7✔
355

7✔
356
    // First we write the "header" types
7✔
357
    for (const page of pages.filter(x => isClass(x.page) || isLibrary(x.page) || isPanel(x.page))) {
7✔
358
      try {
1✔
359
        api += this.writePage(page.page);
1✔
360
      } catch (e) {
1!
NEW
361
        console.error(`Failed to write 'header' page ${page.page.address}: ${e}`);
×
UNCOV
362
      }
×
363
    }
1✔
364

7✔
365
    for (const page of pages.filter(x => !isClass(x.page) && !isLibrary(x.page) && !isPanel(x.page))) {
7✔
366
      try {
6✔
367
        api += this.writePage(page.page);
6✔
368
      } catch (e) {
6!
NEW
369
        console.error(`Failed to write page ${page.page.address}: ${e}`);
×
UNCOV
370
      }
×
371
    }
6✔
372

7✔
373
    return api;
7✔
374
  }
7✔
375

1✔
376
  public writeToDisk() {
1✔
377
    this.files.forEach((pages: IndexedWikiPage[], filePath: string) => {
×
378
      let api = this.makeApiFromPages(pages);
×
379

×
380
      if (api.length > 0) {
×
381
        fs.appendFileSync(filePath, "---@meta\n\n" + api);
×
382
      }
×
383
    });
×
384
  }
×
385

1✔
386
  private transformType(type: string, callback?: FunctionCallback) {
1✔
387
    if (type === 'vararg')
56✔
388
      return 'any';
56✔
389

55✔
390
    // fun(cmd: string, args: string): string[]?
55✔
391
    if (type === "function" && callback) {
56!
392
      let cbStr = `fun(`;
×
393

×
394
      for (const arg of callback.arguments || []) {
×
395
        if (!arg.name) arg.name = arg.type;
×
396
        if (arg.type === 'vararg') arg.name = '...';
×
397

×
398
        cbStr += `${GluaApiWriter.safeName(arg.name)}: ${this.transformType(arg.type)}${arg.default !== undefined ? `?` : ''}, `;
×
399
      }
×
400
      if (cbStr.endsWith(", ")) cbStr = cbStr.substring(0, cbStr.length - 2);
×
401
      cbStr += ")";
×
402

×
403
      for (const ret of callback.returns || []) {
×
404
        if (!ret.name) ret.name = ret.type;
×
405
        if (ret.type === 'vararg') ret.name = '...';
×
406

×
407
        cbStr += `: ${this.transformType(ret.type)}${ret.default !== undefined ? `?` : ''}, `;
×
408
      }
×
409
      if (cbStr.endsWith(", ")) cbStr = cbStr.substring(0, cbStr.length - 2);
×
410

×
411
      return cbStr;
×
412
    } else if (type.startsWith('table<') && !type.includes(',')) {
56✔
413
      // Convert table<Player> to Player[] for LuaLS (but leave table<x, y> untouched)
2✔
414
      let innerType = type.match(/<([^>]+)>/)?.[1];
2✔
415

2✔
416
      if (!innerType) throw new Error(`Invalid table type: ${type}`);
2✔
417

1✔
418
      return `${innerType}[]`;
1✔
419
    } else if (type.startsWith('table{')) {
55✔
420
      // Convert table{ToScreenData} structures to ToScreenData class for LuaLS
1✔
421
      let innerType = type.match(/{([^}]+)}/)?.[1];
1!
422

1✔
423
      if (!innerType) throw new Error(`Invalid table type: ${type}`);
1!
424

1✔
425
      return innerType;
1✔
426
    } else if (type.startsWith('number{')) {
53✔
427
      // Convert number{MATERIAL_FOG} to MATERIAL_FOG enum for LuaLS
1✔
428
      let innerType = type.match(/{([^}]+)}/)?.[1];
1!
429

1✔
430
      if (!innerType) throw new Error(`Invalid number type: ${type}`);
1!
431

1✔
432
      return innerType;
1✔
433
    }
1✔
434

51✔
435
    return type;
51✔
436
  }
51✔
437

1✔
438
  private formatRealm(realm: Realm) {
1✔
439
    // Formats to show the image, with the realm as the alt text
15✔
440
    switch (realm) {
15✔
441
      case 'menu':
15!
442
        return '![(Menu)](https://github.com/user-attachments/assets/62703d98-767e-4cf2-89b3-390b1c2c5cd9)';
×
443
      case 'client':
15✔
444
        return '![(Client)](https://github.com/user-attachments/assets/a5f6ba64-374d-42f0-b2f4-50e5c964e808)';
4✔
445
      case 'server':
15✔
446
        return '![(Server)](https://github.com/user-attachments/assets/d8fbe13a-6305-4e16-8698-5be874721ca1)';
1✔
447
      case 'shared':
15✔
448
        return '![(Shared)](https://github.com/user-attachments/assets/a356f942-57d7-4915-a8cc-559870a980fc)';
8✔
449
      case 'client and menu':
15!
450
        return '![(Client and menu)](https://github.com/user-attachments/assets/25d1a1c8-4288-4a51-9867-5e3bb51b9981)';
×
451
      case 'shared and menu':
15✔
452
        return '![(Shared and Menu)](https://github.com/user-attachments/assets/8f5230ff-38f7-493b-b9fc-cc70ffd5b3f4)';
2✔
453
      default:
15!
454
        throw new Error(`Unknown realm: ${realm}`);
×
455
    }
15✔
456
  }
15✔
457

1✔
458
  private writeFunctionLuaDocComment(func: Function, args: FunctionArgument[] | undefined, realm: Realm) {
1✔
459
    let luaDocComment = `---${this.formatRealm(realm)} ${putCommentBeforeEachLine(func.description!.trim())}\n`;
13✔
460
    luaDocComment += `---\n---[View wiki](${func.url})\n`;
13✔
461

13✔
462
    if (args) {
13✔
463
      args.forEach((arg, index) => {
9✔
464
        if (!arg.name)
11✔
465
          arg.name = arg.type;
11!
466

11✔
467
        if (arg.type === 'vararg')
11✔
468
          arg.name = '...';
11✔
469

11✔
470
        // TODO: This splitting will fail in complicated cases like `table<string|number>|string`.
11✔
471
        // TODO: I'm assuming for now that there is no such case in the GMod API.
11✔
472
        // Split any existing types, append the (deprecated) alt and join them back together
11✔
473
        // while transforming each type to a LuaLS compatible type.
11✔
474
        let types = arg.type.split("|");
11✔
475

11✔
476
        if (arg.altType) {
11✔
477
          types.push(arg.altType);
2✔
478
        }
2✔
479

11✔
480
        let typesString = types.map(type => this.transformType(type, arg.callback))
11✔
481
          .join("|");
11✔
482

11✔
483
        luaDocComment += `---@param ${GluaApiWriter.safeName(arg.name)}${arg.default !== undefined ? `?` : ''} ${typesString} ${putCommentBeforeEachLine(arg.description!.trimEnd())}\n`;
11✔
484
      });
9✔
485
    }
9✔
486

12✔
487
    if (func.returns) {
13✔
488
      func.returns.forEach(ret => {
7✔
489
        const description = putCommentBeforeEachLine(ret.description!.trimEnd());
8✔
490

8✔
491
        luaDocComment += `---@return `;
8✔
492

8✔
493
        if (ret.type === 'vararg')
8✔
494
          luaDocComment += 'any ...';
8✔
495
        else
7✔
496
          luaDocComment += `${this.transformType(ret.type, ret.callback)}`;
7✔
497

8✔
498
        luaDocComment += ` # ${description}\n`;
8✔
499
      });
7✔
500
    }
7✔
501

12✔
502
    if (func.deprecated)
12✔
503
      luaDocComment += `---@deprecated ${removeNewlines(func.deprecated)}\n`;
13!
504

12✔
505
    return luaDocComment;
12✔
506
  }
12✔
507

1✔
508
  private writeFunctionDeclaration(func: Function, args: FunctionArgument[] | undefined, indexer: string = '.') {
1✔
509
    let declaration = `function ${func.parent ? `${func.parent}${indexer}` : ''}${GluaApiWriter.safeName(func.name)}(`;
12!
510

12✔
511
    if (args) {
12✔
512
      declaration += args.map(arg => {
8✔
513
        if (arg.type === 'vararg')
10✔
514
          return '...';
10✔
515

9✔
516
        return GluaApiWriter.safeName(arg.name!);
9✔
517
      }).join(', ');
8✔
518
    }
8✔
519

12✔
520
    declaration += ') end\n\n';
12✔
521

12✔
522
    return declaration;
12✔
523
  }
12✔
524
}
1✔
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