eslint-soml 0.0.2 → 0.0.4
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 +199 -1
- package/language/soml-source-code.js +87 -2
- package/package.json +4 -5
- package/readme.md +98 -8
- package/rules/format.js +9 -2
- package/rules/index.js +0 -2
- package/rules/key-name-casing.js +1 -1
- package/rules/no-block-string-edge-blank-lines.js +4 -8
- package/rules/no-block-string-trailing-whitespace.js +6 -11
- package/rules/no-confusable-keys.js +17 -26
- package/rules/no-decimal-file-modes.js +10 -3
- package/rules/no-duplicate-array-items.js +4 -2
- package/rules/no-empty-keys.js +1 -1
- package/rules/no-invisible-characters.js +3 -3
- package/rules/no-loss-of-precision.js +1 -1
- package/rules/no-misplaced-block-string-delimiters.js +35 -27
- 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 +4 -4
- package/rules/rule/to-eslint-problem.js +1 -0
- package/rules/sort-keys.js +66 -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/get-lines.js +24 -0
- package/rules/utils/has-doubled-backslashes.js +3 -2
- package/rules/utils/index.js +8 -2
- package/rules/utils/invisible-characters.js +7 -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
|
@@ -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,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {stringifyValue} from 'soml-lang';
|
|
2
|
+
import {escapeInvisibleCharacters, hasAdjacentComment, isPunctuator} from './utils/index.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -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. Canonical form writes a bidirectional control as it is, so it is escaped, as an escape in the block string was.
|
|
76
|
+
item: item.block ? escapeInvisibleCharacters(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
|
}
|
|
@@ -81,7 +81,7 @@ const config = {
|
|
|
81
81
|
meta: {
|
|
82
82
|
type: 'problem',
|
|
83
83
|
docs: {
|
|
84
|
-
description: 'Disallow floats
|
|
84
|
+
description: 'Disallow floats whose written digits are lost when they are stored.',
|
|
85
85
|
recommended: 'unopinionated',
|
|
86
86
|
},
|
|
87
87
|
messages,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {getLines} from './utils/index.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -22,6 +22,33 @@ function isClosingQuote(line, index, delimiter) {
|
|
|
22
22
|
return runs.filter(run => run.length === delimiter.length).length % 2 === 1;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/*
|
|
26
|
+
What may follow a closing delimiter on its line: whitespace, commas, closing brackets, and a comment. It is sticky, so it matches from `lastIndex`, which is set before every use.
|
|
27
|
+
*/
|
|
28
|
+
const AFTER_DELIMITER = /[\t ]*(?:[,\]\}][\t ]*)*(?:(?:#|\/\*).*)?$/vy;
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
Where a misplaced delimiter starts on a content line, or `undefined`: a run of exactly the delimiter's length with only what may follow a closing delimiter after it, as TOML and Java allow, in `Welcome!'''`, `Hi'''}`, or `Welcome!''' # greeting`. Only the first run with such a tail is checked, because what follows it is a comment or brackets.
|
|
32
|
+
*/
|
|
33
|
+
function getMisplacedDelimiterIndex(line, delimiter, isEscaped) {
|
|
34
|
+
for (const match of line.matchAll(QUOTE_RUNS[delimiter[0]])) {
|
|
35
|
+
let {index} = match;
|
|
36
|
+
const end = index + match[0].length;
|
|
37
|
+
AFTER_DELIMITER.lastIndex = end;
|
|
38
|
+
|
|
39
|
+
if (!AFTER_DELIMITER.test(line)) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// In a `"""` block, `\"` is an escaped quote, so it is not part of the run.
|
|
44
|
+
if (isEscaped && ESCAPING_BACKSLASHES.test(line.slice(0, index))) {
|
|
45
|
+
index++;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return index > 0 && end - index === delimiter.length && !isClosingQuote(line, index, delimiter) ? index : undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
/**
|
|
26
53
|
@param {SomlContext} context
|
|
27
54
|
*/
|
|
@@ -33,43 +60,24 @@ const create = context => {
|
|
|
33
60
|
return;
|
|
34
61
|
}
|
|
35
62
|
|
|
36
|
-
const lines =
|
|
63
|
+
const lines = getLines(context, node);
|
|
37
64
|
// The first line is the opening delimiter, so it is a run of one quote character.
|
|
38
|
-
const delimiter = lines[0];
|
|
39
|
-
let offset = sourceCode.getRange(node)[0] + delimiter.length + 1;
|
|
65
|
+
const delimiter = lines[0].text;
|
|
40
66
|
|
|
41
67
|
// The last line holds the closing delimiter. Only the first misplaced delimiter is reported, because the lines after it, such as the opening of another block string, are content only because of it.
|
|
42
|
-
for (const
|
|
43
|
-
|
|
44
|
-
let content = trimEnd(line, ' \t');
|
|
45
|
-
|
|
46
|
-
if (content.endsWith(',')) {
|
|
47
|
-
content = trimEnd(content.slice(0, -1), ' \t');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const index = content.length - delimiter.length;
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
index > 0
|
|
54
|
-
&& content.endsWith(delimiter)
|
|
55
|
-
&& content[index - 1] !== delimiter[0]
|
|
56
|
-
&& !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
|
-
) {
|
|
60
|
-
const start = offset + index;
|
|
68
|
+
for (const {text, start} of lines.slice(1, -1)) {
|
|
69
|
+
const index = getMisplacedDelimiterIndex(text, delimiter, node.style === 'escaped');
|
|
61
70
|
|
|
71
|
+
if (index !== undefined) {
|
|
62
72
|
return {
|
|
63
73
|
loc: {
|
|
64
|
-
start: sourceCode.getLocFromIndex(start),
|
|
65
|
-
end: sourceCode.getLocFromIndex(start + delimiter.length),
|
|
74
|
+
start: sourceCode.getLocFromIndex(start + index),
|
|
75
|
+
end: sourceCode.getLocFromIndex(start + index + delimiter.length),
|
|
66
76
|
},
|
|
67
77
|
messageId: MESSAGE_ID,
|
|
68
78
|
data: {delimiter},
|
|
69
79
|
};
|
|
70
80
|
}
|
|
71
|
-
|
|
72
|
-
offset += line.length + 1;
|
|
73
81
|
}
|
|
74
82
|
});
|
|
75
83
|
};
|
|
@@ -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
|
}
|
|
@@ -66,7 +66,7 @@ const config = {
|
|
|
66
66
|
meta: {
|
|
67
67
|
type: 'suggestion',
|
|
68
68
|
docs: {
|
|
69
|
-
description: 'Prefer literal strings, `\'…\'`, when a string
|
|
69
|
+
description: 'Prefer literal strings, `\'…\'`, when a string does not need escapes.',
|
|
70
70
|
recommended: true,
|
|
71
71
|
},
|
|
72
72
|
fixable: 'code',
|
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,70 @@ 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 a surrogate pair is followed by the same low surrogate in both.
|
|
63
|
+
firstIndex++;
|
|
64
|
+
secondIndex++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// A key that ends first comes first, as `a` before `a1`.
|
|
68
|
+
return (first.length - firstIndex) - (second.length - secondIndex) || compareKeys(first, second);
|
|
69
|
+
}
|
|
70
|
+
|
|
16
71
|
/**
|
|
17
72
|
@param {SomlContext} context
|
|
18
73
|
*/
|
|
19
74
|
const create = context => {
|
|
20
75
|
const {sourceCode} = context;
|
|
21
76
|
const [direction, {caseSensitive, natural, minKeys, allowLineSeparatedGroups}] = context.options;
|
|
22
|
-
//
|
|
23
|
-
const compare = natural ?
|
|
77
|
+
// 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.
|
|
78
|
+
const compare = natural ? compareNatural : compareKeys;
|
|
24
79
|
const normalize = caseSensitive ? name => name : name => name.toLowerCase();
|
|
25
80
|
const sign = direction === 'asc' ? 1 : -1;
|
|
26
81
|
|
|
@@ -38,6 +93,9 @@ const create = context => {
|
|
|
38
93
|
return commentLines;
|
|
39
94
|
};
|
|
40
95
|
|
|
96
|
+
// Whether the fix is allowed, found once per file, because the directives would be parsed again for every object.
|
|
97
|
+
let isFixable;
|
|
98
|
+
|
|
41
99
|
/**
|
|
42
100
|
Whether a blank line separates two members. A line in a comment is not blank.
|
|
43
101
|
|
|
@@ -56,25 +114,11 @@ const create = context => {
|
|
|
56
114
|
}
|
|
57
115
|
|
|
58
116
|
/*
|
|
59
|
-
The decoded
|
|
117
|
+
The decoded keys, so `'a'` and `a` sort the same.
|
|
60
118
|
*/
|
|
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
|
-
}
|
|
119
|
+
const keys = new Map(node.members.map(member => [member, normalize(member.key.value)]));
|
|
75
120
|
|
|
76
|
-
|
|
77
|
-
};
|
|
121
|
+
const compareMembers = (first, second) => sign * compare(keys.get(first), keys.get(second));
|
|
78
122
|
|
|
79
123
|
// A blank line starts a group that is sorted on its own, when that is allowed.
|
|
80
124
|
const groups = [];
|
|
@@ -110,7 +154,7 @@ const create = context => {
|
|
|
110
154
|
sortName: natural ? 'natural' : 'code point',
|
|
111
155
|
},
|
|
112
156
|
// 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,
|
|
157
|
+
fix: isFirst && (isFixable ??= !isDisabledAnywhere(context)) ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
|
|
114
158
|
};
|
|
115
159
|
|
|
116
160
|
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`. A quoted key
|
|
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`. A word after an acronym has at least two lowercase letters, so a plural acronym, as in `maxCPUs`, stays one word. 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]{2})/v)
|
|
9
|
+
.filter(word => word !== '')
|
|
10
|
+
.map(word => word.toLowerCase());
|
|
9
11
|
}
|