eslint-soml 0.0.2 → 0.0.3
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 +0 -1
- package/language/soml-source-code.js +23 -1
- package/package.json +2 -3
- package/readme.md +4 -4
- package/rules/format.js +9 -2
- package/rules/index.js +0 -2
- package/rules/key-name-casing.js +1 -1
- package/rules/no-confusable-keys.js +17 -26
- package/rules/no-decimal-file-modes.js +10 -3
- package/rules/no-duplicate-array-items.js +3 -1
- package/rules/no-empty-keys.js +1 -1
- package/rules/no-invisible-characters.js +3 -3
- package/rules/no-misplaced-block-string-delimiters.js +12 -5
- package/rules/no-placeholder-values.js +27 -4
- package/rules/no-sizes-as-durations.js +2 -4
- package/rules/no-unnormalized-keys.js +4 -2
- package/rules/prefer-bare-keys.js +11 -26
- package/rules/prefer-literal-strings.js +3 -3
- package/rules/sort-keys.js +64 -22
- package/rules/spaced-comment.js +2 -2
- package/rules/utils/escape-string.js +12 -3
- package/rules/utils/get-key-words.js +4 -2
- package/rules/utils/has-doubled-backslashes.js +3 -2
- package/rules/utils/index.js +7 -2
- package/rules/utils/invisible-characters.js +6 -2
- package/rules/utils/is-disabled-anywhere.js +19 -0
- package/rules/no-numeric-dotted-keys.js +0 -69
- package/rules/no-scattered-dotted-keys.js +0 -102
- package/rules/utils/is-decimal-point.js +0 -12
package/index.d.ts
CHANGED
|
@@ -16,6 +16,28 @@ const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|
|
|
|
16
16
|
|
|
17
17
|
const isComment = item => item.type === 'Line' || item.type === 'Block';
|
|
18
18
|
|
|
19
|
+
/*
|
|
20
|
+
Merges the tokens and the comments, which are each in source order, into one list in source order. A merge takes linear time, where a sort would not.
|
|
21
|
+
*/
|
|
22
|
+
function mergeByStart(tokens, comments) {
|
|
23
|
+
const items = [];
|
|
24
|
+
let commentIndex = 0;
|
|
25
|
+
|
|
26
|
+
for (const token of tokens) {
|
|
27
|
+
while (commentIndex < comments.length && comments[commentIndex].range[0] < token.range[0]) {
|
|
28
|
+
items.push(comments[commentIndex++]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
items.push(token);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
while (commentIndex < comments.length) {
|
|
35
|
+
items.push(comments[commentIndex++]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return items;
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
The source code of a SOML document, which rules get as `context.sourceCode`.
|
|
21
43
|
*/
|
|
@@ -48,7 +70,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
48
70
|
// SOML has no carriage returns, so a line ends only at a line feed.
|
|
49
71
|
super({text, ast, lineEndingPattern: /\n/v});
|
|
50
72
|
this.ast = ast;
|
|
51
|
-
this.#items =
|
|
73
|
+
this.#items = mergeByStart(ast.tokens, ast.comments);
|
|
52
74
|
}
|
|
53
75
|
|
|
54
76
|
/*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-soml",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "ESLint plugin for SOML, a config format for humans",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:soml-lang/eslint-soml",
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
],
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@eslint/plugin-kit": "^0.7.3",
|
|
55
|
-
"
|
|
56
|
-
"soml-lang": "^0.0.3"
|
|
55
|
+
"soml-lang": "^0.0.4"
|
|
57
56
|
},
|
|
58
57
|
"devDependencies": {
|
|
59
58
|
"@babel/code-frame": "^8.0.6",
|
package/readme.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
> ESLint plugin for [SOML](https://github.com/soml-lang/soml), a config format for humans
|
|
4
4
|
|
|
5
|
+
[`eslint-plugin-unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn#soml) has more rules that also lint SOML, such as `prefer-https` and `name-replacements`.
|
|
6
|
+
|
|
5
7
|
[**Propose a new rule ➡**](.github/contributing.md)
|
|
6
8
|
|
|
7
9
|
*We do not accept pull requests because of too much AI slop.*
|
|
@@ -61,7 +63,7 @@ Disable a rule with a comment, in either comment style:
|
|
|
61
63
|
|
|
62
64
|
The [`reportUnusedDisableDirectives`](https://eslint.org/docs/latest/use/configure/configuration-files#report-unused-disable-directives) and [`reportUnusedInlineConfigs`](https://eslint.org/docs/latest/use/configure/configuration-files#report-unused-inline-configs) linter options work for `.soml` files, and so do the rules of [`@eslint-community/eslint-plugin-eslint-comments`](https://github.com/eslint-community/eslint-plugin-eslint-comments), such as `no-unlimited-disable`.
|
|
63
65
|
|
|
64
|
-
ESLint's [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) rule works too
|
|
66
|
+
ESLint's [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) rule works too. For example, to disallow a key named `password`:
|
|
65
67
|
|
|
66
68
|
```js
|
|
67
69
|
import soml from 'eslint-soml';
|
|
@@ -75,7 +77,7 @@ export default defineConfig([
|
|
|
75
77
|
'no-restricted-syntax': [
|
|
76
78
|
'error',
|
|
77
79
|
{
|
|
78
|
-
selector: 'Member > Key
|
|
80
|
+
selector: 'Member > Key[value="password"]',
|
|
79
81
|
message: 'Read the password from the environment.',
|
|
80
82
|
},
|
|
81
83
|
],
|
|
@@ -113,10 +115,8 @@ export default defineConfig([
|
|
|
113
115
|
| [no-misplaced-block-string-delimiters](docs/rules/no-misplaced-block-string-delimiters.md) | Disallow a block string delimiter at the end of a content line. | ✅ ☑️ | | |
|
|
114
116
|
| [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
|
|
115
117
|
| [no-negative-zero](docs/rules/no-negative-zero.md) | Disallow a sign on zero. | ✅ | 🔧 | |
|
|
116
|
-
| [no-numeric-dotted-keys](docs/rules/no-numeric-dotted-keys.md) | Disallow dotted keys that read as one number. | ✅ ☑️ | | 💡 |
|
|
117
118
|
| [no-placeholder-values](docs/rules/no-placeholder-values.md) | Disallow placeholder values, such as `'changeme'`. | ✅ | | |
|
|
118
119
|
| [no-quoted-keywords](docs/rules/no-quoted-keywords.md) | Disallow the strings `'true'`, `'false'`, and `'null'`. | ✅ | | 💡 |
|
|
119
|
-
| [no-scattered-dotted-keys](docs/rules/no-scattered-dotted-keys.md) | Require the dotted keys under one prefix to be next to each other. | ✅ | 🔧 | |
|
|
120
120
|
| [no-sizes-as-durations](docs/rules/no-sizes-as-durations.md) | Disallow sizes that are durations, such as `memory: 512m`. | ✅ ☑️ | | 💡 |
|
|
121
121
|
| [no-unnormalized-keys](docs/rules/no-unnormalized-keys.md) | Disallow keys that are not in a Unicode normalization form. | ✅ ☑️ | | |
|
|
122
122
|
| [no-zero-duration-parts](docs/rules/no-zero-duration-parts.md) | Disallow duration parts that are zero. | ✅ | 🔧 | |
|
package/rules/format.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {formatEdits} from 'soml-lang';
|
|
2
|
+
import {isDisabledAnywhere} from './utils/index.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -32,6 +33,12 @@ const create = context => {
|
|
|
32
33
|
const {text} = sourceCode;
|
|
33
34
|
const edits = formatEdits(text);
|
|
34
35
|
|
|
36
|
+
if (edits.length === 0) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isFixable = !isDisabledAnywhere(context);
|
|
41
|
+
|
|
35
42
|
// eslint-disable-next-line internal/no-restricted-property-access -- An edit is not a node.
|
|
36
43
|
for (const [index, {range, text: inserted}] of edits.entries()) {
|
|
37
44
|
const [start, end] = range;
|
|
@@ -54,8 +61,8 @@ const create = context => {
|
|
|
54
61
|
deleted: showInvisibles(deleted),
|
|
55
62
|
inserted: showInvisibles(inserted),
|
|
56
63
|
},
|
|
57
|
-
// Every edit is in one fix, so the formatting is applied at once. Applying only some edits, when a fix from another rule overlaps one of them, could leave a block string half moved. Only the first problem carries the fix, because a copy of every edit in every problem would make the output grow with the file size times the problem count.
|
|
58
|
-
fix: index === 0
|
|
64
|
+
// Every edit is in one fix, so the formatting is applied at once. Applying only some edits, when a fix from another rule overlaps one of them, could leave a block string half moved. Only the first problem carries the fix, because a copy of every edit in every problem would make the output grow with the file size times the problem count. There is no fix when a directive turns the rule off anywhere, because the fix would change those lines too.
|
|
65
|
+
fix: index === 0 && isFixable
|
|
59
66
|
? function * (fixer) {
|
|
60
67
|
// eslint-disable-next-line internal/no-restricted-property-access -- An edit is not a node.
|
|
61
68
|
for (const {range, text} of edits) {
|
package/rules/index.js
CHANGED
|
@@ -16,10 +16,8 @@ export {default as 'no-loss-of-precision'} from './no-loss-of-precision.js';
|
|
|
16
16
|
export {default as 'no-misplaced-block-string-delimiters'} from './no-misplaced-block-string-delimiters.js';
|
|
17
17
|
export {default as 'no-mixed-number-types'} from './no-mixed-number-types.js';
|
|
18
18
|
export {default as 'no-negative-zero'} from './no-negative-zero.js';
|
|
19
|
-
export {default as 'no-numeric-dotted-keys'} from './no-numeric-dotted-keys.js';
|
|
20
19
|
export {default as 'no-placeholder-values'} from './no-placeholder-values.js';
|
|
21
20
|
export {default as 'no-quoted-keywords'} from './no-quoted-keywords.js';
|
|
22
|
-
export {default as 'no-scattered-dotted-keys'} from './no-scattered-dotted-keys.js';
|
|
23
21
|
export {default as 'no-sizes-as-durations'} from './no-sizes-as-durations.js';
|
|
24
22
|
export {default as 'no-unnormalized-keys'} from './no-unnormalized-keys.js';
|
|
25
23
|
export {default as 'no-zero-duration-parts'} from './no-zero-duration-parts.js';
|
package/rules/key-name-casing.js
CHANGED
|
@@ -27,7 +27,7 @@ const create = context => {
|
|
|
27
27
|
const pattern = CASES[keyCase];
|
|
28
28
|
const ignorePatterns = ignore.map(source => new RegExp(source, 'v'));
|
|
29
29
|
|
|
30
|
-
context.on('
|
|
30
|
+
context.on('Key', node => {
|
|
31
31
|
// A quoted key holds data rather than a name, such as a path or a URL, so it is not checked.
|
|
32
32
|
if (
|
|
33
33
|
node.style !== 'bare'
|
|
@@ -21,37 +21,28 @@ const fold = key => trimWhitespace(key).toLowerCase().replaceAll('-', '_');
|
|
|
21
21
|
const create = context => {
|
|
22
22
|
context.on('Object', function * (node) {
|
|
23
23
|
/*
|
|
24
|
-
The first key seen for each folded key
|
|
24
|
+
The first key seen for each folded key.
|
|
25
25
|
*/
|
|
26
|
-
const
|
|
26
|
+
const firstKeys = new Map();
|
|
27
27
|
|
|
28
28
|
for (const {key} of node.members) {
|
|
29
|
-
|
|
29
|
+
const folded = fold(key.value);
|
|
30
|
+
const firstKey = firstKeys.get(folded);
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (entry === undefined) {
|
|
36
|
-
entry = {first: segment, keys: new Map(), reported: new Set()};
|
|
37
|
-
keys.set(folded, entry);
|
|
38
|
-
} else if (entry.first.value !== segment.value && !entry.reported.has(segment.value)) {
|
|
39
|
-
// Each pair of spellings is reported once, even when a dotted key repeats one of them.
|
|
40
|
-
entry.reported.add(segment.value);
|
|
41
|
-
|
|
42
|
-
yield {
|
|
43
|
-
node: segment,
|
|
44
|
-
messageId: MESSAGE_ID,
|
|
45
|
-
// The source text shows the quotes, so a key with whitespace stays readable.
|
|
46
|
-
data: {
|
|
47
|
-
key: context.sourceCode.getText(segment),
|
|
48
|
-
otherKey: context.sourceCode.getText(entry.first),
|
|
49
|
-
},
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
keys = entry.keys;
|
|
32
|
+
if (firstKey === undefined) {
|
|
33
|
+
firstKeys.set(folded, key);
|
|
34
|
+
continue;
|
|
54
35
|
}
|
|
36
|
+
|
|
37
|
+
yield {
|
|
38
|
+
node: key,
|
|
39
|
+
messageId: MESSAGE_ID,
|
|
40
|
+
// The source text shows the quotes, so a key with whitespace stays readable.
|
|
41
|
+
data: {
|
|
42
|
+
key: context.sourceCode.getText(key),
|
|
43
|
+
otherKey: context.sourceCode.getText(firstKey),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
55
46
|
}
|
|
56
47
|
});
|
|
57
48
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {getKeyWords} from './utils/index.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
3
5
|
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
@@ -11,15 +13,20 @@ const messages = {
|
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
/*
|
|
14
|
-
|
|
16
|
+
The last word of a key for a file mode, as in `mode`, `file-mode`, `defaultMode`, `FILE_UPLOAD_PERMISSIONS` (Django), or `'listen.mode'` (PHP-FPM). A umask is left out, because it lists the permissions to remove, so what makes a mode sensible does not apply to it.
|
|
15
17
|
*/
|
|
16
|
-
const
|
|
18
|
+
const MODE_WORDS = new Set(['chmod', 'mode', 'perm', 'perms', 'permissions']);
|
|
17
19
|
const OCTAL_DIGITS = /^[0-7]{3,4}$/v;
|
|
18
20
|
|
|
19
21
|
/*
|
|
20
22
|
Whether a number is a sensible file mode, as ansible-lint's `risky-octal` rule decides: no write or execute permission without read permission, except execute alone when the owner can execute, and the owner has at least the permissions of the group, and the group at least those of others.
|
|
21
23
|
*/
|
|
22
24
|
function isSensibleMode(mode) {
|
|
25
|
+
// A mode has 12 bits, so a larger number is not one, whatever its low digits are.
|
|
26
|
+
if (mode > 0o7777) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
const user = Math.trunc(mode / 64) % 8;
|
|
24
31
|
const group = Math.trunc(mode / 8) % 8;
|
|
25
32
|
const other = mode % 8;
|
|
@@ -41,7 +48,7 @@ const create = context => {
|
|
|
41
48
|
context.on('Member', member => {
|
|
42
49
|
const {key, value} = member;
|
|
43
50
|
|
|
44
|
-
if (value.type !== 'Integer' || value.radix !== 10 || !
|
|
51
|
+
if (value.type !== 'Integer' || value.radix !== 10 || !MODE_WORDS.has(getKeyWords(key.value).at(-1))) {
|
|
45
52
|
return;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {stringifyValue} from 'soml-lang';
|
|
1
2
|
import {hasAdjacentComment, isPunctuator} from './utils/index.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -71,7 +72,8 @@ const create = context => {
|
|
|
71
72
|
node: item,
|
|
72
73
|
messageId: MESSAGE_ID_ERROR,
|
|
73
74
|
data: {
|
|
74
|
-
|
|
75
|
+
// A block string spans lines, so it is shown as a one-line string with the same value.
|
|
76
|
+
item: item.block ? stringifyValue(item.value) : sourceCode.getText(item),
|
|
75
77
|
line: sourceCode.getLoc(first).start.line,
|
|
76
78
|
},
|
|
77
79
|
// Removing an item changes the value, so it is a suggestion. It is left out when it would remove a comment.
|
package/rules/no-empty-keys.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
/**
|
|
9
9
|
@import {SomlContext} from './rule/soml-context.js';
|
|
10
10
|
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
11
|
-
@import {StringNode,
|
|
11
|
+
@import {StringNode, KeyNode, Comment} from 'soml-lang';
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const MESSAGE_ID_ESCAPE = 'no-invisible-characters/escape';
|
|
@@ -29,7 +29,7 @@ const create = context => {
|
|
|
29
29
|
/**
|
|
30
30
|
One problem per invisible character in `node`.
|
|
31
31
|
|
|
32
|
-
@param {StringNode |
|
|
32
|
+
@param {StringNode | KeyNode | Comment} node
|
|
33
33
|
*/
|
|
34
34
|
function * getProblems(node) {
|
|
35
35
|
const [nodeStart] = sourceCode.getRange(node);
|
|
@@ -78,7 +78,7 @@ const create = context => {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
context.on(['String', '
|
|
81
|
+
context.on(['String', 'Key'], node => {
|
|
82
82
|
if (node.style !== 'bare') {
|
|
83
83
|
return getProblems(node);
|
|
84
84
|
}
|
|
@@ -47,15 +47,22 @@ const create = context => {
|
|
|
47
47
|
content = trimEnd(content.slice(0, -1), ' \t');
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
// Where the run of quotes at the end starts.
|
|
51
|
+
let index = content.length;
|
|
52
|
+
|
|
53
|
+
while (index > 0 && content[index - 1] === delimiter[0]) {
|
|
54
|
+
index--;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// In a `"""` block, `\"` is an escaped quote, so it is not part of the run.
|
|
58
|
+
if (node.style === 'escaped' && content.length - index >= delimiter.length && ESCAPING_BACKSLASHES.test(content.slice(0, index))) {
|
|
59
|
+
index++;
|
|
60
|
+
}
|
|
51
61
|
|
|
52
62
|
if (
|
|
53
63
|
index > 0
|
|
54
|
-
&& content.
|
|
55
|
-
&& content[index - 1] !== delimiter[0]
|
|
64
|
+
&& content.length - index === delimiter.length
|
|
56
65
|
&& !isClosingQuote(content, index, delimiter)
|
|
57
|
-
// In a `"""` block, `\"` is an escaped quote, so the run is shorter than the delimiter.
|
|
58
|
-
&& !(node.style === 'escaped' && ESCAPING_BACKSLASHES.test(content.slice(0, index)))
|
|
59
66
|
) {
|
|
60
67
|
const start = offset + index;
|
|
61
68
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {escapeInvisibleCharacters} from './utils/index.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
3
5
|
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
@@ -9,9 +11,9 @@ const messages = {
|
|
|
9
11
|
};
|
|
10
12
|
|
|
11
13
|
/*
|
|
12
|
-
Words that a placeholder uses to label itself, as a whole token, so that `replacements` does not match: `changeme`, `CHANGE_ME`, `replace-me`, and `your_api_key_here`. The words are joined by `-` or `_`, not a space, so that prose such as "Set your password here" does not match. The part between `your` and `here` is limited, so a long string cannot make the search slow.
|
|
14
|
+
Words that a placeholder uses to label itself, as a whole token, so that `replacements` does not match: `changeme`, `CHANGE_ME`, `replace-me`, and `your_api_key_here`. Digits may follow, as in `ChangeMe123!`, because a password policy often asks for them. The words are joined by `-` or `_`, not a space, so that prose such as "Set your password here" does not match. The part between `your` and `here` is limited, so a long string cannot make the search slow.
|
|
13
15
|
*/
|
|
14
|
-
const PLACEHOLDER_WORDS = /(?:^|[^\da-z])(?:(?:change|replace)[\-_]?me|your[\-_][\w\-]{0,40}?[\-_]here)(?:$|[
|
|
16
|
+
const PLACEHOLDER_WORDS = /(?:^|[^\da-z])(?:(?:change|replace)[\-_]?me|your[\-_][\w\-]{0,40}?[\-_]here)(?:$|[^a-z])/iv;
|
|
15
17
|
|
|
16
18
|
/*
|
|
17
19
|
A whole value that marks missing work. In uppercase only, because `Todo` is also a word.
|
|
@@ -22,7 +24,24 @@ const MARKER = /^(?:FIXME|TBD|TODO)$/v;
|
|
|
22
24
|
@param {SomlContext} context
|
|
23
25
|
*/
|
|
24
26
|
const create = context => {
|
|
25
|
-
|
|
27
|
+
/*
|
|
28
|
+
Whether each array is a list of markers, found once per array.
|
|
29
|
+
*/
|
|
30
|
+
const markerLists = new WeakMap();
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
Whether an array holds two different markers, as a linter's list of comment tags does, such as `['TODO', 'FIXME']`. Then the markers are data, not placeholders.
|
|
34
|
+
*/
|
|
35
|
+
const isMarkerList = array => {
|
|
36
|
+
if (!markerLists.has(array)) {
|
|
37
|
+
const markers = new Set(array.elements.filter(element => element.type === 'String' && MARKER.test(element.value)).map(element => element.value));
|
|
38
|
+
markerLists.set(array, markers.size > 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return markerLists.get(array);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
context.on('String', (node, parent) => {
|
|
26
45
|
const {value} = node;
|
|
27
46
|
const isMarker = MARKER.test(value);
|
|
28
47
|
|
|
@@ -30,11 +49,15 @@ const create = context => {
|
|
|
30
49
|
return;
|
|
31
50
|
}
|
|
32
51
|
|
|
52
|
+
if (isMarker && parent.type === 'Array' && isMarkerList(parent)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
return {
|
|
34
57
|
node,
|
|
35
58
|
messageId: MESSAGE_ID,
|
|
36
59
|
// A placeholder word never spans lines, so the message shows only its line of a block string. A marker counts only as the whole value, so a `TODO` line is not that line.
|
|
37
|
-
data: {value: isMarker ? value : value.split('\n').find(line => PLACEHOLDER_WORDS.test(line))},
|
|
60
|
+
data: {value: escapeInvisibleCharacters(isMarker ? value : value.split('\n').find(line => PLACEHOLDER_WORDS.test(line)))},
|
|
38
61
|
};
|
|
39
62
|
});
|
|
40
63
|
};
|
|
@@ -23,14 +23,12 @@ Words that name a size at the end of a longer key, as in `max-size` or `buffer_b
|
|
|
23
23
|
const SIZE_ENDINGS = new Set(['bytes', 'size']);
|
|
24
24
|
|
|
25
25
|
/*
|
|
26
|
-
Words that make a key about time, such as `window-size`, `cpu-time`,
|
|
26
|
+
Words that make a key about time, such as `window-size`, `cpu-time`, or `cache-expiry`. A plural, such as `intervals`, counts too. `block` and `chunk` are not, because a block size or a chunk size is in bytes, as in Hadoop, Spark, RocksDB, and Fluentd, and a block or chunk duration is named with `duration`.
|
|
27
27
|
*/
|
|
28
28
|
const TIME_WORDS = new Set([
|
|
29
29
|
'age',
|
|
30
30
|
'backoff',
|
|
31
|
-
'block',
|
|
32
31
|
'bucket',
|
|
33
|
-
'chunk',
|
|
34
32
|
'cooldown',
|
|
35
33
|
'deadline',
|
|
36
34
|
'delay',
|
|
@@ -75,7 +73,7 @@ function isSizeKey(key) {
|
|
|
75
73
|
*/
|
|
76
74
|
const create = context => {
|
|
77
75
|
context.on('Member', ({key, value}) => {
|
|
78
|
-
if (value.type !== 'Duration' || !isSizeLike(value) || !isSizeKey(key.
|
|
76
|
+
if (value.type !== 'Duration' || !isSizeLike(value) || !isSizeKey(key.value)) {
|
|
79
77
|
return;
|
|
80
78
|
}
|
|
81
79
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {escapeInvisibleCharacters} from './utils/index.js';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
3
5
|
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
@@ -14,7 +16,7 @@ const messages = {
|
|
|
14
16
|
const create = context => {
|
|
15
17
|
const [{form}] = context.options;
|
|
16
18
|
|
|
17
|
-
context.on('
|
|
19
|
+
context.on('Key', node => {
|
|
18
20
|
if (node.value.normalize(form) === node.value) {
|
|
19
21
|
return;
|
|
20
22
|
}
|
|
@@ -23,7 +25,7 @@ const create = context => {
|
|
|
23
25
|
node,
|
|
24
26
|
messageId: MESSAGE_ID,
|
|
25
27
|
data: {
|
|
26
|
-
key: node.value,
|
|
28
|
+
key: escapeInvisibleCharacters(node.value),
|
|
27
29
|
form,
|
|
28
30
|
},
|
|
29
31
|
};
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {isBareKey} from 'soml-lang';
|
|
2
|
-
import {isDecimalPoint} from './utils/index.js';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -11,36 +10,22 @@ const messages = {
|
|
|
11
10
|
[MESSAGE_ID]: 'The key `{{key}}` does not need quotes.',
|
|
12
11
|
};
|
|
13
12
|
|
|
14
|
-
/*
|
|
15
|
-
The value of a segment that is bare or can be, or an empty string for a segment that must be quoted or is missing.
|
|
16
|
-
*/
|
|
17
|
-
const getBareValue = segment => segment !== undefined && isBareKey(segment.value) ? segment.value : '';
|
|
18
|
-
|
|
19
13
|
/**
|
|
20
14
|
@param {SomlContext} context
|
|
21
15
|
*/
|
|
22
16
|
const create = context => {
|
|
23
|
-
context.on('Key',
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Without quotes, a digit on both sides of a `.` reads as a number, as in `1.5`, which `no-numeric-dotted-keys` reports. A neighbor that must keep its quotes, as in `'a 1'`, does not.
|
|
30
|
-
const previous = getBareValue(key.segments[index - 1]);
|
|
31
|
-
const next = getBareValue(key.segments[index + 1]);
|
|
32
|
-
|
|
33
|
-
if (isDecimalPoint(previous, node.value) || isDecimalPoint(node.value, next)) {
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
yield {
|
|
38
|
-
node,
|
|
39
|
-
messageId: MESSAGE_ID,
|
|
40
|
-
data: {key: node.value},
|
|
41
|
-
fix: fixer => fixer.replaceText(node, node.value),
|
|
42
|
-
};
|
|
17
|
+
context.on('Key', node => {
|
|
18
|
+
// A key with a `.` is not a bare key, so `'a.b'` keeps its quotes. Without them, `a.b` is a parse error.
|
|
19
|
+
if (node.style === 'bare' || !isBareKey(node.value)) {
|
|
20
|
+
return;
|
|
43
21
|
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
node,
|
|
25
|
+
messageId: MESSAGE_ID,
|
|
26
|
+
data: {key: node.value},
|
|
27
|
+
fix: fixer => fixer.replaceText(node, node.value),
|
|
28
|
+
};
|
|
44
29
|
});
|
|
45
30
|
};
|
|
46
31
|
|
|
@@ -3,7 +3,7 @@ import {canBeLiteral, hasDoubledBackslashes, hasInvisibleCharacter} from './util
|
|
|
3
3
|
/**
|
|
4
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
5
5
|
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
6
|
-
@import {StringNode,
|
|
6
|
+
@import {StringNode, KeyNode} from 'soml-lang';
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const MESSAGE_ID = 'prefer-literal-strings';
|
|
@@ -14,7 +14,7 @@ const messages = {
|
|
|
14
14
|
/**
|
|
15
15
|
The literal spelling of a string or key with the same value, or `undefined` when there is none.
|
|
16
16
|
|
|
17
|
-
@param {StringNode |
|
|
17
|
+
@param {StringNode | KeyNode} node
|
|
18
18
|
@param {string} text The source text of the node.
|
|
19
19
|
@returns {string | undefined}
|
|
20
20
|
*/
|
|
@@ -39,7 +39,7 @@ function getLiteral(node, text) {
|
|
|
39
39
|
@param {SomlContext} context
|
|
40
40
|
*/
|
|
41
41
|
const create = context => {
|
|
42
|
-
context.on(['String', '
|
|
42
|
+
context.on(['String', 'Key'], node => {
|
|
43
43
|
if (node.style !== 'escaped') {
|
|
44
44
|
return;
|
|
45
45
|
}
|
package/rules/sort-keys.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import naturalCompare from 'natural-compare';
|
|
2
1
|
import {compareKeys} from 'soml-lang';
|
|
3
|
-
import {reorderMembers} from './utils/index.js';
|
|
2
|
+
import {isDisabledAnywhere, reorderMembers} from './utils/index.js';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -13,14 +12,71 @@ const messages = {
|
|
|
13
12
|
[MESSAGE_ID]: 'Expected keys to be in {{sortName}} case-{{sensitivity}} {{direction}} order. `{{thisName}}` should be before `{{previousName}}`.',
|
|
14
13
|
};
|
|
15
14
|
|
|
15
|
+
const isDigit = character => character >= '0' && character <= '9';
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
The end of the run of digits that starts at `index`.
|
|
19
|
+
*/
|
|
20
|
+
function getDigitsEnd(text, index) {
|
|
21
|
+
while (index < text.length && isDigit(text[index])) {
|
|
22
|
+
index++;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return index;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Compares two keys by code point, as canonical form does, except that two runs of digits at the same place are compared by their value, so `a2` comes before `a10` and `a9` before `a010`. A value has any number of digits, so the runs are compared as text, without their leading zeros. When only the leading zeros differ, as in `a01` and `a1`, code point order decides, so two different keys are never equal.
|
|
30
|
+
*/
|
|
31
|
+
function compareNatural(first, second) {
|
|
32
|
+
let firstIndex = 0;
|
|
33
|
+
let secondIndex = 0;
|
|
34
|
+
|
|
35
|
+
while (firstIndex < first.length && secondIndex < second.length) {
|
|
36
|
+
if (isDigit(first[firstIndex]) && isDigit(second[secondIndex])) {
|
|
37
|
+
const firstEnd = getDigitsEnd(first, firstIndex);
|
|
38
|
+
const secondEnd = getDigitsEnd(second, secondIndex);
|
|
39
|
+
const firstNumber = first.slice(firstIndex, firstEnd).replace(/^0+/v, '');
|
|
40
|
+
const secondNumber = second.slice(secondIndex, secondEnd).replace(/^0+/v, '');
|
|
41
|
+
|
|
42
|
+
if (firstNumber.length !== secondNumber.length) {
|
|
43
|
+
return firstNumber.length - secondNumber.length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (firstNumber !== secondNumber) {
|
|
47
|
+
return firstNumber < secondNumber ? -1 : 1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
firstIndex = firstEnd;
|
|
51
|
+
secondIndex = secondEnd;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const firstCodePoint = first.codePointAt(firstIndex);
|
|
56
|
+
const secondCodePoint = second.codePointAt(secondIndex);
|
|
57
|
+
|
|
58
|
+
if (firstCodePoint !== secondCodePoint) {
|
|
59
|
+
return firstCodePoint - secondCodePoint;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The two code points are equal, so they have the same length.
|
|
63
|
+
const length = firstCodePoint > 0xFF_FF ? 2 : 1;
|
|
64
|
+
firstIndex += length;
|
|
65
|
+
secondIndex += length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A key that ends first comes first, as `a` before `a1`.
|
|
69
|
+
return (first.length - firstIndex) - (second.length - secondIndex) || compareKeys(first, second);
|
|
70
|
+
}
|
|
71
|
+
|
|
16
72
|
/**
|
|
17
73
|
@param {SomlContext} context
|
|
18
74
|
*/
|
|
19
75
|
const create = context => {
|
|
20
76
|
const {sourceCode} = context;
|
|
21
77
|
const [direction, {caseSensitive, natural, minKeys, allowLineSeparatedGroups}] = context.options;
|
|
22
|
-
//
|
|
23
|
-
const compare = natural ?
|
|
78
|
+
// Keys are in the order of canonical form, by code point. The `<` operator compares UTF-16 code units, which puts an emoji before U+FF61.
|
|
79
|
+
const compare = natural ? compareNatural : compareKeys;
|
|
24
80
|
const normalize = caseSensitive ? name => name : name => name.toLowerCase();
|
|
25
81
|
const sign = direction === 'asc' ? 1 : -1;
|
|
26
82
|
|
|
@@ -56,25 +112,11 @@ const create = context => {
|
|
|
56
112
|
}
|
|
57
113
|
|
|
58
114
|
/*
|
|
59
|
-
The decoded
|
|
115
|
+
The decoded keys, so `'a'` and `a` sort the same.
|
|
60
116
|
*/
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
const compareMembers = (first, second) => {
|
|
64
|
-
const firstPath = paths.get(first);
|
|
65
|
-
const secondPath = paths.get(second);
|
|
66
|
-
|
|
67
|
-
// eslint-disable-next-line unicorn/prefer-iterator-zip -- `Iterator.zip()` is not in Node.js 22.
|
|
68
|
-
for (let index = 0; index < Math.min(firstPath.length, secondPath.length); index++) {
|
|
69
|
-
const difference = compare(firstPath[index], secondPath[index]);
|
|
70
|
-
|
|
71
|
-
if (difference !== 0) {
|
|
72
|
-
return sign * difference;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
117
|
+
const keys = new Map(node.members.map(member => [member, normalize(member.key.value)]));
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
};
|
|
119
|
+
const compareMembers = (first, second) => sign * compare(keys.get(first), keys.get(second));
|
|
78
120
|
|
|
79
121
|
// A blank line starts a group that is sorted on its own, when that is allowed.
|
|
80
122
|
const groups = [];
|
|
@@ -110,7 +152,7 @@ const create = context => {
|
|
|
110
152
|
sortName: natural ? 'natural' : 'code point',
|
|
111
153
|
},
|
|
112
154
|
// The fix sorts the whole object at once, so one run of `--fix` is enough, and only the first problem carries it. Giving every problem the same fix would build it once per problem.
|
|
113
|
-
fix: isFirst ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
|
|
155
|
+
fix: isFirst && !isDisabledAnywhere(context) ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
|
|
114
156
|
};
|
|
115
157
|
|
|
116
158
|
isFirst = false;
|
package/rules/spaced-comment.js
CHANGED
|
@@ -18,9 +18,9 @@ const BLOCK_START = /^\**(?=[^\s*])/v;
|
|
|
18
18
|
const BLOCK_END = /(?<=[^\s*])\**$/v;
|
|
19
19
|
|
|
20
20
|
/*
|
|
21
|
-
A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it
|
|
21
|
+
A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it, also with whitespace or another comment after it, as in `#ff0000 # red`.
|
|
22
22
|
*/
|
|
23
|
-
const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})
|
|
23
|
+
const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8}),?(?:[\t ]+(?:#|\/\*).*|[\t ]*)$/iv;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
@param {SomlContext} context
|
|
@@ -12,6 +12,16 @@ export function toUnicodeEscape(character) {
|
|
|
12
12
|
return String.raw`\u{${character.codePointAt(0).toString(16)}}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
Write every invisible character in `text` as a `\u{…}` escape, such as for a decoded value in a message, where a control character could change the terminal and a bidirectional control could reorder the text.
|
|
17
|
+
|
|
18
|
+
@param {string} text
|
|
19
|
+
@returns {string}
|
|
20
|
+
*/
|
|
21
|
+
export function escapeInvisibleCharacters(text) {
|
|
22
|
+
return text.replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
/**
|
|
16
26
|
Write `value` as a `"..."` string. `\`, `"`, LF, and tab use their short escapes, and every invisible character uses a `\u{…}` escape.
|
|
17
27
|
|
|
@@ -23,10 +33,9 @@ export function toEscapedString(value) {
|
|
|
23
33
|
.replaceAll('\\', '\\\\')
|
|
24
34
|
.replaceAll('"', String.raw`\"`)
|
|
25
35
|
.replaceAll('\n', String.raw`\n`)
|
|
26
|
-
.replaceAll('\t', String.raw`\t`)
|
|
27
|
-
.replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
|
|
36
|
+
.replaceAll('\t', String.raw`\t`);
|
|
28
37
|
|
|
29
|
-
return `"${escaped}"`;
|
|
38
|
+
return `"${escapeInvisibleCharacters(escaped)}"`;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
The words of a key, in lowercase: `maxHeapSize`, `max-heap-size`, and `max_heap_size` all give `max`, `heap`, and `size`.
|
|
2
|
+
The words of a key, in lowercase: `maxHeapSize`, `max-heap-size`, and `max_heap_size` all give `max`, `heap`, and `size`. An acronym is a word too, so `maxRAMSize` gives `max`, `ram`, and `size`. Every character that is not a letter or a digit separates words, so a quoted key works too, as in `'spark.executor.memory'` (Spark) or `'php_admin_value[memory_limit]'` (PHP-FPM). A separator at an end or next to another one makes no empty word, so `_size` is one word.
|
|
3
3
|
|
|
4
4
|
@param {string} key
|
|
5
5
|
@returns {string[]}
|
|
6
6
|
*/
|
|
7
7
|
export default function getKeyWords(key) {
|
|
8
|
-
return key.split(/[
|
|
8
|
+
return key.split(/[^\p{Letter}\p{Number}]|(?<=[\da-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/v)
|
|
9
|
+
.filter(word => word !== '')
|
|
10
|
+
.map(word => word.toLowerCase());
|
|
9
11
|
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
const BACKSLASH_RUN = /\\+/gv;
|
|
2
2
|
const DOUBLED_ESCAPE = /\\\\[.DSWbdsw]/v;
|
|
3
|
+
const QUOTE = /["']/v;
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
Whether every backslash in a value is doubled, as in `^\\d+$`, the way a JSON or JavaScript string writes a regex escape. In a literal string, `\\` is two backslashes, so such a value most likely came from a `"…"` string and was meant with one.
|
|
6
7
|
|
|
7
|
-
A Windows network path, as in `\\server\share`, has a single backslash too, so it is not counted. A value that holds a
|
|
8
|
+
A Windows network path, as in `\\server\share`, has a single backslash too, so it is not counted. A value that holds a quote is not counted either, because it is code with strings in it, which doubles its backslashes on purpose, as JSON text does with `"` and a JavaScript or SQL snippet in a block string can with `'`.
|
|
8
9
|
|
|
9
10
|
@param {string} value
|
|
10
11
|
@returns {boolean}
|
|
11
12
|
*/
|
|
12
13
|
export default function hasDoubledBackslashes(value) {
|
|
13
14
|
return DOUBLED_ESCAPE.test(value)
|
|
14
|
-
&& !
|
|
15
|
+
&& !QUOTE.test(value)
|
|
15
16
|
&& value.match(BACKSLASH_RUN).every(run => run.length % 2 === 0);
|
|
16
17
|
}
|
package/rules/utils/index.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
export {invisibleCharacters, hasInvisibleCharacter} from './invisible-characters.js';
|
|
2
|
-
export {
|
|
3
|
-
|
|
2
|
+
export {
|
|
3
|
+
toUnicodeEscape,
|
|
4
|
+
escapeInvisibleCharacters,
|
|
5
|
+
toEscapedString,
|
|
6
|
+
canBeLiteral,
|
|
7
|
+
} from './escape-string.js';
|
|
4
8
|
export {reorderMembers, hasAdjacentComment} from './reorder-members.js';
|
|
5
9
|
export {default as trimWhitespace} from './trim-whitespace.js';
|
|
6
10
|
export {isComment, isPunctuator} from './token.js';
|
|
7
11
|
export {default as trimEnd} from './trim-end.js';
|
|
8
12
|
export {default as hasDoubledBackslashes} from './has-doubled-backslashes.js';
|
|
9
13
|
export {default as getKeyWords} from './get-key-words.js';
|
|
14
|
+
export {default as isDisabledAnywhere} from './is-disabled-anywhere.js';
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
Characters that a reader cannot see, or cannot tell apart from a space: bidirectional controls (as in Trojan Source), zero-width characters, the byte order mark, the soft hyphen, control characters, and every Unicode whitespace character except space, tab, and line feed. Zero-width joiners and non-joiners are left out, because emoji sequences and several scripts need them.
|
|
2
|
+
Characters that a reader cannot see, or cannot tell apart from a space: bidirectional controls (as in Trojan Source), zero-width characters (including the invisible math operators U+2061 to U+2064), the Hangul fillers, which show as blank, other invisible format characters (U+180E and U+206A to U+206F), the byte order mark, the soft hyphen, control characters, and every Unicode whitespace character except space, tab, and line feed. Zero-width joiners and non-joiners are left out, because emoji sequences and several scripts need them.
|
|
3
|
+
|
|
4
|
+
Tag characters are invisible too, and can hide text, as in ASCII smuggling. They are left out only in an emoji tag sequence, which is U+1F3F4 and then tags up to U+E007F, as in the flag of Scotland. Such a sequence is short, so the lookbehind is bounded.
|
|
3
5
|
*/
|
|
4
|
-
const
|
|
6
|
+
const characters = /[[\p{Control}\p{White_Space}\u{AD}\u{61C}\u{115F}\u{1160}\u{180E}\u{200B}\u{200E}\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{206F}\u{3164}\u{FEFF}\u{FFA0}]--[\t\n ]]/v;
|
|
7
|
+
const tags = /(?<!\u{1F3F4}[\u{E0020}-\u{E007E}]{0,32})[\u{E0001}\u{E0020}-\u{E007F}]/v;
|
|
8
|
+
const invisibleCharacter = new RegExp(`${characters.source}|${tags.source}`, 'v');
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
The same characters, with the `g` flag. Use it only with `String#matchAll()` and `String#replaceAll()`, which start from the beginning when `lastIndex` is 0 and leave it at 0, so the shared regex holds no state between uses.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {ConfigCommentParser} from '@eslint/plugin-kit';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
@import {SomlContext} from '../rule/soml-context.js';
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const commentParser = new ConfigCommentParser();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
Whether a disable directive in the document turns off the rule of `context` anywhere, as `# eslint-disable-next-line soml/format` does, or as one without rule names does.
|
|
11
|
+
|
|
12
|
+
A rule whose one fix changes the whole document or a whole object leaves out that fix then. ESLint applies a fix when the problem that carries it is reported, even when the other problems are turned off, so the fix would change the lines that the directive keeps, and ESLint would then remove the directive as unused.
|
|
13
|
+
|
|
14
|
+
@param {SomlContext} context
|
|
15
|
+
@returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export default function isDisabledAnywhere(context) {
|
|
18
|
+
return context.sourceCode.getDisableDirectives().directives.some(({type, value}) => type !== 'enable' && (value === '' || Object.hasOwn(commentParser.parseListConfig(value), context.id)));
|
|
19
|
+
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import {isDecimalPoint} from './utils/index.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
@import {SomlContext} from './rule/soml-context.js';
|
|
5
|
-
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const MESSAGE_ID_ERROR = 'no-numeric-dotted-keys/error';
|
|
9
|
-
const MESSAGE_ID_SUGGESTION = 'no-numeric-dotted-keys/suggestion';
|
|
10
|
-
const messages = {
|
|
11
|
-
[MESSAGE_ID_ERROR]: '`{{key}}` is a nested key, because `.` separates key segments. It is not one key.',
|
|
12
|
-
[MESSAGE_ID_SUGGESTION]: 'Quote it as one key: `{{replacement}}`.',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
@param {SomlContext} context
|
|
17
|
-
*/
|
|
18
|
-
const create = context => {
|
|
19
|
-
context.on('Key', node => {
|
|
20
|
-
const {segments} = node;
|
|
21
|
-
|
|
22
|
-
// Only between bare segments, because a quoted segment shows that the dot separates.
|
|
23
|
-
const hasDecimalPoint = segments.slice(1).some((next, index) => segments[index].style === 'bare' && next.style === 'bare' && isDecimalPoint(segments[index].value, next.value));
|
|
24
|
-
|
|
25
|
-
if (!hasDecimalPoint) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const key = context.sourceCode.getText(node);
|
|
30
|
-
const problem = {
|
|
31
|
-
node,
|
|
32
|
-
messageId: MESSAGE_ID_ERROR,
|
|
33
|
-
data: {key},
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// A key with quoted segments has no single obvious meaning, so it gets no suggestion. Bare segments never contain `'`.
|
|
37
|
-
if (segments.every(segment => segment.style === 'bare')) {
|
|
38
|
-
const replacement = `'${key}'`;
|
|
39
|
-
problem.suggest = [
|
|
40
|
-
{
|
|
41
|
-
messageId: MESSAGE_ID_SUGGESTION,
|
|
42
|
-
data: {replacement},
|
|
43
|
-
fix: fixer => fixer.replaceText(node, replacement),
|
|
44
|
-
},
|
|
45
|
-
];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return problem;
|
|
49
|
-
});
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
@type {SomlRule}
|
|
54
|
-
*/
|
|
55
|
-
const config = {
|
|
56
|
-
create,
|
|
57
|
-
meta: {
|
|
58
|
-
type: 'problem',
|
|
59
|
-
docs: {
|
|
60
|
-
description: 'Disallow dotted keys that read as one number.',
|
|
61
|
-
recommended: 'unopinionated',
|
|
62
|
-
},
|
|
63
|
-
hasSuggestions: true,
|
|
64
|
-
messages,
|
|
65
|
-
languages: ['soml/soml'],
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export default config;
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import {reorderMembers} 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 {MemberNode} from 'soml-lang';
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
const MESSAGE_ID = 'no-scattered-dotted-keys';
|
|
10
|
-
const messages = {
|
|
11
|
-
[MESSAGE_ID]: 'Keep the keys under `{{prefix}}` together. Another key comes between them.',
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
/*
|
|
15
|
-
The first `length` segments of a key path as one string, for a map. JSON keeps the segments apart, whatever characters they hold.
|
|
16
|
-
*/
|
|
17
|
-
const getPrefix = (member, length) => JSON.stringify(member.key.segments.slice(0, length).map(segment => segment.value));
|
|
18
|
-
|
|
19
|
-
/*
|
|
20
|
-
The members grouped by their dotted prefixes, level by level, each group where its first member is. Members that are not under a shared prefix keep their order.
|
|
21
|
-
*/
|
|
22
|
-
function group(members, depth = 0) {
|
|
23
|
-
const groups = new Map();
|
|
24
|
-
|
|
25
|
-
for (const member of members) {
|
|
26
|
-
const {segments} = member.key;
|
|
27
|
-
// A member with no segment past this depth is a group of its own.
|
|
28
|
-
const groupKey = segments.length > depth + 1 ? segments[depth].value : Symbol('member');
|
|
29
|
-
|
|
30
|
-
if (!groups.has(groupKey)) {
|
|
31
|
-
groups.set(groupKey, []);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
groups.get(groupKey).push(member);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return groups.values().flatMap(members => members.length > 1 ? group(members, depth + 1) : members).toArray();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
@param {SomlContext} context
|
|
42
|
-
*/
|
|
43
|
-
const create = context => {
|
|
44
|
-
const {sourceCode} = context;
|
|
45
|
-
|
|
46
|
-
context.on('Object', function * (node) {
|
|
47
|
-
// The index of the last member under each prefix.
|
|
48
|
-
const lastIndexes = new Map();
|
|
49
|
-
let isFirst = true;
|
|
50
|
-
|
|
51
|
-
for (const [index, member] of node.members.entries()) {
|
|
52
|
-
// The shortest prefix whose members another key came between.
|
|
53
|
-
let scatteredPrefix;
|
|
54
|
-
|
|
55
|
-
for (let length = 1; length < member.key.segments.length; length++) {
|
|
56
|
-
const prefix = getPrefix(member, length);
|
|
57
|
-
const lastIndex = lastIndexes.get(prefix);
|
|
58
|
-
|
|
59
|
-
if (scatteredPrefix === undefined && lastIndex !== undefined && lastIndex !== index - 1) {
|
|
60
|
-
scatteredPrefix = member.key.segments.slice(0, length);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
lastIndexes.set(prefix, index);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (scatteredPrefix === undefined) {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
yield {
|
|
71
|
-
node: member.key,
|
|
72
|
-
messageId: MESSAGE_ID,
|
|
73
|
-
data: {
|
|
74
|
-
prefix: sourceCode.text.slice(sourceCode.getRange(scatteredPrefix[0])[0], sourceCode.getRange(scatteredPrefix.at(-1))[1]),
|
|
75
|
-
},
|
|
76
|
-
// The fix moves every member at once, so one run of `--fix` is enough, and only the first problem carries it. Giving every problem the same fix would build it once per problem.
|
|
77
|
-
fix: isFirst ? reorderMembers(context, node.members, group(node.members)) : undefined,
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
isFirst = false;
|
|
81
|
-
}
|
|
82
|
-
});
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
@type {SomlRule}
|
|
87
|
-
*/
|
|
88
|
-
const config = {
|
|
89
|
-
create,
|
|
90
|
-
meta: {
|
|
91
|
-
type: 'suggestion',
|
|
92
|
-
docs: {
|
|
93
|
-
description: 'Require the dotted keys under one prefix to be next to each other.',
|
|
94
|
-
recommended: true,
|
|
95
|
-
},
|
|
96
|
-
fixable: 'code',
|
|
97
|
-
messages,
|
|
98
|
-
languages: ['soml/soml'],
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
export default config;
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
const isDigit = character => character >= '0' && character <= '9';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
Whether a `.` between two key segments has a digit on each side, so that the key reads as a number, as in `3.14` or `python3.12`, while it is two segments.
|
|
5
|
-
|
|
6
|
-
@param {string} before The segment before the dot.
|
|
7
|
-
@param {string} after The segment after the dot.
|
|
8
|
-
@returns {boolean}
|
|
9
|
-
*/
|
|
10
|
-
export default function isDecimalPoint(before, after) {
|
|
11
|
-
return isDigit(before.at(-1)) && isDigit(after[0]);
|
|
12
|
-
}
|