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
package/rules/format.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {format} from 'soml-lang';
|
|
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_INSERT = 'format/insert';
|
|
9
|
+
const MESSAGE_ID_DELETE = 'format/delete';
|
|
10
|
+
const MESSAGE_ID_REPLACE = 'format/replace';
|
|
11
|
+
const messages = {
|
|
12
|
+
[MESSAGE_ID_INSERT]: 'Insert `{{inserted}}`.',
|
|
13
|
+
[MESSAGE_ID_DELETE]: 'Delete `{{deleted}}`.',
|
|
14
|
+
[MESSAGE_ID_REPLACE]: 'Replace `{{deleted}}` with `{{inserted}}`.',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
Shows whitespace in a message, the way eslint-plugin-prettier does.
|
|
19
|
+
*/
|
|
20
|
+
const showInvisibles = text => text
|
|
21
|
+
.replaceAll(' ', '·')
|
|
22
|
+
.replaceAll('\n', '⏎')
|
|
23
|
+
.replaceAll('\t', '↹');
|
|
24
|
+
|
|
25
|
+
const GAP_CHARACTERS = new Set([' ', '\t', '\n', ',']);
|
|
26
|
+
const isGapCharacter = character => GAP_CHARACTERS.has(character);
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
The end of the run of spaces, tabs, line feeds, and commas at `index`.
|
|
30
|
+
*/
|
|
31
|
+
function skipGap(text, index) {
|
|
32
|
+
while (index < text.length && isGapCharacter(text[index])) {
|
|
33
|
+
index++;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return index;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
The changes from `text` to `formatted`, as ranges of `text` and what replaces them.
|
|
41
|
+
|
|
42
|
+
The formatter only changes spaces, tabs, line feeds, and commas, so without those the two texts are the same. Walking both at once, every run of those characters that differs is one change. This takes linear time, where a general diff takes quadratic time on a file with many changes.
|
|
43
|
+
*/
|
|
44
|
+
function * getChanges(text, formatted) {
|
|
45
|
+
let textIndex = 0;
|
|
46
|
+
let formattedIndex = 0;
|
|
47
|
+
|
|
48
|
+
while (textIndex < text.length || formattedIndex < formatted.length) {
|
|
49
|
+
const textGapEnd = skipGap(text, textIndex);
|
|
50
|
+
const formattedGapEnd = skipGap(formatted, formattedIndex);
|
|
51
|
+
const inserted = formatted.slice(formattedIndex, formattedGapEnd);
|
|
52
|
+
|
|
53
|
+
if (text.slice(textIndex, textGapEnd) !== inserted) {
|
|
54
|
+
yield {start: textIndex, end: textGapEnd, inserted};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
textIndex = textGapEnd;
|
|
58
|
+
formattedIndex = formattedGapEnd;
|
|
59
|
+
|
|
60
|
+
while (textIndex < text.length && !isGapCharacter(text[textIndex]) && text[textIndex] === formatted[formattedIndex]) {
|
|
61
|
+
textIndex++;
|
|
62
|
+
formattedIndex++;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Not expected, but if the formatter changed anything else, both stop at a different character, or one at its end, and the rest is one change.
|
|
66
|
+
const isTextStuck = textIndex === text.length || !isGapCharacter(text[textIndex]);
|
|
67
|
+
const isFormattedStuck = formattedIndex === formatted.length || !isGapCharacter(formatted[formattedIndex]);
|
|
68
|
+
|
|
69
|
+
if (isTextStuck && isFormattedStuck && (textIndex < text.length || formattedIndex < formatted.length)) {
|
|
70
|
+
yield {start: textIndex, end: text.length, inserted: formatted.slice(formattedIndex)};
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
@param {SomlContext} context
|
|
78
|
+
*/
|
|
79
|
+
const create = context => {
|
|
80
|
+
const {sourceCode} = context;
|
|
81
|
+
|
|
82
|
+
context.on('Document', function * () {
|
|
83
|
+
const {text} = sourceCode;
|
|
84
|
+
const formatted = format(text);
|
|
85
|
+
|
|
86
|
+
if (formatted === text) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const {start, end, inserted} of getChanges(text, formatted)) {
|
|
91
|
+
const deleted = text.slice(start, end);
|
|
92
|
+
let messageId = MESSAGE_ID_REPLACE;
|
|
93
|
+
|
|
94
|
+
if (deleted === '') {
|
|
95
|
+
messageId = MESSAGE_ID_INSERT;
|
|
96
|
+
} else if (inserted === '') {
|
|
97
|
+
messageId = MESSAGE_ID_DELETE;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
yield {
|
|
101
|
+
loc: {
|
|
102
|
+
start: sourceCode.getLocFromIndex(start),
|
|
103
|
+
end: sourceCode.getLocFromIndex(end),
|
|
104
|
+
},
|
|
105
|
+
messageId,
|
|
106
|
+
data: {
|
|
107
|
+
deleted: showInvisibles(deleted),
|
|
108
|
+
inserted: showInvisibles(inserted),
|
|
109
|
+
},
|
|
110
|
+
// The whole document is replaced, so the formatting is applied at once. Applying only some changes, when a fix from another rule overlaps one of them, could leave a comma or a block string half moved.
|
|
111
|
+
fix: fixer => fixer.replaceTextRange([0, text.length], formatted),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
@type {SomlRule}
|
|
119
|
+
*/
|
|
120
|
+
const config = {
|
|
121
|
+
create,
|
|
122
|
+
meta: {
|
|
123
|
+
type: 'layout',
|
|
124
|
+
docs: {
|
|
125
|
+
description: 'Enforce the layout of the SOML formatter.',
|
|
126
|
+
recommended: true,
|
|
127
|
+
},
|
|
128
|
+
fixable: 'code',
|
|
129
|
+
messages,
|
|
130
|
+
languages: ['soml/soml'],
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export default config;
|
package/rules/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Generated file, DO NOT edit
|
|
2
|
+
|
|
3
|
+
export {default as format} from './format.js';
|
|
4
|
+
export {default as 'key-name-casing'} from './key-name-casing.js';
|
|
5
|
+
export {default as 'no-block-string-trailing-whitespace'} from './no-block-string-trailing-whitespace.js';
|
|
6
|
+
export {default as 'no-empty-keys'} from './no-empty-keys.js';
|
|
7
|
+
export {default as 'no-invisible-characters'} from './no-invisible-characters.js';
|
|
8
|
+
export {default as 'no-loss-of-precision'} from './no-loss-of-precision.js';
|
|
9
|
+
export {default as 'no-mixed-number-types'} from './no-mixed-number-types.js';
|
|
10
|
+
export {default as 'no-numeric-dotted-keys'} from './no-numeric-dotted-keys.js';
|
|
11
|
+
export {default as 'no-unnormalized-keys'} from './no-unnormalized-keys.js';
|
|
12
|
+
export {default as 'numeric-separators-style'} from './numeric-separators-style.js';
|
|
13
|
+
export {default as 'prefer-bare-keys'} from './prefer-bare-keys.js';
|
|
14
|
+
export {default as 'prefer-literal-strings'} from './prefer-literal-strings.js';
|
|
15
|
+
export {default as 'prefer-utc-instants'} from './prefer-utc-instants.js';
|
|
16
|
+
export {default as 'sort-keys'} from './sort-keys.js';
|
|
17
|
+
export {default as 'spaced-comment'} from './spaced-comment.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID = 'key-name-casing';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'The key `{{key}}` is not in {{case}}.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Digits are allowed anywhere, so `http2-port` is kebab-case, and `404` passes in every case.
|
|
13
|
+
*/
|
|
14
|
+
const CASES = {
|
|
15
|
+
'kebab-case': /^[\da-z]+(?:-[\da-z]+)*$/v,
|
|
16
|
+
camelCase: /^[\da-z][\dA-Za-z]*$/v,
|
|
17
|
+
// eslint-disable-next-line camelcase -- The name of the case is the option value.
|
|
18
|
+
snake_case: /^[\da-z]+(?:_[\da-z]+)*$/v,
|
|
19
|
+
PascalCase: /^[\dA-Z][\dA-Za-z]*$/v,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
@param {SomlContext} context
|
|
24
|
+
*/
|
|
25
|
+
const create = context => {
|
|
26
|
+
const [{case: keyCase, ignore}] = context.options;
|
|
27
|
+
const pattern = CASES[keyCase];
|
|
28
|
+
// The `u` flag, as in ESLint's own rules, because the `v` flag rejects some escapes that people write, such as `\-`.
|
|
29
|
+
const ignorePatterns = ignore.map(source => new RegExp(source, 'u'));
|
|
30
|
+
|
|
31
|
+
context.on('KeySegment', node => {
|
|
32
|
+
// A quoted key holds data rather than a name, such as a path or a URL, so it is not checked.
|
|
33
|
+
if (
|
|
34
|
+
node.style !== 'bare'
|
|
35
|
+
|| pattern.test(node.value)
|
|
36
|
+
|| ignorePatterns.some(ignorePattern => ignorePattern.test(node.value))
|
|
37
|
+
) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
node,
|
|
43
|
+
messageId: MESSAGE_ID,
|
|
44
|
+
data: {
|
|
45
|
+
key: node.value,
|
|
46
|
+
case: keyCase,
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
@type {SomlRule}
|
|
54
|
+
*/
|
|
55
|
+
const config = {
|
|
56
|
+
create,
|
|
57
|
+
meta: {
|
|
58
|
+
type: 'suggestion',
|
|
59
|
+
docs: {
|
|
60
|
+
description: 'Enforce a casing style for keys.',
|
|
61
|
+
recommended: false,
|
|
62
|
+
},
|
|
63
|
+
schema: [
|
|
64
|
+
{
|
|
65
|
+
type: 'object',
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
properties: {
|
|
68
|
+
case: {
|
|
69
|
+
enum: Object.keys(CASES),
|
|
70
|
+
description: 'The casing style keys must use.',
|
|
71
|
+
},
|
|
72
|
+
ignore: {
|
|
73
|
+
type: 'array',
|
|
74
|
+
uniqueItems: true,
|
|
75
|
+
items: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
},
|
|
78
|
+
description: 'Regular expressions for keys to ignore.',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
defaultOptions: [
|
|
84
|
+
{
|
|
85
|
+
case: 'kebab-case',
|
|
86
|
+
ignore: [],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
messages,
|
|
90
|
+
languages: ['soml/soml'],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export default config;
|
|
@@ -0,0 +1,64 @@
|
|
|
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-block-string-trailing-whitespace';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'Whitespace at the end of a line in a block string is part of the value, and editors often remove it.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const TRAILING_WHITESPACE = /[\t ]+$/v;
|
|
12
|
+
const BLANK_LINE = /^[\t ]*$/v;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
@param {SomlContext} context
|
|
16
|
+
*/
|
|
17
|
+
const create = context => {
|
|
18
|
+
const {sourceCode} = context;
|
|
19
|
+
|
|
20
|
+
context.on('String', function * (node) {
|
|
21
|
+
if (!node.block) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines = sourceCode.getText(node).split('\n');
|
|
26
|
+
let offset = sourceCode.getRange(node)[0] + lines[0].length + 1;
|
|
27
|
+
|
|
28
|
+
// The first line holds the opening delimiter, and the last line the closing one.
|
|
29
|
+
for (const line of lines.slice(1, -1)) {
|
|
30
|
+
const match = TRAILING_WHITESPACE.exec(line);
|
|
31
|
+
|
|
32
|
+
// A blank line becomes an empty line, so its whitespace is not part of the value.
|
|
33
|
+
if (match && !BLANK_LINE.test(line)) {
|
|
34
|
+
yield {
|
|
35
|
+
loc: {
|
|
36
|
+
start: sourceCode.getLocFromIndex(offset + match.index),
|
|
37
|
+
end: sourceCode.getLocFromIndex(offset + line.length),
|
|
38
|
+
},
|
|
39
|
+
messageId: MESSAGE_ID,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
offset += line.length + 1;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
@type {SomlRule}
|
|
50
|
+
*/
|
|
51
|
+
const config = {
|
|
52
|
+
create,
|
|
53
|
+
meta: {
|
|
54
|
+
type: 'problem',
|
|
55
|
+
docs: {
|
|
56
|
+
description: 'Disallow trailing whitespace in block strings.',
|
|
57
|
+
recommended: 'unopinionated',
|
|
58
|
+
},
|
|
59
|
+
messages,
|
|
60
|
+
languages: ['soml/soml'],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export default config;
|
|
@@ -0,0 +1,43 @@
|
|
|
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-empty-keys';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'Empty keys are not allowed.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
@param {SomlContext} context
|
|
13
|
+
*/
|
|
14
|
+
const create = context => {
|
|
15
|
+
context.on('KeySegment', node => {
|
|
16
|
+
if (node.value !== '') {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
node,
|
|
22
|
+
messageId: MESSAGE_ID,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
@type {SomlRule}
|
|
29
|
+
*/
|
|
30
|
+
const config = {
|
|
31
|
+
create,
|
|
32
|
+
meta: {
|
|
33
|
+
type: 'problem',
|
|
34
|
+
docs: {
|
|
35
|
+
description: 'Disallow empty keys.',
|
|
36
|
+
recommended: 'unopinionated',
|
|
37
|
+
},
|
|
38
|
+
messages,
|
|
39
|
+
languages: ['soml/soml'],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default config;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {matchInvisibleCharacters, toUnicodeEscape, toEscapedString} 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, Comment} from 'soml-lang';
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MESSAGE_ID = 'no-invisible-characters';
|
|
10
|
+
const messages = {
|
|
11
|
+
[MESSAGE_ID]: 'The character U+{{codePoint}} is invisible or looks like a space. Write it as an escape, `{{escape}}`.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
@param {SomlContext} context
|
|
16
|
+
*/
|
|
17
|
+
const create = context => {
|
|
18
|
+
const {sourceCode} = context;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
@param {StringNode | KeySegmentNode | Comment} node
|
|
22
|
+
@param {((fixer: any, character: string, start: number) => any) | undefined} fix
|
|
23
|
+
*/
|
|
24
|
+
function * getProblems(node, fix) {
|
|
25
|
+
const text = sourceCode.getText(node);
|
|
26
|
+
|
|
27
|
+
for (const match of matchInvisibleCharacters(text)) {
|
|
28
|
+
const [character] = match;
|
|
29
|
+
const start = sourceCode.getRange(node)[0] + match.index;
|
|
30
|
+
const end = start + character.length;
|
|
31
|
+
|
|
32
|
+
yield {
|
|
33
|
+
loc: {
|
|
34
|
+
start: sourceCode.getLocFromIndex(start),
|
|
35
|
+
end: sourceCode.getLocFromIndex(end),
|
|
36
|
+
},
|
|
37
|
+
messageId: MESSAGE_ID,
|
|
38
|
+
data: {
|
|
39
|
+
codePoint: character.codePointAt(0).toString(16).toUpperCase().padStart(4, '0'),
|
|
40
|
+
escape: toUnicodeEscape(character),
|
|
41
|
+
},
|
|
42
|
+
fix: fix && (fixer => fix(fixer, character, start)),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
@param {StringNode | KeySegmentNode} node
|
|
49
|
+
*/
|
|
50
|
+
function getFix(node) {
|
|
51
|
+
// In `"..."`, the character can be replaced by its escape where it is.
|
|
52
|
+
if (node.style === 'escaped') {
|
|
53
|
+
return (fixer, character, start) => fixer.replaceTextRange([start, start + character.length], toUnicodeEscape(character));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A `'...'` string has no escapes, so it becomes a `"..."` string. A `'''` block would need its content re-escaped line by line, so it is left to the author.
|
|
57
|
+
if (node.style === 'literal' && !node.block) {
|
|
58
|
+
return fixer => fixer.replaceText(node, toEscapedString(node.value));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
context.on(['String', 'KeySegment'], node => {
|
|
63
|
+
if (node.style === 'bare') {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return getProblems(node, getFix(node));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
context.on('Document', () => sourceCode.getAllComments().map(comment => getProblems(comment)));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
@type {SomlRule}
|
|
75
|
+
*/
|
|
76
|
+
const config = {
|
|
77
|
+
create,
|
|
78
|
+
meta: {
|
|
79
|
+
type: 'problem',
|
|
80
|
+
docs: {
|
|
81
|
+
description: 'Disallow invisible characters in strings, keys, and comments.',
|
|
82
|
+
recommended: 'unopinionated',
|
|
83
|
+
},
|
|
84
|
+
fixable: 'code',
|
|
85
|
+
messages,
|
|
86
|
+
languages: ['soml/soml'],
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export default config;
|
|
@@ -0,0 +1,96 @@
|
|
|
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-loss-of-precision';
|
|
7
|
+
const messages = {
|
|
8
|
+
[MESSAGE_ID]: 'This number cannot be stored exactly as a float. Its value is `{{value}}`.',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
The significant digits and the exponent of a decimal number, without leading or trailing zeros. `0.0150` is the digits `15` and the exponent `-2`, which is 1.5e-2.
|
|
13
|
+
*/
|
|
14
|
+
function normalize(text) {
|
|
15
|
+
const [mantissa, exponent = '0'] = text.replaceAll('_', '').replace(/^-/v, '').split('e', 2);
|
|
16
|
+
const [integer, fraction = ''] = mantissa.split('.', 2);
|
|
17
|
+
const allDigits = integer + fraction;
|
|
18
|
+
const significant = allDigits.replace(/^0+/v, '');
|
|
19
|
+
const leadingZeros = allDigits.length - significant.length;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
digits: significant.replace(/0+$/v, ''),
|
|
23
|
+
exponent: Number(exponent) + integer.length - 1 - leadingZeros,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
Whether the stored float, written with as many significant digits as the literal has, is not the literal. This is how ESLint's own `no-loss-of-precision` rule decides.
|
|
29
|
+
*/
|
|
30
|
+
function losesPrecision(text, value) {
|
|
31
|
+
const literal = normalize(text);
|
|
32
|
+
|
|
33
|
+
// Zero is exact.
|
|
34
|
+
if (literal.digits === '') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `toExponential()` takes at most 100 fraction digits. A float written exactly with more digits than that, which is rare, is reported anyway.
|
|
39
|
+
if (literal.digits.length > 101) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stored = normalize(Math.abs(value).toExponential(literal.digits.length - 1));
|
|
44
|
+
return stored.digits !== literal.digits || stored.exponent !== literal.exponent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/*
|
|
48
|
+
A float as canonical form writes it: JavaScript's spelling, with `.0` added so it does not read as an int, and no `+` in the exponent.
|
|
49
|
+
*/
|
|
50
|
+
function formatFloat(value) {
|
|
51
|
+
const text = String(value).replace('e+', 'e');
|
|
52
|
+
return /[.e]/v.test(text) ? text : `${text}.0`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
@param {SomlContext} context
|
|
57
|
+
*/
|
|
58
|
+
const create = context => {
|
|
59
|
+
context.on('Float', node => {
|
|
60
|
+
if (!Number.isFinite(node.value)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const text = context.sourceCode.getText(node);
|
|
65
|
+
|
|
66
|
+
if (!losesPrecision(text, node.value)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
node,
|
|
72
|
+
messageId: MESSAGE_ID,
|
|
73
|
+
data: {
|
|
74
|
+
value: formatFloat(node.value),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
@type {SomlRule}
|
|
82
|
+
*/
|
|
83
|
+
const config = {
|
|
84
|
+
create,
|
|
85
|
+
meta: {
|
|
86
|
+
type: 'problem',
|
|
87
|
+
docs: {
|
|
88
|
+
description: 'Disallow floats that cannot be stored exactly as written.',
|
|
89
|
+
recommended: 'unopinionated',
|
|
90
|
+
},
|
|
91
|
+
messages,
|
|
92
|
+
languages: ['soml/soml'],
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export default config;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const MESSAGE_ID_INT = 'no-mixed-number-types/int';
|
|
7
|
+
const MESSAGE_ID_FLOAT = 'no-mixed-number-types/float';
|
|
8
|
+
const MESSAGE_ID_SUGGESTION = 'no-mixed-number-types/suggestion';
|
|
9
|
+
const messages = {
|
|
10
|
+
[MESSAGE_ID_INT]: '`{{number}}` is an int, but the other numbers in this array are floats.',
|
|
11
|
+
[MESSAGE_ID_FLOAT]: '`{{number}}` is a float, but the other numbers in this array are ints.',
|
|
12
|
+
[MESSAGE_ID_SUGGESTION]: 'Write it as a float: `{{replacement}}`.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
@param {SomlContext} context
|
|
17
|
+
*/
|
|
18
|
+
const create = context => {
|
|
19
|
+
const {sourceCode} = context;
|
|
20
|
+
|
|
21
|
+
context.on('Array', function * (node) {
|
|
22
|
+
const ints = node.elements.filter(element => element.type === 'Integer');
|
|
23
|
+
const floats = node.elements.filter(element => element.type === 'Float');
|
|
24
|
+
|
|
25
|
+
if (ints.length === 0 || floats.length === 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The fewer kind is the likely mistake. An int becomes a float by adding `.0`, which only works for a decimal int.
|
|
30
|
+
if (ints.length <= floats.length) {
|
|
31
|
+
for (const int of ints) {
|
|
32
|
+
const number = sourceCode.getText(int);
|
|
33
|
+
const replacement = `${number}.0`;
|
|
34
|
+
|
|
35
|
+
yield {
|
|
36
|
+
node: int,
|
|
37
|
+
messageId: MESSAGE_ID_INT,
|
|
38
|
+
data: {number},
|
|
39
|
+
...(int.radix === 10 && {
|
|
40
|
+
suggest: [
|
|
41
|
+
{
|
|
42
|
+
messageId: MESSAGE_ID_SUGGESTION,
|
|
43
|
+
data: {replacement},
|
|
44
|
+
fix: fixer => fixer.replaceText(int, replacement),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const float of floats) {
|
|
55
|
+
yield {
|
|
56
|
+
node: float,
|
|
57
|
+
messageId: MESSAGE_ID_FLOAT,
|
|
58
|
+
data: {number: sourceCode.getText(float)},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
@type {SomlRule}
|
|
66
|
+
*/
|
|
67
|
+
const config = {
|
|
68
|
+
create,
|
|
69
|
+
meta: {
|
|
70
|
+
type: 'problem',
|
|
71
|
+
docs: {
|
|
72
|
+
description: 'Disallow arrays that mix ints and floats.',
|
|
73
|
+
recommended: true,
|
|
74
|
+
},
|
|
75
|
+
hasSuggestions: true,
|
|
76
|
+
messages,
|
|
77
|
+
languages: ['soml/soml'],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default config;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
@import {SomlContext} from './rule/soml-context.js';
|
|
3
|
+
@import {SomlRule} from './rule/to-eslint-rule.js';
|
|
4
|
+
@import {KeySegmentNode} from 'soml-lang';
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MESSAGE_ID_ERROR = 'no-numeric-dotted-keys/error';
|
|
8
|
+
const MESSAGE_ID_SUGGESTION = 'no-numeric-dotted-keys/suggestion';
|
|
9
|
+
const messages = {
|
|
10
|
+
[MESSAGE_ID_ERROR]: '`{{key}}` is a nested key, because `.` separates key segments. It is not one key.',
|
|
11
|
+
[MESSAGE_ID_SUGGESTION]: 'Quote it as one key: `{{replacement}}`.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const isDigit = character => character >= '0' && character <= '9';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
Whether the `.` between two segments reads as a decimal point, as in `3.14` or `python3.12`.
|
|
18
|
+
|
|
19
|
+
@param {KeySegmentNode} segment
|
|
20
|
+
@param {KeySegmentNode} next
|
|
21
|
+
*/
|
|
22
|
+
const isDecimalPoint = (segment, next) =>
|
|
23
|
+
segment.style === 'bare'
|
|
24
|
+
&& next.style === 'bare'
|
|
25
|
+
&& isDigit(segment.value.at(-1))
|
|
26
|
+
&& isDigit(next.value[0]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
@param {SomlContext} context
|
|
30
|
+
*/
|
|
31
|
+
const create = context => {
|
|
32
|
+
context.on('Key', node => {
|
|
33
|
+
const {segments} = node;
|
|
34
|
+
|
|
35
|
+
const hasDecimalPoint = segments.slice(1).some((next, index) => isDecimalPoint(segments[index], next));
|
|
36
|
+
|
|
37
|
+
if (!hasDecimalPoint) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const key = context.sourceCode.getText(node);
|
|
42
|
+
const problem = {
|
|
43
|
+
node,
|
|
44
|
+
messageId: MESSAGE_ID_ERROR,
|
|
45
|
+
data: {key},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// A key with quoted segments has no single obvious meaning, so it gets no suggestion. Bare segments never contain `'`.
|
|
49
|
+
if (segments.every(segment => segment.style === 'bare')) {
|
|
50
|
+
const replacement = `'${key}'`;
|
|
51
|
+
problem.suggest = [
|
|
52
|
+
{
|
|
53
|
+
messageId: MESSAGE_ID_SUGGESTION,
|
|
54
|
+
data: {replacement},
|
|
55
|
+
fix: fixer => fixer.replaceText(node, replacement),
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return problem;
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
@type {SomlRule}
|
|
66
|
+
*/
|
|
67
|
+
const config = {
|
|
68
|
+
create,
|
|
69
|
+
meta: {
|
|
70
|
+
type: 'problem',
|
|
71
|
+
docs: {
|
|
72
|
+
description: 'Disallow dotted keys that read as one number.',
|
|
73
|
+
recommended: 'unopinionated',
|
|
74
|
+
},
|
|
75
|
+
hasSuggestions: true,
|
|
76
|
+
messages,
|
|
77
|
+
languages: ['soml/soml'],
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export default config;
|