eslint-soml 0.0.3 → 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 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,
@@ -22,6 +46,181 @@ export type {
22
46
  SourceLocation,
23
47
  } from 'soml-lang';
24
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
+
25
224
  declare const eslintSoml: ESLint.Plugin & {
26
225
  configs: {
27
226
  recommended: Linter.Config;
@@ -13,6 +13,15 @@ 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
 
@@ -58,10 +67,20 @@ export default class SomlSourceCode extends TextSourceCodeBase {
58
67
  */
59
68
  #inlineConfigComments;
60
69
  /**
70
+ Where each line starts, found once, when it is first needed.
71
+
72
+ @type {number[] | undefined}
73
+ */
74
+ #lineStarts;
75
+ /**
61
76
  @type {DocumentNode}
62
77
  */
63
78
  ast;
64
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};
65
84
 
66
85
  /**
67
86
  @param {{text: string, ast: DocumentNode}} options
@@ -113,6 +132,49 @@ export default class SomlSourceCode extends TextSourceCodeBase {
113
132
  return null;
114
133
  }
115
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
+
116
178
  /**
117
179
  @returns {Iterable<VisitNodeStep>}
118
180
  */
@@ -162,6 +224,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
162
224
  }
163
225
 
164
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
+
165
228
  /**
166
229
  @returns {readonly Comment[]}
167
230
  */
@@ -173,7 +236,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
173
236
  @returns {Comment[]}
174
237
  */
175
238
  getInlineConfigNodes() {
176
- this.#inlineConfigComments ??= this.ast.comments.filter(comment => INLINE_CONFIG.test(comment.value));
239
+ this.#inlineConfigComments ??= this.ast.comments.filter(comment => isInlineConfig(comment));
177
240
  return this.#inlineConfigComments;
178
241
  }
179
242
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-soml",
3
- "version": "0.0.3",
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,20 +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
- "soml-lang": "^0.0.4"
56
+ "soml-lang": "^0.0.5"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@babel/code-frame": "^8.0.6",
59
60
  "@eslint-community/eslint-utils": "^4.10.1",
60
- "@eslint/core": "^1.2.1",
61
61
  "@typescript-eslint/types": "^8.71.1",
62
62
  "enquirer": "^2.4.1",
63
63
  "eslint": "^10.12.0",
64
64
  "eslint-config-xo": "^5.0.0",
65
65
  "eslint-doc-generator": "^3.7.1",
66
66
  "eslint-plugin-eslint-plugin": "^7.6.2",
67
- "eslint-plugin-jsdoc": "^65.2.1",
67
+ "eslint-plugin-jsdoc": "^65.2.4",
68
68
  "indent-string": "^5.0.0",
69
69
  "markdownlint-cli": "^0.49.1",
70
70
  "nano-spawn": "^2.1.0",
package/readme.md CHANGED
@@ -16,7 +16,7 @@ npm install --save-dev eslint eslint-soml
16
16
 
17
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).**
18
18
 
19
- The parser needs `Temporal` for instants and durations, and Node.js 26 and later have it built in. On older versions, linting a file with an instant or a duration fails, unless a [`Temporal` polyfill](https://github.com/fullcalendar/temporal-polyfill) is loaded first. Make `import 'temporal-polyfill/global';` the first import in `eslint.config.js`, before `eslint-soml`.
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`.
20
20
 
21
21
  ## Usage
22
22
 
@@ -61,7 +61,7 @@ Disable a rule with a comment, in either comment style:
61
61
  '404': 'Not found' /* eslint-disable-line soml/prefer-bare-keys */
62
62
  ```
63
63
 
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`.
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.
65
65
 
66
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`:
67
67
 
@@ -86,6 +86,54 @@ export default defineConfig([
86
86
  ]);
87
87
  ```
88
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
+
89
137
  ## Rules
90
138
 
91
139
  <!-- Do not manually modify this list. Run: `npm run fix:eslint-docs` -->
@@ -111,7 +159,7 @@ export default defineConfig([
111
159
  | [no-escapes-in-paths](docs/rules/no-escapes-in-paths.md) | Disallow `\n` and `\t` escapes in Windows paths. | ✅ ☑️ | | 💡 |
112
160
  | [no-float-trailing-zeros](docs/rules/no-float-trailing-zeros.md) | Disallow trailing zeros in the fraction of a float. | ✅ | | 💡 |
113
161
  | [no-invisible-characters](docs/rules/no-invisible-characters.md) | Disallow invisible characters in strings, keys, and comments. | ✅ ☑️ | 🔧 | |
114
- | [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats that cannot be stored exactly as written. | ✅ ☑️ | | |
162
+ | [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats whose written digits are lost when they are stored. | ✅ ☑️ | | |
115
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. | ✅ ☑️ | | |
116
164
  | [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
117
165
  | [no-negative-zero](docs/rules/no-negative-zero.md) | Disallow a sign on zero. | ✅ | 🔧 | |
@@ -122,7 +170,7 @@ export default defineConfig([
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 has no escapes. | ✅ | 🔧 | |
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
@@ -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 = sourceCode.getText(node).split('\n');
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 = [sourceCode.getRange(node)[0] + lines[0].length + 1];
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 line of lines.slice(1, -1)) {
29
- const content = trimEnd(line, ' \t');
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 < line.length) {
29
+ if (content !== '' && content.length < text.length) {
33
30
  yield {
34
31
  loc: {
35
- start: sourceCode.getLocFromIndex(offset + content.length),
36
- end: sourceCode.getLocFromIndex(offset + line.length),
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
  };
@@ -1,5 +1,5 @@
1
1
  import {stringifyValue} from 'soml-lang';
2
- import {hasAdjacentComment, isPunctuator} from './utils/index.js';
2
+ import {escapeInvisibleCharacters, hasAdjacentComment, isPunctuator} from './utils/index.js';
3
3
 
4
4
  /**
5
5
  @import {SomlContext} from './rule/soml-context.js';
@@ -72,8 +72,8 @@ const create = context => {
72
72
  node: item,
73
73
  messageId: MESSAGE_ID_ERROR,
74
74
  data: {
75
- // A block string spans lines, so it is shown as a one-line string with the same value.
76
- item: item.block ? stringifyValue(item.value) : sourceCode.getText(item),
75
+ // 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),
77
77
  line: sourceCode.getLoc(first).start.line,
78
78
  },
79
79
  // Removing an item changes the value, so it is a suggestion. It is left out when it would remove a comment.
@@ -81,7 +81,7 @@ const config = {
81
81
  meta: {
82
82
  type: 'problem',
83
83
  docs: {
84
- description: 'Disallow floats that cannot be stored exactly as written.',
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 {trimEnd} from './utils/index.js';
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,50 +60,24 @@ const create = context => {
33
60
  return;
34
61
  }
35
62
 
36
- const lines = sourceCode.getText(node).split('\n');
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 line of lines.slice(1, -1)) {
43
- // A run of exactly the delimiter's length at the end of a line, as TOML and Java allow, optionally followed by a comma.
44
- let content = trimEnd(line, ' \t');
45
-
46
- if (content.endsWith(',')) {
47
- content = trimEnd(content.slice(0, -1), ' \t');
48
- }
49
-
50
- // Where the run of quotes at the end starts.
51
- let index = content.length;
52
-
53
- while (index > 0 && content[index - 1] === delimiter[0]) {
54
- index--;
55
- }
56
-
57
- // In a `"""` block, `\"` is an escaped quote, so it is not part of the run.
58
- if (node.style === 'escaped' && content.length - index >= delimiter.length && ESCAPING_BACKSLASHES.test(content.slice(0, index))) {
59
- index++;
60
- }
61
-
62
- if (
63
- index > 0
64
- && content.length - index === delimiter.length
65
- && !isClosingQuote(content, index, delimiter)
66
- ) {
67
- const start = offset + index;
68
+ for (const {text, start} of lines.slice(1, -1)) {
69
+ const index = getMisplacedDelimiterIndex(text, delimiter, node.style === 'escaped');
68
70
 
71
+ if (index !== undefined) {
69
72
  return {
70
73
  loc: {
71
- start: sourceCode.getLocFromIndex(start),
72
- end: sourceCode.getLocFromIndex(start + delimiter.length),
74
+ start: sourceCode.getLocFromIndex(start + index),
75
+ end: sourceCode.getLocFromIndex(start + index + delimiter.length),
73
76
  },
74
77
  messageId: MESSAGE_ID,
75
78
  data: {delimiter},
76
79
  };
77
80
  }
78
-
79
- offset += line.length + 1;
80
81
  }
81
82
  });
82
83
  };
@@ -66,7 +66,7 @@ const config = {
66
66
  meta: {
67
67
  type: 'suggestion',
68
68
  docs: {
69
- description: 'Prefer literal strings, `\'…\'`, when a string has no escapes.',
69
+ description: 'Prefer literal strings, `\'…\'`, when a string does not need escapes.',
70
70
  recommended: true,
71
71
  },
72
72
  fixable: 'code',
@@ -31,6 +31,7 @@ Convert a problem from a listener to an ESLint problem. A suggestion gets the `d
31
31
  */
32
32
  export default function toEslintProblem(somlProblem) {
33
33
  const {fix, suggest, ...problem} = somlProblem;
34
+
34
35
  /**
35
36
  @type {EslintProblem}
36
37
  */
@@ -59,10 +59,9 @@ function compareNatural(first, second) {
59
59
  return firstCodePoint - secondCodePoint;
60
60
  }
61
61
 
62
- // The two code points are equal, so they have the same length.
63
- const length = firstCodePoint > 0xFF_FF ? 2 : 1;
64
- firstIndex += length;
65
- secondIndex += length;
62
+ // The two code points are equal, so a surrogate pair is followed by the same low surrogate in both.
63
+ firstIndex++;
64
+ secondIndex++;
66
65
  }
67
66
 
68
67
  // A key that ends first comes first, as `a` before `a1`.
@@ -94,6 +93,9 @@ const create = context => {
94
93
  return commentLines;
95
94
  };
96
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
+
97
99
  /**
98
100
  Whether a blank line separates two members. A line in a comment is not blank.
99
101
 
@@ -152,7 +154,7 @@ const create = context => {
152
154
  sortName: natural ? 'natural' : 'code point',
153
155
  },
154
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.
155
- fix: isFirst && !isDisabledAnywhere(context) ? 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,
156
158
  };
157
159
 
158
160
  isFirst = false;
@@ -1,11 +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`. An acronym is a word too, so `maxRAMSize` gives `max`, `ram`, and `size`. Every character that is not a letter or a digit separates words, so a quoted key works too, as in `'spark.executor.memory'` (Spark) or `'php_admin_value[memory_limit]'` (PHP-FPM). A separator at an end or next to another one makes no empty word, so `_size` is one word.
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(/[^\p{Letter}\p{Number}]|(?<=[\da-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/v)
8
+ return key.split(/[^\p{Letter}\p{Number}]|(?<=[\da-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z]{2})/v)
9
9
  .filter(word => word !== '')
10
10
  .map(word => word.toLowerCase());
11
11
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ @import {SomlContext} from '../rule/soml-context.js';
3
+ @import {Node} from 'soml-lang';
4
+ */
5
+
6
+ /**
7
+ The lines of the source text of `node`, each with the offset where it starts. In a block string, the first line holds the opening delimiter, and the last line the closing one.
8
+
9
+ @param {SomlContext} context
10
+ @param {Node} node
11
+ @returns {Array<{text: string, start: number}>}
12
+ */
13
+ export default function getLines(context, node) {
14
+ const {sourceCode} = context;
15
+ const lines = [];
16
+ let [start] = sourceCode.getRange(node);
17
+
18
+ for (const text of sourceCode.getText(node).split('\n')) {
19
+ lines.push({text, start});
20
+ start += text.length + 1;
21
+ }
22
+
23
+ return lines;
24
+ }
@@ -9,6 +9,7 @@ export {reorderMembers, hasAdjacentComment} from './reorder-members.js';
9
9
  export {default as trimWhitespace} from './trim-whitespace.js';
10
10
  export {isComment, isPunctuator} from './token.js';
11
11
  export {default as trimEnd} from './trim-end.js';
12
+ export {default as getLines} from './get-lines.js';
12
13
  export {default as hasDoubledBackslashes} from './has-doubled-backslashes.js';
13
14
  export {default as getKeyWords} from './get-key-words.js';
14
15
  export {default as isDisabledAnywhere} from './is-disabled-anywhere.js';
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  Characters that a reader cannot see, or cannot tell apart from a space: bidirectional controls (as in Trojan Source), zero-width characters (including the invisible math operators U+2061 to U+2064), the Hangul fillers, which show as blank, other invisible format characters (U+180E and U+206A to U+206F), the byte order mark, the soft hyphen, control characters, and every Unicode whitespace character except space, tab, and line feed. Zero-width joiners and non-joiners are left out, because emoji sequences and several scripts need them.
3
3
 
4
- Tag characters are invisible too, and can hide text, as in ASCII smuggling. They are left out only in an emoji tag sequence, which is U+1F3F4 and then tags up to U+E007F, as in the flag of Scotland. Such a sequence is short, so the lookbehind is bounded.
4
+ Tag characters are invisible too, and can hide text, as in ASCII smuggling. They are left out only in an emoji tag sequence, which is U+1F3F4, at most six tags, and U+E007F, as in the flag of Scotland. So a tag is reported when more than five tags or no U+1F3F4 come before it, or more than five tags or no U+E007F come after it.
5
5
  */
6
6
  const characters = /[[\p{Control}\p{White_Space}\u{AD}\u{61C}\u{115F}\u{1160}\u{180E}\u{200B}\u{200E}\u{200F}\u{202A}-\u{202E}\u{2060}-\u{2064}\u{2066}-\u{206F}\u{3164}\u{FEFF}\u{FFA0}]--[\t\n ]]/v;
7
- const tags = /(?<!\u{1F3F4}[\u{E0020}-\u{E007E}]{0,32})[\u{E0001}\u{E0020}-\u{E007F}]/v;
7
+ const tagSpec = String.raw`[\u{E0020}-\u{E007E}]`;
8
+ const tags = new RegExp(String.raw`\u{E0001}|(?<!\u{1F3F4}${tagSpec}{0,5})${tagSpec}|${tagSpec}(?!${tagSpec}{0,5}\u{E007F})|(?<!\u{1F3F4}${tagSpec}{1,6})\u{E007F}`, 'v');
8
9
  const invisibleCharacter = new RegExp(`${characters.source}|${tags.source}`, 'v');
9
10
 
10
11
  /**
@@ -9,7 +9,7 @@ const commentParser = new ConfigCommentParser();
9
9
  /**
10
10
  Whether a disable directive in the document turns off the rule of `context` anywhere, as `# eslint-disable-next-line soml/format` does, or as one without rule names does.
11
11
 
12
- A rule whose one fix changes the whole document or a whole object leaves out that fix then. ESLint applies a fix when the problem that carries it is reported, even when the other problems are turned off, so the fix would change the lines that the directive keeps, and ESLint would then remove the directive as unused.
12
+ A rule whose one fix changes the whole document or a whole object leaves out that fix then. ESLint applies a fix when the problem that carries it is reported, even when the other problems are turned off, so the fix would change the lines that the directive keeps, and ESLint would then remove the directive as unused. A rule cannot see the `noInlineConfig` linter option, so a directive that the option turns off still counts.
13
13
 
14
14
  @param {SomlContext} context
15
15
  @returns {boolean}