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

streetsidesoftware / cspell / 15422135169

03 Jun 2025 03:58PM UTC coverage: 92.429% (-0.8%) from 93.187%
15422135169

Pull #7414

github

web-flow
Merge f35b4f8b3 into 1e041585f
Pull Request #7414: fix: Add init command to command-line.

12807 of 15079 branches covered (84.93%)

185 of 336 new or added lines in 13 files covered. (55.06%)

7 existing lines in 2 files now uncovered.

15774 of 17066 relevant lines covered (92.43%)

30203.68 hits per line

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

74.37
/packages/cspell-config-lib/src/CSpellConfigFile/CSpellConfigFileYaml.ts
1
import assert from 'node:assert';
2

3
import type { CSpellSettings } from '@cspell/cspell-types';
4
import {
5
    type Document as YamlDocument,
6
    isAlias,
7
    isMap,
8
    isPair,
9
    isScalar,
10
    isSeq,
11
    type Node as YamlNode,
12
    type Pair,
13
    parseDocument,
14
    Scalar,
15
    stringify,
16
    visit as yamlWalkAst,
17
    YAMLMap,
18
    YAMLSeq,
19
} from 'yaml';
20

21
import { MutableCSpellConfigFile } from '../CSpellConfigFile.js';
22
import { detectIndentAsNum } from '../serializers/util.js';
23
import type { TextFile } from '../TextFile.js';
24
import type { KeyOf, ValueOf1 } from '../types.js';
25
import type {
26
    CfgArrayNode,
27
    CfgObjectNode,
28
    CfgScalarNode,
29
    NodeComments,
30
    NodeOrValue,
31
    NodeValue,
32
    RCfgNode,
33
} from '../UpdateConfig/CfgTree.js';
34
import { isNodeValue } from '../UpdateConfig/CfgTree.js';
35
import { ParseError } from './Errors.js';
36

37
type S = CSpellSettings;
38

39
export class CSpellConfigFileYaml extends MutableCSpellConfigFile {
40
    #settings: CSpellSettings | undefined = undefined;
25✔
41

42
    constructor(
43
        readonly url: URL,
25✔
44
        readonly yamlDoc: YamlDocument,
25✔
45
        readonly indent: number,
25✔
46
    ) {
47
        super(url);
25✔
48
        // Set the initial settings from the YAML document.
49
        this.#settings = this.yamlDoc.toJS() as CSpellSettings;
25✔
50
    }
51

52
    get settings(): CSpellSettings {
53
        return this.#settings ?? (this.yamlDoc.toJS() as CSpellSettings);
8✔
54
    }
55

56
    addWords(wordsToAdd: string[]): this {
57
        const cfgWords: YAMLSeq<StringOrScalar> =
58
            (this.yamlDoc.get('words') as YAMLSeq<StringOrScalar>) || new YAMLSeq<StringOrScalar>();
7!
59
        assert(isSeq(cfgWords), 'Expected words to be a YAML sequence');
7✔
60
        const knownWords = new Set(cfgWords.items.map((item) => getScalarValue(item)));
49✔
61
        wordsToAdd.forEach((w) => {
7✔
62
            if (knownWords.has(w)) return;
23✔
63
            cfgWords.add(w);
18✔
64
            knownWords.add(w);
18✔
65
        });
66
        const sorted = sortWords(cfgWords.items);
7✔
67
        sorted.forEach((item, index) => cfgWords.set(index, item));
67✔
68
        cfgWords.items.length = sorted.length;
7✔
69
        this.yamlDoc.set('words', cfgWords);
7✔
70
        this.#markAsMutable();
7✔
71
        return this;
7✔
72
    }
73

74
    serialize() {
75
        return stringify(this.yamlDoc, { indent: this.indent });
15✔
76
    }
77

78
    setValue<K extends keyof S>(key: K, value: NodeOrValue<ValueOf1<S, K>>): this {
79
        if (isNodeValue(value)) {
2✔
80
            let node = this.#getNode(key);
1✔
81
            if (!node) {
1!
NEW
82
                node = this.yamlDoc.createNode(value.value);
×
NEW
83
                setYamlNodeComments(node, value);
×
NEW
84
                this.yamlDoc.set(key, node);
×
85
            } else {
86
                setYamlNodeValue(node, value);
1✔
87
            }
88
        } else {
89
            this.yamlDoc.set(key, value);
1✔
90
        }
91
        this.#markAsMutable();
2✔
92
        return this;
2✔
93
    }
94

95
    getValue<K extends keyof S>(key: K): ValueOf1<S, K> {
96
        const node = this.#getNode(key);
4✔
97
        return node?.toJS(this.yamlDoc);
4✔
98
    }
99

100
    #getNode(key: unknown | unknown[]): YamlNode | undefined {
101
        return getYamlNode(this.yamlDoc, key);
25✔
102
    }
103

104
    getNode<K extends keyof S>(key: K): RCfgNode<ValueOf1<S, K>> | undefined;
105
    getNode<K extends keyof S>(
106
        key: K,
107
        defaultValue: Exclude<ValueOf1<S, K>, undefined>,
108
    ): Exclude<RCfgNode<ValueOf1<S, K>>, undefined>;
109
    getNode<K extends keyof S>(key: K, defaultValue: ValueOf1<S, K> | undefined): RCfgNode<ValueOf1<S, K>> | undefined;
110
    getNode<K extends keyof S>(
111
        key: K,
112
        defaultValue?: ValueOf1<CSpellSettings, K>,
113
    ): RCfgNode<ValueOf1<CSpellSettings, K>> | undefined {
114
        let yNode = this.#getNode(key);
20✔
115
        if (!yNode) {
20✔
116
            if (defaultValue === undefined) {
3✔
117
                return undefined;
2✔
118
            }
119
            yNode = this.yamlDoc.createNode(defaultValue);
1✔
120
            this.yamlDoc.set(key, yNode);
1✔
121
        }
122
        this.#markAsMutable();
18✔
123
        return toConfigNode(this.yamlDoc, yNode) as RCfgNode<ValueOf1<CSpellSettings, K>>;
18✔
124
    }
125

126
    getFieldNode<K extends keyof S>(key: K): RCfgNode<string> | undefined {
127
        const contents = this.yamlDoc.contents;
2✔
128
        if (!isMap(contents)) {
2!
NEW
129
            return undefined;
×
130
        }
131

132
        const pair = findPair(contents, key as string);
2✔
133
        if (!pair) {
2!
NEW
134
            return undefined;
×
135
        }
136
        return toConfigNode(this.yamlDoc, pair.key) as RCfgNode<string>;
2✔
137
    }
138

139
    /**
140
     * Removes a value from the document.
141
     * @returns `true` if the item was found and removed.
142
     */
143
    delete(key: keyof S): boolean {
144
        const removed = this.yamlDoc.delete(key);
1✔
145
        if (removed) {
1!
146
            this.#markAsMutable();
1✔
147
        }
148
        return removed;
1✔
149
    }
150

151
    get comment(): string | undefined {
NEW
152
        return this.yamlDoc.comment ?? undefined;
×
153
    }
154

155
    set comment(comment: string | undefined) {
156
        // eslint-disable-next-line unicorn/no-null
NEW
157
        this.yamlDoc.comment = comment ?? null;
×
158
    }
159

160
    setSchema(schemaRef: string): this {
161
        let commentBefore = this.yamlDoc.commentBefore || '';
2✔
162
        commentBefore = commentBefore.replace(/^ yaml-language-server: \$schema=.*\n?/m, '');
2✔
163
        commentBefore = ` yaml-language-server: $schema=${schemaRef}` + (commentBefore ? '\n' + commentBefore : '');
2!
164
        this.yamlDoc.commentBefore = commentBefore;
2✔
165
        if (this.getNode('$schema')) {
2!
NEW
166
            this.setValue('$schema', schemaRef);
×
167
        }
168
        return this;
2✔
169
    }
170

171
    removeAllComments(): this {
172
        const doc = this.yamlDoc;
1✔
173
        // eslint-disable-next-line unicorn/no-null
174
        doc.comment = null;
1✔
175
        // eslint-disable-next-line unicorn/no-null
176
        doc.commentBefore = null;
1✔
177
        yamlWalkAst(this.yamlDoc, (_, node) => {
1✔
178
            if (!(isScalar(node) || isMap(node) || isSeq(node))) return;
18✔
179
            // eslint-disable-next-line unicorn/no-null
180
            node.comment = null;
14✔
181
            // eslint-disable-next-line unicorn/no-null
182
            node.commentBefore = null;
14✔
183
        });
184
        return this;
1✔
185
    }
186

187
    /**
188
     * Marks the config file as mutable. Any access to settings will the settings to be regenerated
189
     * from the YAML document.
190
     */
191
    #markAsMutable() {
192
        this.#settings = undefined;
28✔
193
    }
194

195
    static parse(file: TextFile): CSpellConfigFileYaml {
196
        return parseCSpellConfigFileYaml(file);
1✔
197
    }
198
}
199

200
export function parseCSpellConfigFileYaml(file: TextFile): CSpellConfigFileYaml {
201
    const { url, content } = file;
28✔
202

203
    try {
28✔
204
        const doc = parseDocument<YAMLMap | Scalar<null | string>>(content);
28✔
205
        // Force empty content to be a map.
206
        if (doc.contents === null || (isScalar(doc.contents) && !doc.contents.value)) {
28✔
207
            doc.contents = new YAMLMap();
3✔
208
        }
209
        if (!isMap(doc.contents)) {
28✔
210
            throw new ParseError(url, `Invalid YAML content ${url}`);
3✔
211
        }
212
        const indent = detectIndentAsNum(content);
25✔
213
        return new CSpellConfigFileYaml(url, doc, indent);
25✔
214
    } catch (e) {
215
        if (e instanceof ParseError) {
3!
216
            throw e;
3✔
217
        }
NEW
218
        throw new ParseError(url, undefined, { cause: e });
×
219
    }
220
}
221

222
function getScalarValue<T>(node: T | Scalar<T>): T {
223
    if (isScalar(node)) {
259✔
224
        return node.value;
196✔
225
    }
226
    return node;
63✔
227
}
228

229
function toScalar<T>(node: T | Scalar<T>): Scalar<T> {
230
    if (isScalar(node)) {
78✔
231
        return node;
60✔
232
    }
233
    return new Scalar(node);
18✔
234
}
235

236
type StringOrScalar = string | Scalar<string>;
237

238
function groupWords(words: StringOrScalar[]): StringOrScalar[][] {
239
    const groups: StringOrScalar[][] = [];
7✔
240
    if (words.length === 0) {
7!
UNCOV
241
        return groups;
×
242
    }
243
    let currentGroup: StringOrScalar[] = [];
7✔
244
    groups.push(currentGroup);
7✔
245
    for (const word of words) {
7✔
246
        if (isSectionHeader(word)) {
67✔
247
            currentGroup = [];
8✔
248
            groups.push(currentGroup);
8✔
249
        }
250
        currentGroup.push(cloneWord(word));
67✔
251
    }
252
    return groups;
7✔
253
}
254

255
function isSectionHeader(word: StringOrScalar): boolean {
256
    if (!isScalar(word) || (!word.commentBefore && !word.spaceBefore)) return false;
67✔
257
    if (word.spaceBefore) return true;
16✔
258
    if (!word.commentBefore) return false;
8!
259
    return word.commentBefore.includes('\n\n');
8✔
260
}
261

262
function adjustSectionHeader(word: Scalar<string>, prev: StringOrScalar, isFirstSection: boolean): void {
263
    // console.log('adjustSectionHeader %o', { word, prev, isFirstSection });
264
    if (!isScalar(prev)) return;
11!
265
    let captureComment = isFirstSection;
11✔
266
    if (prev.spaceBefore) {
11✔
267
        word.spaceBefore = true;
6✔
268
        captureComment = true;
6✔
269
        delete prev.spaceBefore;
6✔
270
    }
271
    if (!prev.commentBefore) return;
11✔
272

273
    const originalComment = prev.commentBefore;
5✔
274
    const lines = originalComment.split(/^\n/gm);
5✔
275
    const lastLine = lines[lines.length - 1];
5✔
276
    // console.log('adjustSectionHeader lines %o', { lines, isFirstSection, lastLine, originalComment });
277
    captureComment = (captureComment && originalComment.trim() === lastLine.trim()) || originalComment.endsWith('\n');
5!
278
    let header = originalComment;
5✔
279
    if (captureComment) {
5✔
280
        delete prev.commentBefore;
3✔
281
    } else {
282
        prev.commentBefore = lastLine;
2✔
283
        lines.pop();
2✔
284
        header = lines.join('\n');
2✔
285
    }
286
    if (word.commentBefore) {
5✔
287
        header += header.endsWith('\n\n') ? '' : '\n';
2!
288
        header += header.endsWith('\n\n') ? '' : '\n';
2✔
289
        header += word.commentBefore;
2✔
290
    }
291
    word.commentBefore = header;
5✔
292
    // console.log('adjustSectionHeader after %o', { word, prev, isFirstSection, originalComment, lastLine, lines });
293
}
294

295
function sortWords(words: StringOrScalar[]): StringOrScalar[] {
296
    const compare = new Intl.Collator().compare;
7✔
297

298
    const groups = groupWords(words);
7✔
299
    let firstGroup = true;
7✔
300
    for (const group of groups) {
7✔
301
        const head = group[0];
15✔
302
        group.sort((a, b) => {
15✔
303
            return compare(getScalarValue(a), getScalarValue(b));
105✔
304
        });
305
        if (group[0] !== head && isScalar(head)) {
15✔
306
            const first = (group[0] = toScalar(group[0]));
11✔
307
            adjustSectionHeader(first, head, firstGroup);
11✔
308
        }
309
        firstGroup = false;
15✔
310
    }
311

312
    const result = groups.flat();
7✔
313
    return result.map((w) => toScalar(w));
67✔
314
}
315

316
function cloneWord(word: StringOrScalar): StringOrScalar {
317
    if (isScalar(word)) {
67✔
318
        return word.clone() as Scalar<string>;
49✔
319
    }
320
    return word;
18✔
321
}
322

323
function getYamlNode(yamlDoc: YamlDocument | YAMLMap | YAMLSeq, key: unknown | unknown[]): YamlNode | undefined {
324
    return (Array.isArray(key) ? yamlDoc.getIn(key, true) : yamlDoc.get(key, true)) as YamlNode | undefined;
30!
325
}
326

327
type ArrayType<T> = T extends unknown[] ? T[number] : never;
328

329
function toConfigNode<T>(doc: YamlDocument, yNode: YamlNode): RCfgNode<T> {
330
    if (isYamlSeq(yNode)) {
24✔
331
        return toConfigArrayNode(doc, yNode) as RCfgNode<T>;
10✔
332
    }
333
    if (isMap(yNode)) {
14✔
334
        return toConfigObjectNode(doc, yNode) as RCfgNode<T>;
1✔
335
    }
336
    if (isScalar(yNode)) {
13!
337
        return toConfigScalarNode(doc, yNode) as RCfgNode<T>;
13✔
338
    }
UNCOV
339
    throw new Error(`Unsupported YAML node type: ${yamlNodeType(yNode)}`);
×
340
}
341

342
function toConfigNodeBase<T>(doc: YamlDocument, yNode: YamlNode) {
343
    const node = {
24✔
344
        get value() {
345
            return yNode.toJS(doc) as T;
24✔
346
        },
347
        get comment() {
348
            return yNode.comment ?? undefined;
24✔
349
        },
350
        set comment(comment: string | undefined) {
351
            // eslint-disable-next-line unicorn/no-null
UNCOV
352
            yNode.comment = comment ?? null;
×
353
        },
354
        get commentBefore() {
355
            return yNode.commentBefore ?? undefined;
24✔
356
        },
357
        set commentBefore(comment: string | undefined) {
358
            // eslint-disable-next-line unicorn/no-null
NEW
359
            yNode.commentBefore = comment ?? null;
×
360
        },
361
    };
362
    return node;
24✔
363
}
364

365
function toConfigArrayNode<T extends unknown[]>(
366
    doc: YamlDocument,
367
    yNode: YAMLSeq<ArrayType<T>>,
368
): CfgArrayNode<ArrayType<T>> {
369
    type TT = ArrayType<T>;
370
    const cfgNode: CfgArrayNode<TT> = {
10✔
371
        type: 'array',
372
        ...toConfigNodeBase<ArrayType<T>[]>(doc, yNode),
373
        getNode(key: number) {
374
            const node = getYamlNode(yNode, key);
4✔
375
            if (!node) return undefined;
4!
376
            return toConfigNode<TT>(doc, node) as RCfgNode<TT>;
4✔
377
        },
378
        getValue(key: number): TT | undefined {
379
            const node = getYamlNode(yNode, key);
1✔
380
            if (!node) return undefined;
1!
381
            return node.toJS(doc) as TT;
1✔
382
        },
383
        setValue(key: number, value: NodeOrValue<TT>): void {
384
            if (!isNodeValue(value)) {
1!
385
                yNode.set(key, value);
1✔
386
                return;
1✔
387
            }
NEW
388
            yNode.set(key, value.value);
×
NEW
389
            const yNodeValue = getYamlNode(yNode, key);
×
NEW
390
            assert(yNodeValue);
×
391
            // eslint-disable-next-line unicorn/no-null
NEW
392
            yNodeValue.comment = value.comment ?? null;
×
393
            // eslint-disable-next-line unicorn/no-null
NEW
394
            yNodeValue.commentBefore = value.commentBefore ?? null;
×
395
        },
396
        delete(key: number): boolean {
NEW
397
            return yNode.delete(key);
×
398
        },
399
        push(value: NodeOrValue<TT>): number {
NEW
400
            if (!isNodeValue(value)) {
×
NEW
401
                yNode.add(value);
×
NEW
402
                return yNode.items.length;
×
403
            }
NEW
404
            yNode.add(value.value);
×
405

NEW
406
            setYamlNodeComments(getYamlNode(yNode, yNode.items.length - 1), value);
×
NEW
407
            return yNode.items.length;
×
408
        },
409
        get length(): number {
NEW
410
            return yNode.items.length;
×
411
        },
412
    };
413

414
    return cfgNode;
10✔
415
}
416

417
function toConfigObjectNode<T extends object>(doc: YamlDocument, yNode: YAMLMap): CfgObjectNode<T> {
418
    const cfgNode: CfgObjectNode<T> = {
1✔
419
        type: 'object',
420
        ...toConfigNodeBase<T>(doc, yNode),
421
        getValue<K extends keyof T>(key: K): T[K] | undefined {
NEW
422
            const node = getYamlNode(yNode, key);
×
NEW
423
            if (!node) return undefined;
×
NEW
424
            return node.toJS(doc) as T[K];
×
425
        },
426
        getNode<K extends keyof T>(key: K): RCfgNode<T[K]> | undefined {
NEW
427
            const node = getYamlNode(yNode, key);
×
NEW
428
            if (!node) return undefined;
×
NEW
429
            return toConfigNode<T[K]>(doc, node);
×
430
        },
431
        setValue<K extends KeyOf<T>>(key: K, value: NodeOrValue<ValueOf1<T, K>>): void {
NEW
432
            if (!isNodeValue(value)) {
×
NEW
433
                yNode.set(key, value);
×
NEW
434
                return;
×
435
            }
NEW
436
            yNode.set(key, value.value);
×
NEW
437
            const yNodeValue = getYamlNode(yNode, key);
×
NEW
438
            assert(yNodeValue);
×
439
            // eslint-disable-next-line unicorn/no-null
NEW
440
            yNodeValue.comment = value.comment ?? null;
×
441
            // eslint-disable-next-line unicorn/no-null
NEW
442
            yNodeValue.commentBefore = value.commentBefore ?? null;
×
443
        },
444
        delete<K extends KeyOf<T>>(key: K): boolean {
NEW
445
            return yNode.delete(key);
×
446
        },
447
    };
448
    return cfgNode;
1✔
449
}
450

451
function toConfigScalarNode<T extends string | number | boolean | null | undefined>(
452
    doc: YamlDocument,
453
    yNode: Scalar,
454
): CfgScalarNode<T> {
455
    const node = toConfigNodeBase<T>(doc, yNode);
13✔
456
    const cfgNode: CfgScalarNode<T> = {
13✔
457
        type: 'scalar',
458
        ...node,
459
    };
460
    return cfgNode;
13✔
461
}
462

463
function isYamlSeq<T>(node: YamlNode): node is YAMLSeq<T> {
464
    return isSeq(node);
24✔
465
}
466

467
function yamlNodeType(node: YamlNode): 'scalar' | 'seq' | 'map' | 'alias' | 'unknown' {
NEW
468
    if (isScalar(node)) return 'scalar';
×
NEW
469
    if (isSeq(node)) return 'seq';
×
NEW
470
    if (isMap(node)) return 'map';
×
NEW
471
    if (isAlias(node)) return 'alias';
×
NEW
472
    return 'unknown';
×
473
}
474

475
function setYamlNodeComments(yamlNode: YamlNode | undefined, comments: NodeComments): void {
476
    if (!yamlNode) return;
1!
477
    if ('comment' in comments) {
1!
478
        // eslint-disable-next-line unicorn/no-null
479
        yamlNode.comment = comments.comment ?? null;
1!
480
    }
481
    if ('commentBefore' in comments) {
1!
482
        // eslint-disable-next-line unicorn/no-null
483
        yamlNode.commentBefore = comments.commentBefore ?? null;
1!
484
    }
485
}
486

487
function setYamlNodeValue<T>(yamlNode: YamlNode, nodeValue: NodeValue<T>): void {
488
    setYamlNodeComments(yamlNode, nodeValue);
1✔
489
    if (isScalar(yamlNode)) {
1!
490
        yamlNode.value = nodeValue.value;
1✔
491
        return;
1✔
492
    }
NEW
493
    const value = nodeValue.value;
×
NEW
494
    if (isSeq(yamlNode)) {
×
NEW
495
        assert(Array.isArray(value), 'Expected value to be an array for YAMLSeq');
×
NEW
496
        yamlNode.items = [];
×
NEW
497
        for (let i = 0; i < value.length; ++i) {
×
NEW
498
            yamlNode.set(i, value[i]);
×
499
        }
NEW
500
        return;
×
501
    }
NEW
502
    if (isMap(yamlNode)) {
×
NEW
503
        assert(typeof value === 'object' && value !== null, 'Expected value to be an object for YAMLMap');
×
NEW
504
        yamlNode.items = [];
×
NEW
505
        for (const [key, val] of Object.entries(value)) {
×
NEW
506
            yamlNode.set(key, val);
×
507
        }
NEW
508
        return;
×
509
    }
NEW
510
    throw new Error(`Unsupported YAML node type: ${yamlNodeType(yamlNode)}`);
×
511
}
512

513
function findPair(yNode: YamlNode, key: string): Pair<Scalar<string>, YamlNode> | undefined {
514
    if (!isMap(yNode)) return undefined;
2!
515
    const items = yNode.items as Pair<YamlNode, YamlNode>[];
2✔
516
    for (const item of items) {
2✔
517
        if (!isPair(item)) continue;
5!
518
        if (isScalar(item.key) && item.key.value === key) {
5✔
519
            return item as Pair<Scalar<string>, YamlNode>;
2✔
520
        }
521
    }
NEW
522
    return undefined;
×
523
}
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