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
package/index.d.ts
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
import type {ESLint, Linter} from 'eslint';
|
|
2
|
+
import type {LanguageOptions, RuleVisitor} from '@eslint/core';
|
|
3
|
+
import type {
|
|
4
|
+
CustomRuleDefinitionType,
|
|
5
|
+
CustomRuleTypeDefinitions,
|
|
6
|
+
CustomRuleVisitorWithExit,
|
|
7
|
+
TextSourceCodeBase,
|
|
8
|
+
} from '@eslint/plugin-kit';
|
|
9
|
+
import type {
|
|
10
|
+
Node,
|
|
11
|
+
DocumentNode,
|
|
12
|
+
ObjectNode,
|
|
13
|
+
MemberNode,
|
|
14
|
+
ArrayNode,
|
|
15
|
+
KeyNode,
|
|
16
|
+
StringNode,
|
|
17
|
+
IntegerNode,
|
|
18
|
+
FloatNode,
|
|
19
|
+
BooleanNode,
|
|
20
|
+
NullNode,
|
|
21
|
+
InstantNode,
|
|
22
|
+
DurationNode,
|
|
23
|
+
Token,
|
|
24
|
+
Comment,
|
|
25
|
+
} from 'soml-lang';
|
|
2
26
|
|
|
3
27
|
export type {
|
|
4
28
|
Node,
|
|
@@ -7,7 +31,6 @@ export type {
|
|
|
7
31
|
MemberNode,
|
|
8
32
|
ArrayNode,
|
|
9
33
|
KeyNode,
|
|
10
|
-
KeySegmentNode,
|
|
11
34
|
ValueNode,
|
|
12
35
|
StringNode,
|
|
13
36
|
IntegerNode,
|
|
@@ -23,6 +46,181 @@ export type {
|
|
|
23
46
|
SourceLocation,
|
|
24
47
|
} from 'soml-lang';
|
|
25
48
|
|
|
49
|
+
/**
|
|
50
|
+
A node, a token, or a comment of a SOML document.
|
|
51
|
+
*/
|
|
52
|
+
export type SomlSyntaxElement = Node | Token | Comment;
|
|
53
|
+
|
|
54
|
+
type TokenOptions = {
|
|
55
|
+
/**
|
|
56
|
+
Whether comments count as tokens.
|
|
57
|
+
|
|
58
|
+
@default false
|
|
59
|
+
*/
|
|
60
|
+
readonly includeComments?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type TokenOptionsWithoutComments = TokenOptions & {readonly includeComments?: false};
|
|
64
|
+
type TokenOptionsWithComments = TokenOptions & {readonly includeComments: true};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
The source code of a SOML document, which a rule gets as `context.sourceCode`.
|
|
68
|
+
|
|
69
|
+
It has the methods of `TextSourceCodeBase` from `@eslint/plugin-kit`, such as `getText()`, `getLoc()`, `getRange()`, and `getLocFromIndex()`, and the methods below. A token method finds a token in source order and returns `null` when there is none, like ESLint's own `SourceCode`. It takes only the `includeComments` option.
|
|
70
|
+
|
|
71
|
+
@example
|
|
72
|
+
```
|
|
73
|
+
import type {SomlRuleDefinition} from 'eslint-soml';
|
|
74
|
+
|
|
75
|
+
const rule: SomlRuleDefinition = {
|
|
76
|
+
meta: {
|
|
77
|
+
type: 'problem',
|
|
78
|
+
messages: {
|
|
79
|
+
trailingComma: 'Remove the comma after the last item.',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
create(context) {
|
|
83
|
+
return {
|
|
84
|
+
Array(node) {
|
|
85
|
+
const lastToken = context.sourceCode.getLastToken(node);
|
|
86
|
+
const beforeLast = lastToken && context.sourceCode.getTokenBefore(lastToken);
|
|
87
|
+
|
|
88
|
+
if (beforeLast?.value === ',') {
|
|
89
|
+
context.report({loc: beforeLast.loc, messageId: 'trailingComma'});
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
```
|
|
96
|
+
*/
|
|
97
|
+
export interface SomlSourceCode extends TextSourceCodeBase<{
|
|
98
|
+
LangOptions: LanguageOptions;
|
|
99
|
+
RootNode: DocumentNode;
|
|
100
|
+
SyntaxElementWithLoc: SomlSyntaxElement;
|
|
101
|
+
ConfigNode: Comment;
|
|
102
|
+
}> {
|
|
103
|
+
/**
|
|
104
|
+
Marks the source code as SOML, so a rule that works on several languages can tell, as with `context.sourceCode.parserServices?.isSOML`.
|
|
105
|
+
*/
|
|
106
|
+
readonly parserServices: {readonly isSOML: true};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
The parent of a node, or `undefined` for the `Document` node. Nodes have no `parent` property.
|
|
110
|
+
*/
|
|
111
|
+
getParent(node: Node): Node | undefined;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
Every comment, in source order.
|
|
115
|
+
*/
|
|
116
|
+
getAllComments(): readonly Comment[];
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
The token before a node, token, or comment.
|
|
120
|
+
*/
|
|
121
|
+
getTokenBefore(nodeOrToken: SomlSyntaxElement, options?: TokenOptionsWithoutComments): Token | null;
|
|
122
|
+
getTokenBefore(nodeOrToken: SomlSyntaxElement, options: TokenOptionsWithComments): Token | Comment | null;
|
|
123
|
+
getTokenBefore(nodeOrToken: SomlSyntaxElement, options?: TokenOptions): Token | Comment | null;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
The token after a node, token, or comment.
|
|
127
|
+
*/
|
|
128
|
+
getTokenAfter(nodeOrToken: SomlSyntaxElement, options?: TokenOptionsWithoutComments): Token | null;
|
|
129
|
+
getTokenAfter(nodeOrToken: SomlSyntaxElement, options: TokenOptionsWithComments): Token | Comment | null;
|
|
130
|
+
getTokenAfter(nodeOrToken: SomlSyntaxElement, options?: TokenOptions): Token | Comment | null;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
The first token inside a node.
|
|
134
|
+
*/
|
|
135
|
+
getFirstToken(node: Node, options?: TokenOptionsWithoutComments): Token | null;
|
|
136
|
+
getFirstToken(node: Node, options: TokenOptionsWithComments): Token | Comment | null;
|
|
137
|
+
getFirstToken(node: Node, options?: TokenOptions): Token | Comment | null;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
The last token inside a node.
|
|
141
|
+
*/
|
|
142
|
+
getLastToken(node: Node, options?: TokenOptionsWithoutComments): Token | null;
|
|
143
|
+
getLastToken(node: Node, options: TokenOptionsWithComments): Token | Comment | null;
|
|
144
|
+
getLastToken(node: Node, options?: TokenOptions): Token | Comment | null;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
Every token inside a node, in source order.
|
|
148
|
+
*/
|
|
149
|
+
getTokens(node: Node, options?: TokenOptionsWithoutComments): Token[];
|
|
150
|
+
getTokens(node: Node, options: TokenOptionsWithComments): Array<Token | Comment>;
|
|
151
|
+
getTokens(node: Node, options?: TokenOptions): Array<Token | Comment>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type ValueParent = MemberNode | ArrayNode;
|
|
155
|
+
type CollectionParent = DocumentNode | ValueParent;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
The listeners that a rule for SOML returns, with the type of each node and of its parent.
|
|
159
|
+
*/
|
|
160
|
+
export interface SomlRuleVisitor extends RuleVisitor, CustomRuleVisitorWithExit<{
|
|
161
|
+
Document?(node: DocumentNode): void;
|
|
162
|
+
Object?(node: ObjectNode, parent: CollectionParent): void;
|
|
163
|
+
Member?(node: MemberNode, parent: ObjectNode): void;
|
|
164
|
+
Key?(node: KeyNode, parent: MemberNode): void;
|
|
165
|
+
Array?(node: ArrayNode, parent: CollectionParent): void;
|
|
166
|
+
String?(node: StringNode, parent: ValueParent): void;
|
|
167
|
+
Integer?(node: IntegerNode, parent: ValueParent): void;
|
|
168
|
+
Float?(node: FloatNode, parent: ValueParent): void;
|
|
169
|
+
Boolean?(node: BooleanNode, parent: ValueParent): void;
|
|
170
|
+
Null?(node: NullNode, parent: ValueParent): void;
|
|
171
|
+
Instant?(node: InstantNode, parent: ValueParent): void;
|
|
172
|
+
Duration?(node: DurationNode, parent: ValueParent): void;
|
|
173
|
+
}> {}
|
|
174
|
+
|
|
175
|
+
export type SomlRuleDefinitionTypeOptions = CustomRuleTypeDefinitions;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
The type of a rule for SOML files, which gets a `SomlSourceCode` and returns a `SomlRuleVisitor`.
|
|
179
|
+
|
|
180
|
+
@example
|
|
181
|
+
```
|
|
182
|
+
import type {SomlRuleDefinition} from 'eslint-soml';
|
|
183
|
+
|
|
184
|
+
const rule: SomlRuleDefinition = {
|
|
185
|
+
meta: {
|
|
186
|
+
type: 'suggestion',
|
|
187
|
+
messages: {
|
|
188
|
+
tooLong: 'The key `{{key}}` is longer than 40 characters.',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
create(context) {
|
|
192
|
+
return {
|
|
193
|
+
Key(node) {
|
|
194
|
+
if (node.value.length > 40) {
|
|
195
|
+
context.report({node, messageId: 'tooLong', data: {key: node.value}});
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
```
|
|
202
|
+
*/
|
|
203
|
+
export type SomlRuleDefinition<Options extends Partial<SomlRuleDefinitionTypeOptions> = {}> = CustomRuleDefinitionType<{
|
|
204
|
+
LangOptions: LanguageOptions;
|
|
205
|
+
Code: SomlSourceCode;
|
|
206
|
+
Visitor: SomlRuleVisitor;
|
|
207
|
+
Node: SomlSyntaxElement;
|
|
208
|
+
}, Options>;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
The `context` of a rule for SOML files, for a helper that a rule passes it to.
|
|
212
|
+
|
|
213
|
+
@example
|
|
214
|
+
```
|
|
215
|
+
import type {SomlRuleContext, StringNode} from 'eslint-soml';
|
|
216
|
+
|
|
217
|
+
function isWrittenEscaped(context: SomlRuleContext, node: StringNode): boolean {
|
|
218
|
+
return context.sourceCode.getText(node).startsWith('"');
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
*/
|
|
222
|
+
export type SomlRuleContext<Options extends Partial<SomlRuleDefinitionTypeOptions> = {}> = Parameters<SomlRuleDefinition<Options>['create']>[0];
|
|
223
|
+
|
|
26
224
|
declare const eslintSoml: ESLint.Plugin & {
|
|
27
225
|
configs: {
|
|
28
226
|
recommended: Linter.Config;
|
|
@@ -13,9 +13,40 @@ import {visitorKeys} from 'soml-lang';
|
|
|
13
13
|
|
|
14
14
|
const commentParser = new ConfigCommentParser();
|
|
15
15
|
const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/v;
|
|
16
|
+
const RULE_CONFIG = /^\s*eslint(?:\s|$)/v;
|
|
17
|
+
// What separates a directive from its justification, as ESLint splits it.
|
|
18
|
+
const JUSTIFICATION = /\s-{2,}\s/v;
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
Whether a comment is a directive. A `#` comment is the usual comment style, so one that starts with `eslint` in prose, as in `# eslint version to install`, is not a rule configuration. A rule configuration always has a `:`, so a `#` comment without one before its justification is not a directive.
|
|
22
|
+
*/
|
|
23
|
+
const isInlineConfig = comment => INLINE_CONFIG.test(comment.value)
|
|
24
|
+
&& !(comment.type === 'Line' && RULE_CONFIG.test(comment.value) && !comment.value.split(JUSTIFICATION, 1)[0].includes(':'));
|
|
16
25
|
|
|
17
26
|
const isComment = item => item.type === 'Line' || item.type === 'Block';
|
|
18
27
|
|
|
28
|
+
/*
|
|
29
|
+
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.
|
|
30
|
+
*/
|
|
31
|
+
function mergeByStart(tokens, comments) {
|
|
32
|
+
const items = [];
|
|
33
|
+
let commentIndex = 0;
|
|
34
|
+
|
|
35
|
+
for (const token of tokens) {
|
|
36
|
+
while (commentIndex < comments.length && comments[commentIndex].range[0] < token.range[0]) {
|
|
37
|
+
items.push(comments[commentIndex++]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
items.push(token);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
while (commentIndex < comments.length) {
|
|
44
|
+
items.push(comments[commentIndex++]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return items;
|
|
48
|
+
}
|
|
49
|
+
|
|
19
50
|
/**
|
|
20
51
|
The source code of a SOML document, which rules get as `context.sourceCode`.
|
|
21
52
|
*/
|
|
@@ -36,10 +67,20 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
36
67
|
*/
|
|
37
68
|
#inlineConfigComments;
|
|
38
69
|
/**
|
|
70
|
+
Where each line starts, found once, when it is first needed.
|
|
71
|
+
|
|
72
|
+
@type {number[] | undefined}
|
|
73
|
+
*/
|
|
74
|
+
#lineStarts;
|
|
75
|
+
/**
|
|
39
76
|
@type {DocumentNode}
|
|
40
77
|
*/
|
|
41
78
|
ast;
|
|
42
79
|
visitorKeys = visitorKeys;
|
|
80
|
+
/**
|
|
81
|
+
Marks the source code as SOML, so a rule that works on several languages can tell, as `eslint-plugin-yml` and `eslint-plugin-toml` do with `isYAML` and `isTOML`.
|
|
82
|
+
*/
|
|
83
|
+
parserServices = {isSOML: true};
|
|
43
84
|
|
|
44
85
|
/**
|
|
45
86
|
@param {{text: string, ast: DocumentNode}} options
|
|
@@ -48,7 +89,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
48
89
|
// SOML has no carriage returns, so a line ends only at a line feed.
|
|
49
90
|
super({text, ast, lineEndingPattern: /\n/v});
|
|
50
91
|
this.ast = ast;
|
|
51
|
-
this.#items =
|
|
92
|
+
this.#items = mergeByStart(ast.tokens, ast.comments);
|
|
52
93
|
}
|
|
53
94
|
|
|
54
95
|
/*
|
|
@@ -91,6 +132,49 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
91
132
|
return null;
|
|
92
133
|
}
|
|
93
134
|
|
|
135
|
+
/**
|
|
136
|
+
The 1-based line and 0-based column of an offset.
|
|
137
|
+
|
|
138
|
+
This replaces the method of `TextSourceCodeBase`, which searches the rest of the text for a line feed again for every offset on the last line of a file with no line feed at the end, so a rule with many problems on that line takes quadratic time.
|
|
139
|
+
|
|
140
|
+
@param {number} index
|
|
141
|
+
@returns {{line: number, column: number}}
|
|
142
|
+
*/
|
|
143
|
+
getLocFromIndex(index) {
|
|
144
|
+
if (typeof index !== 'number') {
|
|
145
|
+
throw new TypeError('Expected `index` to be a number.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (index < 0 || index > this.text.length) {
|
|
149
|
+
throw new RangeError(`Index out of range (requested index ${index}, but source text has length ${this.text.length}).`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!this.#lineStarts) {
|
|
153
|
+
this.#lineStarts = [0];
|
|
154
|
+
|
|
155
|
+
for (let lineFeed = this.text.indexOf('\n'); lineFeed !== -1; lineFeed = this.text.indexOf('\n', lineFeed + 1)) {
|
|
156
|
+
this.#lineStarts.push(lineFeed + 1);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The last line that starts at or before `index`.
|
|
161
|
+
const lineStarts = this.#lineStarts;
|
|
162
|
+
let low = 0;
|
|
163
|
+
let high = lineStarts.length - 1;
|
|
164
|
+
|
|
165
|
+
while (low < high) {
|
|
166
|
+
const middle = Math.ceil((low + high) / 2);
|
|
167
|
+
|
|
168
|
+
if (lineStarts[middle] <= index) {
|
|
169
|
+
low = middle;
|
|
170
|
+
} else {
|
|
171
|
+
high = middle - 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {line: low + 1, column: index - lineStarts[low]};
|
|
176
|
+
}
|
|
177
|
+
|
|
94
178
|
/**
|
|
95
179
|
@returns {Iterable<VisitNodeStep>}
|
|
96
180
|
*/
|
|
@@ -140,6 +224,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
140
224
|
}
|
|
141
225
|
|
|
142
226
|
// TODO: Add a `comments` property that holds `this.ast.comments` when ESLint ships https://github.com/eslint/rfcs/pull/150. It adds `comments` to `SourceCode` and `TextSourceCodeBase`, deprecates `getAllComments()`, and makes `no-irregular-whitespace` work on any language that has `comments`. Then use `comments` in the rules, too.
|
|
227
|
+
|
|
143
228
|
/**
|
|
144
229
|
@returns {readonly Comment[]}
|
|
145
230
|
*/
|
|
@@ -151,7 +236,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
|
|
|
151
236
|
@returns {Comment[]}
|
|
152
237
|
*/
|
|
153
238
|
getInlineConfigNodes() {
|
|
154
|
-
this.#inlineConfigComments ??= this.ast.comments.filter(comment =>
|
|
239
|
+
this.#inlineConfigComments ??= this.ast.comments.filter(comment => isInlineConfig(comment));
|
|
155
240
|
return this.#inlineConfigComments;
|
|
156
241
|
}
|
|
157
242
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-soml",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "ESLint plugin for SOML, a config format for humans",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "github:soml-lang/eslint-soml",
|
|
@@ -51,21 +51,20 @@
|
|
|
51
51
|
"style"
|
|
52
52
|
],
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@eslint/core": "^1.2.1",
|
|
54
55
|
"@eslint/plugin-kit": "^0.7.3",
|
|
55
|
-
"
|
|
56
|
-
"soml-lang": "^0.0.3"
|
|
56
|
+
"soml-lang": "^0.0.5"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@babel/code-frame": "^8.0.6",
|
|
60
60
|
"@eslint-community/eslint-utils": "^4.10.1",
|
|
61
|
-
"@eslint/core": "^1.2.1",
|
|
62
61
|
"@typescript-eslint/types": "^8.71.1",
|
|
63
62
|
"enquirer": "^2.4.1",
|
|
64
63
|
"eslint": "^10.12.0",
|
|
65
64
|
"eslint-config-xo": "^5.0.0",
|
|
66
65
|
"eslint-doc-generator": "^3.7.1",
|
|
67
66
|
"eslint-plugin-eslint-plugin": "^7.6.2",
|
|
68
|
-
"eslint-plugin-jsdoc": "^65.2.
|
|
67
|
+
"eslint-plugin-jsdoc": "^65.2.4",
|
|
69
68
|
"indent-string": "^5.0.0",
|
|
70
69
|
"markdownlint-cli": "^0.49.1",
|
|
71
70
|
"nano-spawn": "^2.1.0",
|
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.*
|
|
@@ -14,7 +16,7 @@ npm install --save-dev eslint eslint-soml
|
|
|
14
16
|
|
|
15
17
|
**Requires Node.js 22.13 or later, ESLint `>=10.4`, [flat config](https://eslint.org/docs/latest/use/configure/configuration-files), and [ESM](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c#how-can-i-make-my-typescript-project-output-esm).**
|
|
16
18
|
|
|
17
|
-
The
|
|
19
|
+
The rules of this plugin do not need `Temporal`, so they work on every supported Node.js version, also for files with instants and durations. Only reading the `value` of an `Instant` or `Duration` node needs it, as in your own rule or in a `no-restricted-syntax` selector that matches on `value`, such as `[value=1]`. Node.js 26 and later have `Temporal` built in. On older versions, load a [`Temporal` polyfill](https://github.com/fullcalendar/temporal-polyfill) first: make `import 'temporal-polyfill/global';` the first import in `eslint.config.js`, before `eslint-soml`.
|
|
18
20
|
|
|
19
21
|
## Usage
|
|
20
22
|
|
|
@@ -59,9 +61,9 @@ Disable a rule with a comment, in either comment style:
|
|
|
59
61
|
'404': 'Not found' /* eslint-disable-line soml/prefer-bare-keys */
|
|
60
62
|
```
|
|
61
63
|
|
|
62
|
-
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`.
|
|
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`. Those rules follow JavaScript, where a line comment holds only `eslint-disable-line` and `eslint-disable-next-line`, so some of them, such as `no-unlimited-disable` and `no-use`, do not check `# eslint-disable` and `# eslint-enable`. Write those as `/* … */` comments to have them checked.
|
|
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
|
],
|
|
@@ -84,6 +86,54 @@ export default defineConfig([
|
|
|
84
86
|
]);
|
|
85
87
|
```
|
|
86
88
|
|
|
89
|
+
A selector can also check a value under a given key, which catches settings that are risky in production:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
'no-restricted-syntax': [
|
|
93
|
+
'error',
|
|
94
|
+
{
|
|
95
|
+
selector: 'Member:has(> Key[value=/^(allowed-origins|cors-origins?)$/]) String[value="*"]',
|
|
96
|
+
message: 'Allow only known origins.',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
selector: 'Member:has(> Key[value=/^(verify-ssl|ssl-verify|tls-verify)$/]) > Boolean[value=false]',
|
|
100
|
+
message: 'Do not turn off TLS verification.',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
selector: 'Member:has(> Key[value=/^(host|bind|listen)$/]) > String[value="0.0.0.0"]',
|
|
104
|
+
message: 'Bind to a specific address.',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
// Only at the top level.
|
|
108
|
+
selector: 'Document > Object > Member > Key[value="debug"]',
|
|
109
|
+
message: 'Set `debug` in the environment, not in the file.',
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### SOML in Markdown
|
|
115
|
+
|
|
116
|
+
To lint the `` ```soml `` code blocks in Markdown files, add the processor of [`@eslint/markdown`](https://github.com/eslint/markdown) before the preset. The blocks are linted as `.soml` files, and `--fix` writes the fixes back, also in an indented block:
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
import markdown from '@eslint/markdown';
|
|
120
|
+
import soml from 'eslint-soml';
|
|
121
|
+
import {defineConfig} from 'eslint/config';
|
|
122
|
+
|
|
123
|
+
export default defineConfig([
|
|
124
|
+
{
|
|
125
|
+
files: ['**/*.md'],
|
|
126
|
+
plugins: {
|
|
127
|
+
markdown,
|
|
128
|
+
},
|
|
129
|
+
processor: 'markdown/markdown',
|
|
130
|
+
},
|
|
131
|
+
soml.configs.recommended,
|
|
132
|
+
]);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A block that shows a mistake on purpose can be skipped with `<!-- eslint-skip -->` on the line before it.
|
|
136
|
+
|
|
87
137
|
## Rules
|
|
88
138
|
|
|
89
139
|
<!-- Do not manually modify this list. Run: `npm run fix:eslint-docs` -->
|
|
@@ -109,20 +159,18 @@ export default defineConfig([
|
|
|
109
159
|
| [no-escapes-in-paths](docs/rules/no-escapes-in-paths.md) | Disallow `\n` and `\t` escapes in Windows paths. | ✅ ☑️ | | 💡 |
|
|
110
160
|
| [no-float-trailing-zeros](docs/rules/no-float-trailing-zeros.md) | Disallow trailing zeros in the fraction of a float. | ✅ | | 💡 |
|
|
111
161
|
| [no-invisible-characters](docs/rules/no-invisible-characters.md) | Disallow invisible characters in strings, keys, and comments. | ✅ ☑️ | 🔧 | |
|
|
112
|
-
| [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats
|
|
162
|
+
| [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats whose written digits are lost when they are stored. | ✅ ☑️ | | |
|
|
113
163
|
| [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
164
|
| [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
|
|
115
165
|
| [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
166
|
| [no-placeholder-values](docs/rules/no-placeholder-values.md) | Disallow placeholder values, such as `'changeme'`. | ✅ | | |
|
|
118
167
|
| [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
168
|
| [no-sizes-as-durations](docs/rules/no-sizes-as-durations.md) | Disallow sizes that are durations, such as `memory: 512m`. | ✅ ☑️ | | 💡 |
|
|
121
169
|
| [no-unnormalized-keys](docs/rules/no-unnormalized-keys.md) | Disallow keys that are not in a Unicode normalization form. | ✅ ☑️ | | |
|
|
122
170
|
| [no-zero-duration-parts](docs/rules/no-zero-duration-parts.md) | Disallow duration parts that are zero. | ✅ | 🔧 | |
|
|
123
171
|
| [numeric-separators-style](docs/rules/numeric-separators-style.md) | Enforce the grouping of digits with `_` in decimal numbers. | ✅ | 🔧 | |
|
|
124
172
|
| [prefer-bare-keys](docs/rules/prefer-bare-keys.md) | Prefer bare keys when a key does not need quotes. | ✅ | 🔧 | |
|
|
125
|
-
| [prefer-literal-strings](docs/rules/prefer-literal-strings.md) | Prefer literal strings, `'…'`, when a string
|
|
173
|
+
| [prefer-literal-strings](docs/rules/prefer-literal-strings.md) | Prefer literal strings, `'…'`, when a string does not need escapes. | ✅ | 🔧 | |
|
|
126
174
|
| [prefer-utc-instants](docs/rules/prefer-utc-instants.md) | Prefer instants in UTC. | | 🔧 | |
|
|
127
175
|
| [sort-keys](docs/rules/sort-keys.md) | Require object keys to be sorted. | | 🔧 | |
|
|
128
176
|
| [spaced-comment](docs/rules/spaced-comment.md) | Require a space after `#` and `/*`, and before `*/`. | ✅ | 🔧 | |
|
|
@@ -201,6 +249,48 @@ In Visual Studio Code, the [ESLint extension](https://marketplace.visualstudio.c
|
|
|
201
249
|
}
|
|
202
250
|
```
|
|
203
251
|
|
|
252
|
+
## Writing rules
|
|
253
|
+
|
|
254
|
+
A rule for SOML files gets the syntax tree of [`soml-lang`](https://github.com/soml-lang/soml-javascript#parsetreetext), which documents every node. This package exports the types of the nodes, and `SomlRuleDefinition`, `SomlRuleVisitor`, `SomlSourceCode`, and `SomlRuleContext` (for a helper that a rule passes its `context` to) for a rule:
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
/**
|
|
258
|
+
@type {import('eslint-soml').SomlRuleDefinition}
|
|
259
|
+
*/
|
|
260
|
+
const rule = {
|
|
261
|
+
meta: {
|
|
262
|
+
type: 'suggestion',
|
|
263
|
+
messages: {
|
|
264
|
+
tooLong: 'The key `{{key}}` is longer than 40 characters.',
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
create(context) {
|
|
268
|
+
return {
|
|
269
|
+
Key(node) {
|
|
270
|
+
if (node.value.length > 40) {
|
|
271
|
+
context.report({node, messageId: 'tooLong', data: {key: node.value}});
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The nodes hold decoded values, and the source text keeps the spelling:
|
|
280
|
+
|
|
281
|
+
- `Key` and `String` have the decoded `value`, so `a`, `'a'`, and `"\u{61}"` all have the value `a`. Their `style` is how they are written: `'bare'` (a key only), `'literal'` for `'…'`, or `'escaped'` for `"…"`. A `String` also has `block`, which is `true` for `'''` and `"""`.
|
|
282
|
+
- `Integer` has a `bigint` `value` and its `radix`, so `0xFF` and `255` have the same value. `Float` and `Boolean` have a `value`, and `Null` has none.
|
|
283
|
+
- `context.sourceCode.getText(node)` gives the source text, such as `0xFF` or `"\u{61}"`, for a rule about spelling.
|
|
284
|
+
|
|
285
|
+
`context.sourceCode` has the methods of `TextSourceCodeBase` from `@eslint/plugin-kit`, such as `getText()`, `getLoc()`, `getRange()`, and `getLocFromIndex()`, and these:
|
|
286
|
+
|
|
287
|
+
- `getParent(node)` returns the parent of a node, or `undefined` for the `Document` node. Nodes have no `parent` property. A listener also gets the parent as its second argument.
|
|
288
|
+
- `getAllComments()` returns every comment, in source order.
|
|
289
|
+
- `getTokenBefore()`, `getTokenAfter()`, `getFirstToken()`, `getLastToken()`, and `getTokens()` find tokens in source order. They take only the `includeComments` option, and return `null` when there is no token, like ESLint's own `SourceCode`.
|
|
290
|
+
- `parserServices.isSOML` is `true`, so a rule that works on several languages can tell a SOML file, with `context.sourceCode.parserServices?.isSOML`.
|
|
291
|
+
|
|
292
|
+
Reading the `value` of an `Instant` or a `Duration` node needs `Temporal`, and so does spreading such a node. Check the type of a node before you read its `value`, and use `parts` or the source text of a duration when you can.
|
|
293
|
+
|
|
204
294
|
## Related
|
|
205
295
|
|
|
206
296
|
- [soml-javascript](https://github.com/soml-lang/soml-javascript) - The reference parser and serializer for SOML
|
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'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {trimEnd} from './utils/index.js';
|
|
1
|
+
import {getLines, trimEnd} from './utils/index.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -23,15 +23,11 @@ const create = context => {
|
|
|
23
23
|
return;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const lines =
|
|
26
|
+
const lines = getLines(context, node);
|
|
27
27
|
// The first line holds the opening delimiter, and the last line the closing one.
|
|
28
|
-
const content = lines.slice(1, -1);
|
|
28
|
+
const content = lines.slice(1, -1).map(line => line.text);
|
|
29
29
|
// Where each content line starts, and then where the closing line starts.
|
|
30
|
-
const lineStarts =
|
|
31
|
-
|
|
32
|
-
for (const line of content) {
|
|
33
|
-
lineStarts.push(lineStarts.at(-1) + line.length + 1);
|
|
34
|
-
}
|
|
30
|
+
const lineStarts = lines.slice(1).map(line => line.start);
|
|
35
31
|
|
|
36
32
|
const firstContent = content.findIndex(line => !isBlank(line));
|
|
37
33
|
// With no content, every line is a leading blank line.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {trimEnd} from './utils/index.js';
|
|
1
|
+
import {getLines, trimEnd} from './utils/index.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
@import {SomlContext} from './rule/soml-context.js';
|
|
@@ -21,25 +21,20 @@ const create = context => {
|
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const lines = sourceCode.getText(node).split('\n');
|
|
25
|
-
let offset = sourceCode.getRange(node)[0] + lines[0].length + 1;
|
|
26
|
-
|
|
27
24
|
// The first line holds the opening delimiter, and the last line the closing one.
|
|
28
|
-
for (const
|
|
29
|
-
const content = trimEnd(
|
|
25
|
+
for (const {text, start} of getLines(context, node).slice(1, -1)) {
|
|
26
|
+
const content = trimEnd(text, ' \t');
|
|
30
27
|
|
|
31
28
|
// A blank line becomes an empty line, so its whitespace is not part of the value.
|
|
32
|
-
if (content !== '' && content.length <
|
|
29
|
+
if (content !== '' && content.length < text.length) {
|
|
33
30
|
yield {
|
|
34
31
|
loc: {
|
|
35
|
-
start: sourceCode.getLocFromIndex(
|
|
36
|
-
end: sourceCode.getLocFromIndex(
|
|
32
|
+
start: sourceCode.getLocFromIndex(start + content.length),
|
|
33
|
+
end: sourceCode.getLocFromIndex(start + text.length),
|
|
37
34
|
},
|
|
38
35
|
messageId: MESSAGE_ID,
|
|
39
36
|
};
|
|
40
37
|
}
|
|
41
|
-
|
|
42
|
-
offset += line.length + 1;
|
|
43
38
|
}
|
|
44
39
|
});
|
|
45
40
|
};
|