eslint-soml 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +33 -0
- package/index.js +47 -0
- package/language/soml-language.js +72 -0
- package/language/soml-source-code.js +277 -0
- package/license +9 -0
- package/package.json +83 -0
- package/readme.md +175 -0
- package/rules/format.js +134 -0
- package/rules/index.js +17 -0
- package/rules/key-name-casing.js +94 -0
- package/rules/no-block-string-trailing-whitespace.js +64 -0
- package/rules/no-empty-keys.js +43 -0
- package/rules/no-invisible-characters.js +90 -0
- package/rules/no-loss-of-precision.js +96 -0
- package/rules/no-mixed-number-types.js +81 -0
- package/rules/no-numeric-dotted-keys.js +81 -0
- package/rules/no-unnormalized-keys.js +62 -0
- package/rules/numeric-separators-style.js +136 -0
- package/rules/prefer-bare-keys.js +63 -0
- package/rules/prefer-literal-strings.js +85 -0
- package/rules/prefer-utc-instants.js +53 -0
- package/rules/rule/index.js +7 -0
- package/rules/rule/soml-context.js +47 -0
- package/rules/rule/soml-listeners.js +63 -0
- package/rules/rule/to-eslint-create.js +34 -0
- package/rules/rule/to-eslint-listener.js +42 -0
- package/rules/rule/to-eslint-problem.js +53 -0
- package/rules/rule/to-eslint-rule-fixer.js +56 -0
- package/rules/rule/to-eslint-rule.js +36 -0
- package/rules/rule/to-eslint-rules.js +9 -0
- package/rules/rule/utilities.js +29 -0
- package/rules/sort-keys.js +187 -0
- package/rules/spaced-comment.js +83 -0
- package/rules/utils/escape-string.js +30 -0
- package/rules/utils/get-documentation-url.js +15 -0
- package/rules/utils/index.js +2 -0
- package/rules/utils/invisible-characters.js +26 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID = 'no-unnormalized-keys';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'The key `{{key}}` is not in {{form}}, so it can look the same as a different key.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
@param {SomlContext} context
|
|
13
|
+
*/
|
|
14
|
+
const create = context => {
|
|
15
|
+
const [{form}] = context.options;
|
|
16
|
+
|
|
17
|
+
context.on('KeySegment', node => {
|
|
18
|
+
if (node.value.normalize(form) === node.value) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
node,
|
|
24
|
+
messageId: MESSAGE_ID,
|
|
25
|
+
data: {
|
|
26
|
+
key: node.value,
|
|
27
|
+
form,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
@type {SomlRule}
|
|
35
|
+
*/
|
|
36
|
+
const config = {
|
|
37
|
+
create,
|
|
38
|
+
meta: {
|
|
39
|
+
type: 'problem',
|
|
40
|
+
docs: {
|
|
41
|
+
description: 'Disallow keys that are not in a Unicode normalization form.',
|
|
42
|
+
recommended: 'unopinionated',
|
|
43
|
+
},
|
|
44
|
+
schema: [
|
|
45
|
+
{
|
|
46
|
+
type: 'object',
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
properties: {
|
|
49
|
+
form: {
|
|
50
|
+
enum: ['NFC', 'NFD', 'NFKC', 'NFKD'],
|
|
51
|
+
description: 'The Unicode normalization form keys must be in.',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
defaultOptions: [{form: 'NFC'}],
|
|
57
|
+
messages,
|
|
58
|
+
languages: ['soml/soml'],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export default config;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID = 'numeric-separators-style';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'Group the digits as `{{formatted}}`.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DECIMAL_NUMBER = /^(?<sign>-?)(?<integer>[\d_]+)(?:\.(?<fraction>[\d_]+))?(?:e(?<exponentSign>-?)(?<exponent>[\d_]+))?$/v;
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
Groups `digits` with `_`, counting from the right, or from the left for a fraction. Fewer than `minimumDigits` digits, or no `groupLength`, means no grouping.
|
|
15
|
+
*/
|
|
16
|
+
function group(digits, {minimumDigits, groupLength, isFromLeft = false}) {
|
|
17
|
+
if (groupLength === undefined || digits.length < minimumDigits) {
|
|
18
|
+
return digits;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const groups = [];
|
|
22
|
+
|
|
23
|
+
if (isFromLeft) {
|
|
24
|
+
for (let start = 0; start < digits.length; start += groupLength) {
|
|
25
|
+
groups.push(digits.slice(start, start + groupLength));
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
for (let end = digits.length; end > 0; end -= groupLength) {
|
|
29
|
+
groups.unshift(digits.slice(Math.max(end - groupLength, 0), end));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return groups.join('_');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function format(text, options) {
|
|
37
|
+
const {sign, integer, fraction, exponentSign, exponent} = DECIMAL_NUMBER.exec(text).groups;
|
|
38
|
+
const strip = digits => digits.replaceAll('_', '');
|
|
39
|
+
let formatted = sign + group(strip(integer), options);
|
|
40
|
+
|
|
41
|
+
if (fraction !== undefined) {
|
|
42
|
+
formatted += `.${group(strip(fraction), {...options, groupLength: options.fractionGroupLength, isFromLeft: true})}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (exponent !== undefined) {
|
|
46
|
+
formatted += `e${exponentSign}${group(strip(exponent), options)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return formatted;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
@param {SomlContext} context
|
|
54
|
+
*/
|
|
55
|
+
const create = context => {
|
|
56
|
+
const [options] = context.options;
|
|
57
|
+
|
|
58
|
+
context.on(['Integer', 'Float'], node => {
|
|
59
|
+
// A hexadecimal, octal, or binary int is grouped by what its digits mean, such as bytes or bits, so it is left alone.
|
|
60
|
+
if (node.type === 'Integer' ? node.radix !== 10 : !Number.isFinite(node.value)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = context.sourceCode.getText(node);
|
|
65
|
+
|
|
66
|
+
if (options.onlyIfContainsSeparator && !text.includes('_')) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const formatted = format(text, options);
|
|
71
|
+
|
|
72
|
+
if (formatted === text) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
node,
|
|
78
|
+
messageId: MESSAGE_ID,
|
|
79
|
+
data: {formatted},
|
|
80
|
+
fix: fixer => fixer.replaceText(node, formatted),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
@type {SomlRule}
|
|
87
|
+
*/
|
|
88
|
+
const config = {
|
|
89
|
+
create,
|
|
90
|
+
meta: {
|
|
91
|
+
type: 'suggestion',
|
|
92
|
+
docs: {
|
|
93
|
+
description: 'Enforce the grouping of digits with `_` in decimal numbers.',
|
|
94
|
+
recommended: true,
|
|
95
|
+
},
|
|
96
|
+
fixable: 'code',
|
|
97
|
+
schema: [
|
|
98
|
+
{
|
|
99
|
+
type: 'object',
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
properties: {
|
|
102
|
+
minimumDigits: {
|
|
103
|
+
type: 'integer',
|
|
104
|
+
minimum: 0,
|
|
105
|
+
description: 'The number of digits from which separators are required.',
|
|
106
|
+
},
|
|
107
|
+
groupLength: {
|
|
108
|
+
type: 'integer',
|
|
109
|
+
minimum: 1,
|
|
110
|
+
description: 'The number of digits in each group.',
|
|
111
|
+
},
|
|
112
|
+
fractionGroupLength: {
|
|
113
|
+
type: 'integer',
|
|
114
|
+
minimum: 1,
|
|
115
|
+
description: 'The number of digits in each group after the decimal point. By default, the digits after the decimal point are not grouped.',
|
|
116
|
+
},
|
|
117
|
+
onlyIfContainsSeparator: {
|
|
118
|
+
type: 'boolean',
|
|
119
|
+
description: 'Whether to only check numbers that already contain a separator.',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
defaultOptions: [
|
|
125
|
+
{
|
|
126
|
+
minimumDigits: 5,
|
|
127
|
+
groupLength: 3,
|
|
128
|
+
onlyIfContainsSeparator: false,
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
messages,
|
|
132
|
+
languages: ['soml/soml'],
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default config;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID = 'prefer-bare-keys';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'The key `{{key}}` does not need quotes.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
A bare key is ASCII letters, digits, `_`, and `-`.
|
|
13
|
+
*/
|
|
14
|
+
const BARE_KEY = /^[\w\-]+$/v;
|
|
15
|
+
|
|
16
|
+
const isDigit = character => character >= '0' && character <= '9';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
@param {SomlContext} context
|
|
20
|
+
*/
|
|
21
|
+
const create = context => {
|
|
22
|
+
context.on('Key', function * (key) {
|
|
23
|
+
for (const [index, node] of key.segments.entries()) {
|
|
24
|
+
if (node.style === 'bare' || !BARE_KEY.test(node.value)) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Without quotes, a digit on both sides of a `.` reads as a number, as in `1.5`, which `no-numeric-dotted-keys` reports.
|
|
29
|
+
const previous = key.segments[index - 1]?.value ?? '';
|
|
30
|
+
const next = key.segments[index + 1]?.value ?? '';
|
|
31
|
+
|
|
32
|
+
if ((isDigit(previous.at(-1)) && isDigit(node.value[0])) || (isDigit(node.value.at(-1)) && isDigit(next[0]))) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
yield {
|
|
37
|
+
node,
|
|
38
|
+
messageId: MESSAGE_ID,
|
|
39
|
+
data: {key: node.value},
|
|
40
|
+
fix: fixer => fixer.replaceText(node, node.value),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
@type {SomlRule}
|
|
48
|
+
*/
|
|
49
|
+
const config = {
|
|
50
|
+
create,
|
|
51
|
+
meta: {
|
|
52
|
+
type: 'suggestion',
|
|
53
|
+
docs: {
|
|
54
|
+
description: 'Prefer bare keys when a key does not need quotes.',
|
|
55
|
+
recommended: true,
|
|
56
|
+
},
|
|
57
|
+
fixable: 'code',
|
|
58
|
+
messages,
|
|
59
|
+
languages: ['soml/soml'],
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default config;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {hasInvisibleCharacter} from './utils/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
5
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
6
|
+
@import {StringNode, KeySegmentNode} from 'soml-lang';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MESSAGE_ID = 'prefer-literal-strings';
|
|
10
|
+
const messages = {
|
|
11
|
+
[MESSAGE_ID]: 'Prefer a literal string. A `"…"` string is only needed for escapes.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
What a `'...'` string cannot hold: `'`, which would end it, and a line feed. A tab and every other control character stay escaped too, because they are invisible. That is also how canonical form decides.
|
|
16
|
+
*/
|
|
17
|
+
const NOT_LITERAL = /[\p{Control}']/v;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
The literal spelling of a string or key with the same value, or `undefined` when there is none.
|
|
21
|
+
|
|
22
|
+
@param {StringNode | KeySegmentNode} node
|
|
23
|
+
@param {string} text The source text of the node.
|
|
24
|
+
@returns {string | undefined}
|
|
25
|
+
*/
|
|
26
|
+
function getLiteral(node, text) {
|
|
27
|
+
// A block string keeps its layout, so it only changes when its text already means the same without escapes. With no `'` in it, no line can close the new delimiter early.
|
|
28
|
+
if (node.block) {
|
|
29
|
+
if (text.includes('\\') || text.includes('\'') || hasInvisibleCharacter(text)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const delimiter = '\''.repeat(text.indexOf('\n'));
|
|
34
|
+
return delimiter + text.slice(delimiter.length, -delimiter.length) + delimiter;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Invisible characters stay escaped, as `no-invisible-characters` asks for.
|
|
38
|
+
if (NOT_LITERAL.test(node.value) || hasInvisibleCharacter(node.value)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `'${node.value}'`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
@param {SomlContext} context
|
|
47
|
+
*/
|
|
48
|
+
const create = context => {
|
|
49
|
+
context.on(['String', 'KeySegment'], node => {
|
|
50
|
+
if (node.style !== 'escaped') {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const literal = getLiteral(node, context.sourceCode.getText(node));
|
|
55
|
+
|
|
56
|
+
if (literal === undefined) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
node,
|
|
62
|
+
messageId: MESSAGE_ID,
|
|
63
|
+
fix: fixer => fixer.replaceText(node, literal),
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
@type {SomlRule}
|
|
70
|
+
*/
|
|
71
|
+
const config = {
|
|
72
|
+
create,
|
|
73
|
+
meta: {
|
|
74
|
+
type: 'suggestion',
|
|
75
|
+
docs: {
|
|
76
|
+
description: 'Prefer literal strings, `\'…\'`, when a string has no escapes.',
|
|
77
|
+
recommended: true,
|
|
78
|
+
},
|
|
79
|
+
fixable: 'code',
|
|
80
|
+
messages,
|
|
81
|
+
languages: ['soml/soml'],
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default config;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID = 'prefer-utc-instants';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'Write the instant in UTC, as `{{utc}}`.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
@param {SomlContext} context
|
|
13
|
+
*/
|
|
14
|
+
const create = context => {
|
|
15
|
+
context.on('Instant', node => {
|
|
16
|
+
const text = context.sourceCode.getText(node);
|
|
17
|
+
|
|
18
|
+
if (text.endsWith('Z')) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Keep as many fractional digits as were written, so `.500` stays `.500`.
|
|
23
|
+
const fractionalSecondDigits = /\.(?<fraction>\d+)/v.exec(text)?.groups.fraction.length ?? 0;
|
|
24
|
+
const utc = node.value.toString({fractionalSecondDigits});
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
node,
|
|
28
|
+
messageId: MESSAGE_ID,
|
|
29
|
+
data: {utc},
|
|
30
|
+
// The offset is not part of the value, so this keeps it.
|
|
31
|
+
fix: fixer => fixer.replaceText(node, utc),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
@type {SomlRule}
|
|
38
|
+
*/
|
|
39
|
+
const config = {
|
|
40
|
+
create,
|
|
41
|
+
meta: {
|
|
42
|
+
type: 'suggestion',
|
|
43
|
+
docs: {
|
|
44
|
+
description: 'Prefer instants in UTC.',
|
|
45
|
+
recommended: false,
|
|
46
|
+
},
|
|
47
|
+
fixable: 'code',
|
|
48
|
+
messages,
|
|
49
|
+
languages: ['soml/soml'],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default config;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {RuleContext} from '@eslint/core';
|
|
3
|
+
@import {Node} from 'soml-lang';
|
|
4
|
+
@import SomlListeners from './soml-listeners.js';
|
|
5
|
+
@import {SomlProblems} from './to-eslint-problem.js';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
@typedef {RuleContext} SomlRuleContext
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
@template {Node['type']} [NodeType=Node['type']]
|
|
14
|
+
@typedef {(node: Extract<Node, {type: NodeType}>, parent: Node | undefined) => SomlProblems} SomlRuleListener
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
@typedef {<NodeType extends Node['type']>(type: NodeType | NodeType[], listener: SomlRuleListener<NodeType>) => void} SomlRuleListen
|
|
19
|
+
@typedef {SomlRuleContext & {
|
|
20
|
+
on: SomlRuleListen
|
|
21
|
+
onExit: SomlRuleListen
|
|
22
|
+
}} SomlContext
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
Create a better `Context` object with `on` and `onExit` method to add listeners
|
|
27
|
+
|
|
28
|
+
@param {SomlRuleContext} eslintContext
|
|
29
|
+
@param {SomlListeners} listeners
|
|
30
|
+
@returns {SomlContext}
|
|
31
|
+
*/
|
|
32
|
+
export default function createSomlContext(eslintContext, listeners) {
|
|
33
|
+
/**
|
|
34
|
+
@type {SomlContext}
|
|
35
|
+
*/
|
|
36
|
+
const context = new Proxy(/** @type {SomlContext} */ (eslintContext), {
|
|
37
|
+
get(target, property, receiver) {
|
|
38
|
+
if (property === 'on' || property === 'onExit') {
|
|
39
|
+
return listeners[property].bind(listeners);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return Reflect.get(target, property, receiver);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return context;
|
|
47
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import toEslintListener from './to-eslint-listener.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
@import {RuleVisitor} from '@eslint/core';
|
|
5
|
+
@import {Node} from 'soml-lang';
|
|
6
|
+
@import {SomlRuleContext, SomlRuleListener} from './soml-context.js';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export default class SomlListeners {
|
|
10
|
+
#context;
|
|
11
|
+
#listeners = new Map();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
@param {SomlRuleContext} context
|
|
15
|
+
*/
|
|
16
|
+
constructor(context) {
|
|
17
|
+
this.#context = context;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#addEventListener(selectors, listener) {
|
|
21
|
+
const listeners = this.#listeners;
|
|
22
|
+
for (const selector of selectors) {
|
|
23
|
+
if (listeners.has(selector)) {
|
|
24
|
+
listeners.get(selector).push(listener);
|
|
25
|
+
} else {
|
|
26
|
+
listeners.set(selector, [listener]);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
@template {Node['type']} NodeType
|
|
33
|
+
@param {NodeType | NodeType[]} selectorOrSelectors
|
|
34
|
+
@param {SomlRuleListener<NodeType>} listener
|
|
35
|
+
*/
|
|
36
|
+
on(selectorOrSelectors, listener) {
|
|
37
|
+
const selectors = Array.isArray(selectorOrSelectors) ? selectorOrSelectors : [selectorOrSelectors];
|
|
38
|
+
this.#addEventListener(selectors, listener);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
@template {Node['type']} NodeType
|
|
43
|
+
@param {NodeType | NodeType[]} selectorOrSelectors
|
|
44
|
+
@param {SomlRuleListener<NodeType>} listener
|
|
45
|
+
*/
|
|
46
|
+
onExit(selectorOrSelectors, listener) {
|
|
47
|
+
const selectors = Array.isArray(selectorOrSelectors) ? selectorOrSelectors : [selectorOrSelectors];
|
|
48
|
+
this.#addEventListener(selectors.map(selector => `${selector}:exit`), listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
@returns {RuleVisitor}
|
|
53
|
+
*/
|
|
54
|
+
toEslintListeners() {
|
|
55
|
+
const eslintListeners = {};
|
|
56
|
+
|
|
57
|
+
for (const [selector, listeners] of this.#listeners) {
|
|
58
|
+
eslintListeners[selector] = toEslintListener(this.#context, listeners);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return eslintListeners;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import createSomlContext from './soml-context.js';
|
|
3
|
+
import SomlListeners from './soml-listeners.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
@import {RuleDefinition} from '@eslint/core';
|
|
7
|
+
@import {SomlContext} from './soml-context.js';
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
@typedef {RuleDefinition['create']} EslintCreate
|
|
12
|
+
@typedef {(context: SomlContext) => void} SomlCreate
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
Convert Soml style of `create` to ESLint style
|
|
17
|
+
|
|
18
|
+
@param {SomlCreate} somlCreate
|
|
19
|
+
@returns {EslintCreate}
|
|
20
|
+
*/
|
|
21
|
+
export default function toEslintCreate(somlCreate) {
|
|
22
|
+
return eslintContext => {
|
|
23
|
+
const somlListeners = new SomlListeners(eslintContext);
|
|
24
|
+
const somlContext = createSomlContext(eslintContext, somlListeners);
|
|
25
|
+
|
|
26
|
+
const result = somlCreate(somlContext);
|
|
27
|
+
|
|
28
|
+
assert.equal(result, undefined, `[${eslintContext.id}] Rule \`create\` function should return \`undefined\`, please use \`context.on()\` instead of return listeners.`);
|
|
29
|
+
|
|
30
|
+
const eslintListeners = somlListeners.toEslintListeners();
|
|
31
|
+
|
|
32
|
+
return eslintListeners;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {forEachFixOrProblem} from './utilities.js';
|
|
2
|
+
import toEslintProblem from './to-eslint-problem.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
@import {RuleVisitor} from '@eslint/core';
|
|
6
|
+
@import {Node} from 'soml-lang';
|
|
7
|
+
@import {SomlRuleContext, SomlRuleListener} from './soml-context.js';
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
@typedef {(node: Node, parent: Node | undefined) => void} EslintListener
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
@param {SomlRuleContext} context
|
|
16
|
+
@param {SomlRuleListener[]} listeners
|
|
17
|
+
@returns {EslintListener}
|
|
18
|
+
*/
|
|
19
|
+
export default function toEslintListener(context, listeners) {
|
|
20
|
+
const reportProblem = somlProblem => {
|
|
21
|
+
if (somlProblem) {
|
|
22
|
+
context.report(toEslintProblem(somlProblem));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
Declared with fixed arity rather than rest arguments, since this runs for every node visit of every rule and a per-call rest array is measurable.
|
|
28
|
+
Listeners receive the node and its parent.
|
|
29
|
+
*/
|
|
30
|
+
return (node, parent) => {
|
|
31
|
+
for (const listener of listeners) {
|
|
32
|
+
const somlProblems = listener(node, parent);
|
|
33
|
+
|
|
34
|
+
// Listeners report nothing on the vast majority of nodes, so keep that path free of iterator allocation.
|
|
35
|
+
if (!somlProblems) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
forEachFixOrProblem(somlProblems, reportProblem);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import toEslintFixer from './to-eslint-rule-fixer.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
@import {SomlRuleContext} from './soml-context.js';
|
|
5
|
+
@import {SomlRuleFixer} from './to-eslint-rule-fixer.js';
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
@typedef {Parameters<SomlRuleContext['report']>[0]} EslintProblem
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
@template {NonNullable<EslintProblem['suggest']>[number]} [Suggestion=NonNullable<EslintProblem['suggest']>[number]]
|
|
14
|
+
@typedef {Suggestion extends unknown ? Omit<Suggestion, 'fix'> & {fix: SomlRuleFixer} : never} SomlSuggestion
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
@template {EslintProblem} [Problem=EslintProblem]
|
|
19
|
+
@typedef {Problem extends unknown ? Omit<Problem, 'fix' | 'suggest'> & {fix?: SomlRuleFixer, suggest?: SomlSuggestion[]} : never} SomlProblem
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
@typedef {SomlProblem | void | Iterable<SomlProblem | void>} SomlProblems
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
@param {SomlProblem} somlProblem
|
|
28
|
+
@returns {EslintProblem}
|
|
29
|
+
*/
|
|
30
|
+
export default function toEslintProblem(somlProblem) {
|
|
31
|
+
const {fix, suggest, ...problem} = somlProblem;
|
|
32
|
+
/**
|
|
33
|
+
@type {EslintProblem}
|
|
34
|
+
*/
|
|
35
|
+
const eslintProblem = problem;
|
|
36
|
+
|
|
37
|
+
if (fix) {
|
|
38
|
+
eslintProblem.fix = toEslintFixer(fix);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (Array.isArray(suggest)) {
|
|
42
|
+
eslintProblem.suggest = suggest.map(somlSuggest => ({
|
|
43
|
+
...somlSuggest,
|
|
44
|
+
fix: toEslintFixer(somlSuggest.fix),
|
|
45
|
+
data: {
|
|
46
|
+
...somlProblem.data,
|
|
47
|
+
...somlSuggest.data,
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return eslintProblem;
|
|
53
|
+
}
|