eslint-soml 0.0.4 → 0.0.5

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
@@ -66,7 +66,7 @@ type TokenOptionsWithComments = TokenOptions & {readonly includeComments: true};
66
66
  /**
67
67
  The source code of a SOML document, which a rule gets as `context.sourceCode`.
68
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.
69
+ It has the methods of `TextSourceCodeBase` from `@eslint/plugin-kit`, such as `getText()`, `getLoc()`, `getRange()`, and `getLocFromIndex()`, and the methods below. Token methods find tokens in source order and take only the `includeComments` option. Single-token methods return `null` when there is no token; `getTokens()` returns an array.
70
70
 
71
71
  @example
72
72
  ```
@@ -110,6 +110,16 @@ export interface SomlSourceCode extends TextSourceCodeBase<{
110
110
  */
111
111
  getParent(node: Node): Node | undefined;
112
112
 
113
+ /**
114
+ Every ancestor node, from the root to the parent. Tokens and comments have no ancestors.
115
+
116
+ @example
117
+ ```
118
+ const parents = context.sourceCode.getAncestors(node).map(ancestor => context.sourceCode.getParent(ancestor));
119
+ ```
120
+ */
121
+ getAncestors(node: SomlSyntaxElement): Node[];
122
+
113
123
  /**
114
124
  Every comment, in source order.
115
125
  */
@@ -158,7 +168,7 @@ type CollectionParent = DocumentNode | ValueParent;
158
168
  The listeners that a rule for SOML returns, with the type of each node and of its parent.
159
169
  */
160
170
  export interface SomlRuleVisitor extends RuleVisitor, CustomRuleVisitorWithExit<{
161
- Document?(node: DocumentNode): void;
171
+ Document?(node: DocumentNode, parent: undefined): void;
162
172
  Object?(node: ObjectNode, parent: CollectionParent): void;
163
173
  Member?(node: MemberNode, parent: ObjectNode): void;
164
174
  Key?(node: KeyNode, parent: MemberNode): void;
package/index.js CHANGED
@@ -28,6 +28,7 @@ const soml = {
28
28
  meta: {
29
29
  // `eslint-doc-generator` derives the rule prefix from this name, expecting either the `eslint-plugin-<prefix>` convention or the prefix itself. The package name does not follow that convention, so this is the prefix.
30
30
  name: 'soml',
31
+ namespace: 'soml',
31
32
  version: packageJson.version,
32
33
  },
33
34
  languages: {
@@ -132,6 +132,23 @@ export default class SomlSourceCode extends TextSourceCodeBase {
132
132
  return null;
133
133
  }
134
134
 
135
+ /**
136
+ The offsets where each line starts, shared by both location conversions.
137
+
138
+ @returns {number[]}
139
+ */
140
+ #getLineStarts() {
141
+ if (!this.#lineStarts) {
142
+ this.#lineStarts = [0];
143
+
144
+ for (let lineFeed = this.text.indexOf('\n'); lineFeed !== -1; lineFeed = this.text.indexOf('\n', lineFeed + 1)) {
145
+ this.#lineStarts.push(lineFeed + 1);
146
+ }
147
+ }
148
+
149
+ return this.#lineStarts;
150
+ }
151
+
135
152
  /**
136
153
  The 1-based line and 0-based column of an offset.
137
154
 
@@ -149,16 +166,8 @@ export default class SomlSourceCode extends TextSourceCodeBase {
149
166
  throw new RangeError(`Index out of range (requested index ${index}, but source text has length ${this.text.length}).`);
150
167
  }
151
168
 
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
169
  // The last line that starts at or before `index`.
161
- const lineStarts = this.#lineStarts;
170
+ const lineStarts = this.#getLineStarts();
162
171
  let low = 0;
163
172
  let high = lineStarts.length - 1;
164
173
 
@@ -175,6 +184,34 @@ export default class SomlSourceCode extends TextSourceCodeBase {
175
184
  return {line: low + 1, column: index - lineStarts[low]};
176
185
  }
177
186
 
187
+ /**
188
+ The offset of a 1-based line and 0-based column. Use the cached line starts so repeated locations on the last line do not search for a line feed again.
189
+
190
+ @param {{line: number, column: number}} location
191
+ @returns {number}
192
+ */
193
+ getIndexFromLoc(location) {
194
+ if (location === null || typeof location !== 'object' || typeof location.line !== 'number' || typeof location.column !== 'number') {
195
+ throw new TypeError('Expected `loc` to be an object with numeric `line` and `column` properties.');
196
+ }
197
+
198
+ const lineStarts = this.#getLineStarts();
199
+ if (location.line < 1 || location.line > lineStarts.length) {
200
+ throw new RangeError(`Line number out of range (line ${location.line} requested). Valid range: 1-${lineStarts.length}`);
201
+ }
202
+
203
+ const lineStartIndex = lineStarts[location.line - 1];
204
+ const isLastLine = location.line === lineStarts.length;
205
+ const lineEndIndex = isLastLine ? this.text.length : lineStarts[location.line];
206
+ const positionIndex = lineStartIndex + location.column;
207
+
208
+ if (location.column < 0 || (isLastLine ? positionIndex > lineEndIndex : positionIndex >= lineEndIndex)) {
209
+ throw new RangeError(`Column number out of range (column ${location.column} requested). Valid range for line ${location.line}: 0-${lineEndIndex - lineStartIndex + (isLastLine ? 0 : -1)}`);
210
+ }
211
+
212
+ return positionIndex;
213
+ }
214
+
178
215
  /**
179
216
  @returns {Iterable<VisitNodeStep>}
180
217
  */
@@ -223,7 +260,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
223
260
  return this.#parents.get(node);
224
261
  }
225
262
 
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.
263
+ // When ESLint ships https://github.com/eslint/rfcs/pull/150, add a `comments` property holding `this.ast.comments` and use it in the rules. The new API deprecates `getAllComments()` and enables `no-irregular-whitespace` across languages.
227
264
 
228
265
  /**
229
266
  @returns {readonly Comment[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-soml",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "ESLint plugin for SOML, a config format for humans",
5
5
  "license": "MIT",
6
6
  "repository": "github:soml-lang/eslint-soml",
@@ -53,20 +53,20 @@
53
53
  "dependencies": {
54
54
  "@eslint/core": "^1.2.1",
55
55
  "@eslint/plugin-kit": "^0.7.3",
56
- "soml-lang": "^0.0.5"
56
+ "soml-lang": "^0.0.7"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@babel/code-frame": "^8.0.6",
60
60
  "@eslint-community/eslint-utils": "^4.10.1",
61
- "@typescript-eslint/types": "^8.71.1",
61
+ "@typescript-eslint/types": "^8.71.2",
62
62
  "enquirer": "^2.4.1",
63
- "eslint": "^10.12.0",
64
- "eslint-config-xo": "^5.0.0",
63
+ "eslint": "^10.13.0",
64
+ "eslint-config-xo": "^7.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.4",
67
+ "eslint-plugin-jsdoc": "^65.3.11",
68
68
  "indent-string": "^5.0.0",
69
- "markdownlint-cli": "^0.49.1",
69
+ "markdownlint-cli": "^0.50.0",
70
70
  "nano-spawn": "^2.1.0",
71
71
  "node-style-text": "^2.1.2",
72
72
  "npm-run-all2": "^9.0.3",
package/readme.md CHANGED
@@ -111,6 +111,38 @@ A selector can also check a value under a given key, which catches settings that
111
111
  ],
112
112
  ```
113
113
 
114
+ A selector can also say what the program that reads a file does not support. SOML allows each of these values, so use only the ones your program needs:
115
+
116
+ ```js
117
+ 'no-restricted-syntax': [
118
+ 'error',
119
+ {
120
+ // Such as a program that reads ints as JavaScript numbers.
121
+ selector: ':matches(Integer[value>9007199254740991], Integer[value<-9007199254740991])',
122
+ message: 'This program needs ints in the JavaScript safe integer range.',
123
+ },
124
+ {
125
+ // Such as a program that writes the value as JSON, where `infinity` becomes `null`.
126
+ selector: ':matches(Float[value="Infinity"], Float[value="-Infinity"])',
127
+ message: 'This program needs finite numbers.',
128
+ },
129
+ {
130
+ // Such as a program that uses the value as a file name.
131
+ selector: ':matches(String, Key)[value=/\\u0000/]',
132
+ message: 'This program does not support the NUL character.',
133
+ },
134
+ {
135
+ // Such as a program that merges the value into an object with `Object.assign()`.
136
+ selector: 'Key[value="__proto__"]',
137
+ message: 'This program does not support the key `__proto__`.',
138
+ },
139
+ {
140
+ selector: ':matches(Instant, Duration)',
141
+ message: 'This program does not support instants and durations.',
142
+ },
143
+ ],
144
+ ```
145
+
114
146
  ### SOML in Markdown
115
147
 
116
148
  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:
@@ -145,35 +177,40 @@ A block that shows a mistake on purpose can be skipped with `<!-- eslint-skip --
145
177
  🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix).\
146
178
  💡 Manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
147
179
 
148
- | Name                                      | Description | 💼 | 🔧 | 💡 |
149
- | :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------ | :--- | :- | :- |
150
- | [format](docs/rules/format.md) | Enforce the layout of the SOML formatter. | ✅ | 🔧 | |
151
- | [key-name-casing](docs/rules/key-name-casing.md) | Enforce a casing style for keys. | | | |
152
- | [no-block-string-edge-blank-lines](docs/rules/no-block-string-edge-blank-lines.md) | Disallow blank lines at the start and end of block strings. | ✅ | 🔧 | |
153
- | [no-block-string-trailing-whitespace](docs/rules/no-block-string-trailing-whitespace.md) | Disallow trailing whitespace in block strings. | ✅ ☑️ | | |
154
- | [no-confusable-keys](docs/rules/no-confusable-keys.md) | Disallow keys that differ only in case, `-` and `_`, or surrounding whitespace. | ✅ ☑️ | | |
155
- | [no-decimal-file-modes](docs/rules/no-decimal-file-modes.md) | Disallow file modes written as decimal numbers. | ✅ ☑️ | | 💡 |
156
- | [no-doubled-backslashes-in-literal-strings](docs/rules/no-doubled-backslashes-in-literal-strings.md) | Disallow doubled backslashes in literal strings, such as `'^\\d+$'`. | ✅ | | 💡 |
157
- | [no-duplicate-array-items](docs/rules/no-duplicate-array-items.md) | Disallow the same string twice in an array. | | | 💡 |
158
- | [no-empty-keys](docs/rules/no-empty-keys.md) | Disallow empty and whitespace-only keys. | ✅ ☑️ | | |
159
- | [no-escapes-in-paths](docs/rules/no-escapes-in-paths.md) | Disallow `\n` and `\t` escapes in Windows paths. | ✅ ☑️ | | 💡 |
160
- | [no-float-trailing-zeros](docs/rules/no-float-trailing-zeros.md) | Disallow trailing zeros in the fraction of a float. | ✅ | | 💡 |
161
- | [no-invisible-characters](docs/rules/no-invisible-characters.md) | Disallow invisible characters in strings, keys, and comments. | ✅ ☑️ | 🔧 | |
162
- | [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats whose written digits are lost when they are stored. | ✅ ☑️ | | |
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. | ✅ ☑️ | | |
164
- | [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
165
- | [no-negative-zero](docs/rules/no-negative-zero.md) | Disallow a sign on zero. | ✅ | 🔧 | |
166
- | [no-placeholder-values](docs/rules/no-placeholder-values.md) | Disallow placeholder values, such as `'changeme'`. | ✅ | | |
167
- | [no-quoted-keywords](docs/rules/no-quoted-keywords.md) | Disallow the strings `'true'`, `'false'`, and `'null'`. | ✅ | | 💡 |
168
- | [no-sizes-as-durations](docs/rules/no-sizes-as-durations.md) | Disallow sizes that are durations, such as `memory: 512m`. | ✅ ☑️ | | 💡 |
169
- | [no-unnormalized-keys](docs/rules/no-unnormalized-keys.md) | Disallow keys that are not in a Unicode normalization form. | ✅ ☑️ | | |
170
- | [no-zero-duration-parts](docs/rules/no-zero-duration-parts.md) | Disallow duration parts that are zero. | ✅ | 🔧 | |
171
- | [numeric-separators-style](docs/rules/numeric-separators-style.md) | Enforce the grouping of digits with `_` in decimal numbers. | ✅ | 🔧 | |
172
- | [prefer-bare-keys](docs/rules/prefer-bare-keys.md) | Prefer bare keys when a key does not need quotes. | ✅ | 🔧 | |
173
- | [prefer-literal-strings](docs/rules/prefer-literal-strings.md) | Prefer literal strings, `'…'`, when a string does not need escapes. | ✅ | 🔧 | |
174
- | [prefer-utc-instants](docs/rules/prefer-utc-instants.md) | Prefer instants in UTC. | | 🔧 | |
175
- | [sort-keys](docs/rules/sort-keys.md) | Require object keys to be sorted. | | 🔧 | |
176
- | [spaced-comment](docs/rules/spaced-comment.md) | Require a space after `#` and `/*`, and before `*/`. | ✅ | 🔧 | |
180
+ | Name                                      | Description | 💼 | 🔧 | 💡 |
181
+ | :--------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------ | :--- | :- | :- |
182
+ | [format](docs/rules/format.md) | Enforce the layout of the SOML formatter. | ✅ | 🔧 | |
183
+ | [key-name-casing](docs/rules/key-name-casing.md) | Enforce a casing style for keys. | | | |
184
+ | [no-block-string-edge-blank-lines](docs/rules/no-block-string-edge-blank-lines.md) | Disallow blank lines at the start and end of block strings. | ✅ | 🔧 | |
185
+ | [no-block-string-trailing-whitespace](docs/rules/no-block-string-trailing-whitespace.md) | Disallow trailing whitespace in block strings. | ✅ ☑️ | | |
186
+ | [no-confusable-keys](docs/rules/no-confusable-keys.md) | Disallow keys that differ only in case, `-`, `_`, and `.`, or surrounding whitespace. | ✅ ☑️ | | |
187
+ | [no-decimal-file-modes](docs/rules/no-decimal-file-modes.md) | Disallow file modes written as decimal numbers. | ✅ ☑️ | | 💡 |
188
+ | [no-dotted-key-collisions](docs/rules/no-dotted-key-collisions.md) | Disallow quoted keys with a `.` that read as a path into an object next to them. | ✅ | | |
189
+ | [no-doubled-backslashes-in-literal-strings](docs/rules/no-doubled-backslashes-in-literal-strings.md) | Disallow doubled backslashes in literal strings, such as `'^\\d+$'`. | ✅ | | 💡 |
190
+ | [no-duplicate-array-items](docs/rules/no-duplicate-array-items.md) | Disallow the same string twice in an array. | | | 💡 |
191
+ | [no-empty-keys](docs/rules/no-empty-keys.md) | Disallow empty and whitespace-only keys. | ✅ ☑️ | | |
192
+ | [no-escapes-in-literal-strings](docs/rules/no-escapes-in-literal-strings.md) | Disallow escapes in literal strings, such as `'Line one\nLine two'`. | ✅ | | 💡 |
193
+ | [no-escapes-in-paths](docs/rules/no-escapes-in-paths.md) | Disallow `\n` and `\t` escapes in Windows paths. | ✅ ☑️ | | 💡 |
194
+ | [no-float-trailing-zeros](docs/rules/no-float-trailing-zeros.md) | Disallow trailing zeros in the fraction of a float. | ✅ | | 💡 |
195
+ | [no-invisible-characters](docs/rules/no-invisible-characters.md) | Disallow invisible characters in strings, keys, and comments. | ✅ ☑️ | 🔧 | |
196
+ | [no-loss-of-precision](docs/rules/no-loss-of-precision.md) | Disallow floats whose written digits are lost when they are stored. | ✅ ☑️ | | |
197
+ | [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. | ✅ ☑️ | | |
198
+ | [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
199
+ | [no-negative-zero](docs/rules/no-negative-zero.md) | Disallow a sign on zero. | ✅ | 🔧 | |
200
+ | [no-placeholder-values](docs/rules/no-placeholder-values.md) | Disallow placeholder values, such as `'changeme'`. | ✅ | | |
201
+ | [no-quoted-keywords](docs/rules/no-quoted-keywords.md) | Disallow the strings `'true'`, `'false'`, and `'null'`. | ✅ | | 💡 |
202
+ | [no-quoted-values-in-arrays](docs/rules/no-quoted-values-in-arrays.md) | Disallow quoted values in an array of that type, such as `'443'` in `[80, '443']`. | ✅ | | 💡 |
203
+ | [no-sizes-as-durations](docs/rules/no-sizes-as-durations.md) | Disallow sizes that are durations, such as `memory: 512m`. | ✅ ☑️ | | 💡 |
204
+ | [no-surrounding-whitespace-in-keys](docs/rules/no-surrounding-whitespace-in-keys.md) | Disallow whitespace at the start and end of keys. | ✅ | | |
205
+ | [no-unnormalized-keys](docs/rules/no-unnormalized-keys.md) | Disallow keys that are not in a Unicode normalization form. | ✅ ☑️ | | |
206
+ | [no-zero-duration-parts](docs/rules/no-zero-duration-parts.md) | Disallow duration parts that are zero. | ✅ | 🔧 | |
207
+ | [numeric-separators-style](docs/rules/numeric-separators-style.md) | Enforce the grouping of digits with `_` in decimal numbers. | ✅ | 🔧 | |
208
+ | [prefer-bare-keys](docs/rules/prefer-bare-keys.md) | Prefer bare keys when a key does not need quotes. | ✅ | 🔧 | |
209
+ | [prefer-block-strings](docs/rules/prefer-block-strings.md) | Prefer block strings for strings of three lines or more. | ✅ | 🔧 | |
210
+ | [prefer-literal-strings](docs/rules/prefer-literal-strings.md) | Prefer literal strings, `'…'`, when a string does not need escapes. | ✅ | 🔧 | |
211
+ | [prefer-utc-instants](docs/rules/prefer-utc-instants.md) | Prefer instants in UTC. | | 🔧 | |
212
+ | [sort-keys](docs/rules/sort-keys.md) | Require object keys to be sorted. | | 🔧 | |
213
+ | [spaced-comment](docs/rules/spaced-comment.md) | Require a space after `#` and `/*`, and before `*/`. | ✅ | 🔧 | |
177
214
 
178
215
  <!-- end auto-generated rules list -->
179
216
 
@@ -251,45 +288,7 @@ In Visual Studio Code, the [ESLint extension](https://marketplace.visualstudio.c
251
288
 
252
289
  ## Writing rules
253
290
 
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.
291
+ See [how to write rules for SOML files](docs/writing-rules.md).
293
292
 
294
293
  ## Related
295
294
 
package/rules/index.js CHANGED
@@ -6,9 +6,11 @@ export {default as 'no-block-string-edge-blank-lines'} from './no-block-string-e
6
6
  export {default as 'no-block-string-trailing-whitespace'} from './no-block-string-trailing-whitespace.js';
7
7
  export {default as 'no-confusable-keys'} from './no-confusable-keys.js';
8
8
  export {default as 'no-decimal-file-modes'} from './no-decimal-file-modes.js';
9
+ export {default as 'no-dotted-key-collisions'} from './no-dotted-key-collisions.js';
9
10
  export {default as 'no-doubled-backslashes-in-literal-strings'} from './no-doubled-backslashes-in-literal-strings.js';
10
11
  export {default as 'no-duplicate-array-items'} from './no-duplicate-array-items.js';
11
12
  export {default as 'no-empty-keys'} from './no-empty-keys.js';
13
+ export {default as 'no-escapes-in-literal-strings'} from './no-escapes-in-literal-strings.js';
12
14
  export {default as 'no-escapes-in-paths'} from './no-escapes-in-paths.js';
13
15
  export {default as 'no-float-trailing-zeros'} from './no-float-trailing-zeros.js';
14
16
  export {default as 'no-invisible-characters'} from './no-invisible-characters.js';
@@ -18,11 +20,14 @@ export {default as 'no-mixed-number-types'} from './no-mixed-number-types.js';
18
20
  export {default as 'no-negative-zero'} from './no-negative-zero.js';
19
21
  export {default as 'no-placeholder-values'} from './no-placeholder-values.js';
20
22
  export {default as 'no-quoted-keywords'} from './no-quoted-keywords.js';
23
+ export {default as 'no-quoted-values-in-arrays'} from './no-quoted-values-in-arrays.js';
21
24
  export {default as 'no-sizes-as-durations'} from './no-sizes-as-durations.js';
25
+ export {default as 'no-surrounding-whitespace-in-keys'} from './no-surrounding-whitespace-in-keys.js';
22
26
  export {default as 'no-unnormalized-keys'} from './no-unnormalized-keys.js';
23
27
  export {default as 'no-zero-duration-parts'} from './no-zero-duration-parts.js';
24
28
  export {default as 'numeric-separators-style'} from './numeric-separators-style.js';
25
29
  export {default as 'prefer-bare-keys'} from './prefer-bare-keys.js';
30
+ export {default as 'prefer-block-strings'} from './prefer-block-strings.js';
26
31
  export {default as 'prefer-literal-strings'} from './prefer-literal-strings.js';
27
32
  export {default as 'prefer-utc-instants'} from './prefer-utc-instants.js';
28
33
  export {default as 'sort-keys'} from './sort-keys.js';
@@ -1,4 +1,4 @@
1
- import {trimWhitespace} from './utils/index.js';
1
+ import {escapeInvisibleCharacters, trimWhitespace} from './utils/index.js';
2
2
 
3
3
  /**
4
4
  @import {SomlContext} from './rule/soml-context.js';
@@ -7,13 +7,25 @@ import {trimWhitespace} from './utils/index.js';
7
7
 
8
8
  const MESSAGE_ID = 'no-confusable-keys';
9
9
  const messages = {
10
- [MESSAGE_ID]: '`{{key}}` and `{{otherKey}}` are one key to a program that ignores case, `-` versus `_`, or surrounding whitespace.',
10
+ [MESSAGE_ID]: '`{{key}}` and `{{otherKey}}` are one key to a program that ignores case or surrounding whitespace, or that treats `-`, `_`, and `.` as the same.',
11
11
  };
12
12
 
13
13
  /*
14
- What many programs compare keys by: case-insensitive, with `-` and `_` the same, and without surrounding whitespace.
14
+ What many programs compare keys by: case-insensitive, with `-`, `_`, and `.` the same, and without surrounding whitespace. Libraries that read settings from environment variables treat `.` as `_`: Spring reads `LOG_LEVEL` as `log.level`, and Viper, with its usual replacer, as both `log.level` and `log-level`.
15
15
  */
16
- const fold = key => trimWhitespace(key).toLowerCase().replaceAll('-', '_');
16
+ const foldCharacter = character => {
17
+ const upper = character.toUpperCase();
18
+ const lower = upper.toLowerCase();
19
+ // A form that is more than one code point, such as the `SS` that `ß` uppercases to, is not a case of one letter, so keep the character as it is.
20
+ return [...upper].length === 1 && [...lower].length === 1 ? lower : character;
21
+ };
22
+
23
+ /*
24
+ Lowercasing alone leaves some case variants apart, because several code points can share one uppercase form: `ς` (final sigma) does not lowercase to `σ`, `ſ` does not lowercase to `s`, and `ı` does not lowercase to `i`. Uppercasing and lowercasing each code point again reaches the form the others share. Lowercase first, so a document that a JavaScript `toLowerCase()` reader would merge, such as `İ` and `i̇`, still folds together.
25
+ */
26
+ const fold = key => [...trimWhitespace(key).toLowerCase().replaceAll(/[\-.]/gv, '_')]
27
+ .map(character => foldCharacter(character))
28
+ .join('');
17
29
 
18
30
  /**
19
31
  @param {SomlContext} context
@@ -37,10 +49,10 @@ const create = context => {
37
49
  yield {
38
50
  node: key,
39
51
  messageId: MESSAGE_ID,
40
- // The source text shows the quotes, so a key with whitespace stays readable.
52
+ // The source text shows the quotes, so a key with whitespace stays readable, and a bidirectional control is escaped.
41
53
  data: {
42
- key: context.sourceCode.getText(key),
43
- otherKey: context.sourceCode.getText(firstKey),
54
+ key: escapeInvisibleCharacters(context.sourceCode.getText(key)),
55
+ otherKey: escapeInvisibleCharacters(context.sourceCode.getText(firstKey)),
44
56
  },
45
57
  };
46
58
  }
@@ -55,7 +67,7 @@ const config = {
55
67
  meta: {
56
68
  type: 'problem',
57
69
  docs: {
58
- description: 'Disallow keys that differ only in case, `-` and `_`, or surrounding whitespace.',
70
+ description: 'Disallow keys that differ only in case, `-`, `_`, and `.`, or surrounding whitespace.',
59
71
  recommended: 'unopinionated',
60
72
  },
61
73
  messages,
@@ -8,8 +8,8 @@ import {getKeyWords} from './utils/index.js';
8
8
  const MESSAGE_ID_ERROR = 'no-decimal-file-modes/error';
9
9
  const MESSAGE_ID_SUGGESTION = 'no-decimal-file-modes/suggestion';
10
10
  const messages = {
11
- [MESSAGE_ID_ERROR]: '`{{number}}` is a decimal number, so this file mode is `{{octal}}`. A file mode is octal, as in `0o{{number}}`.',
12
- [MESSAGE_ID_SUGGESTION]: 'Write it as `0o{{number}}`.',
11
+ [MESSAGE_ID_ERROR]: '`{{number}}` is a decimal number, so this file mode is `{{octal}}`. A file mode is octal, as in `0o{{digits}}`.',
12
+ [MESSAGE_ID_SUGGESTION]: 'Write it as `0o{{digits}}`.',
13
13
  };
14
14
 
15
15
  /*
@@ -53,10 +53,12 @@ const create = context => {
53
53
  }
54
54
 
55
55
  const number = sourceCode.getText(value);
56
+ // The spec permits `_` between digits, so the digits alone decide what the number is.
57
+ const digits = number.replaceAll('_', '');
56
58
  const decimal = Number(value.value);
57
59
 
58
60
  // The digits look octal, and the value makes no sense as a mode, but the same digits read as octal do.
59
- if (!OCTAL_DIGITS.test(number) || isSensibleMode(decimal) || !isSensibleMode(Number.parseInt(number, 8))) {
61
+ if (!OCTAL_DIGITS.test(digits) || isSensibleMode(decimal) || !isSensibleMode(Number.parseInt(digits, 8))) {
60
62
  return;
61
63
  }
62
64
 
@@ -66,12 +68,13 @@ const create = context => {
66
68
  data: {
67
69
  number,
68
70
  octal: `0o${decimal.toString(8)}`,
71
+ digits,
69
72
  },
70
73
  suggest: [
71
74
  {
72
75
  messageId: MESSAGE_ID_SUGGESTION,
73
- data: {number},
74
- fix: fixer => fixer.replaceText(value, `0o${number}`),
76
+ data: {digits},
77
+ fix: fixer => fixer.replaceText(value, `0o${digits}`),
75
78
  },
76
79
  ],
77
80
  };
@@ -0,0 +1,66 @@
1
+ import {escapeInvisibleCharacters} from './utils/index.js';
2
+
3
+ /**
4
+ @import {SomlContext} from './rule/soml-context.js';
5
+ @import {SomlRule} from './rule/to-eslint-rule.js';
6
+ */
7
+
8
+ const MESSAGE_ID = 'no-dotted-key-collisions';
9
+ const messages = {
10
+ [MESSAGE_ID]: '`{{key}}` is a key of its own, not `{{rest}}` in the object `{{object}}`, because SOML does not split keys at `.`. Move it into `{{object}}`.',
11
+ };
12
+
13
+ /**
14
+ @param {SomlContext} context
15
+ */
16
+ const create = context => {
17
+ context.on('Object', function * (node) {
18
+ // Only a sibling that is an object shows that the author nests with braces, and then expects `'a.b'` to go into `a`, as in TOML. A flat map of dotted names, such as Spark or log4j settings, or a host next to its domain, has no such object.
19
+ const objectKeys = new Set(node.members
20
+ .filter(member => member.value.type === 'Object')
21
+ .map(member => member.key.value));
22
+
23
+ for (const {key, value} of node.members) {
24
+ const dotIndex = key.value.indexOf('.');
25
+
26
+ // A key that starts with `.` names a file, such as `'.eslintrc'`, not a path. A key whose value is an object is more likely a name in a map of objects, such as the logger `'uvicorn.error'` next to `uvicorn`, or the host `'bastion.internal'` next to `bastion`.
27
+ if (dotIndex <= 0 || value.type === 'Object') {
28
+ continue;
29
+ }
30
+
31
+ const object = key.value.slice(0, dotIndex);
32
+
33
+ if (!objectKeys.has(object)) {
34
+ continue;
35
+ }
36
+
37
+ yield {
38
+ node: key,
39
+ messageId: MESSAGE_ID,
40
+ data: {
41
+ key: escapeInvisibleCharacters(context.sourceCode.getText(key)),
42
+ object: escapeInvisibleCharacters(object),
43
+ rest: escapeInvisibleCharacters(key.value.slice(dotIndex + 1)),
44
+ },
45
+ };
46
+ }
47
+ });
48
+ };
49
+
50
+ /**
51
+ @type {SomlRule}
52
+ */
53
+ const config = {
54
+ create,
55
+ meta: {
56
+ type: 'problem',
57
+ docs: {
58
+ description: 'Disallow quoted keys with a `.` that read as a path into an object next to them.',
59
+ recommended: true,
60
+ },
61
+ messages,
62
+ languages: ['soml/soml'],
63
+ },
64
+ };
65
+
66
+ export default config;
@@ -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. Canonical form writes a bidirectional control as it is, so it is escaped, as an escape in the block string was.
76
- item: item.block ? escapeInvisibleCharacters(stringifyValue(item.value)) : sourceCode.getText(item),
75
+ // A block string spans lines, so it is shown as a one-line string with the same value. A bidirectional control is escaped, or it could reorder the message, whichever spelling the string uses.
76
+ item: escapeInvisibleCharacters(item.block ? 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.
@@ -0,0 +1,77 @@
1
+ import {parse} from 'soml-lang';
2
+ import {canBeLiteral, getMeantEscape, toEscapedString} from './utils/index.js';
3
+
4
+ /**
5
+ @import {SomlContext} from './rule/soml-context.js';
6
+ @import {SomlRule} from './rule/to-eslint-rule.js';
7
+ */
8
+
9
+ const MESSAGE_ID_ERROR = 'no-escapes-in-literal-strings/error';
10
+ const MESSAGE_ID_SUGGESTION = 'no-escapes-in-literal-strings/suggestion';
11
+ const messages = {
12
+ [MESSAGE_ID_ERROR]: 'A literal string, `\'…\'`, has no escapes, so `{{escape}}` is a backslash and `{{rest}}`. Use a `"…"` string for escapes.',
13
+ [MESSAGE_ID_SUGGESTION]: 'Write it as `{{replacement}}`.',
14
+ };
15
+
16
+ /**
17
+ @param {SomlContext} context
18
+ */
19
+ const create = context => {
20
+ context.on('String', node => {
21
+ if (node.style !== 'literal' || node.block) {
22
+ return;
23
+ }
24
+
25
+ const escape = getMeantEscape(node.value);
26
+
27
+ if (escape === undefined) {
28
+ return;
29
+ }
30
+
31
+ // Every backslash starts an escape and the value has no `"`, so the same text in `"…"` is a valid string, unless an escape names a character that SOML cannot hold, such as `\u{d}`.
32
+ let value;
33
+ try {
34
+ [value] = parse(`["${node.value}"]`);
35
+ } catch {
36
+ return;
37
+ }
38
+
39
+ // A literal string only when it would not be reported again, by this rule or by `no-doubled-backslashes-in-literal-strings`, as in `prefer-literal-strings`.
40
+ const replacement = canBeLiteral(value) ? `'${value}'` : toEscapedString(value);
41
+
42
+ return {
43
+ node,
44
+ messageId: MESSAGE_ID_ERROR,
45
+ data: {
46
+ escape,
47
+ rest: escape.slice(1),
48
+ },
49
+ suggest: [
50
+ {
51
+ messageId: MESSAGE_ID_SUGGESTION,
52
+ data: {replacement},
53
+ fix: fixer => fixer.replaceText(node, replacement),
54
+ },
55
+ ],
56
+ };
57
+ });
58
+ };
59
+
60
+ /**
61
+ @type {SomlRule}
62
+ */
63
+ const config = {
64
+ create,
65
+ meta: {
66
+ type: 'problem',
67
+ docs: {
68
+ description: 'Disallow escapes in literal strings, such as `\'Line one\\nLine two\'`.',
69
+ recommended: true,
70
+ },
71
+ hasSuggestions: true,
72
+ messages,
73
+ languages: ['soml/soml'],
74
+ },
75
+ };
76
+
77
+ export default config;
@@ -1,3 +1,4 @@
1
+ import {parse} from 'soml-lang';
1
2
  import {canBeLiteral, toEscapedString} from './utils/index.js';
2
3
 
3
4
  /**
@@ -17,11 +18,8 @@ A Windows drive, such as `C:\`, at the start of the string, where a path value s
17
18
  */
18
19
  const DRIVE = /^[A-Z]:\\/v;
19
20
  const ESCAPE = /\\(?:u\{(?<codePoint>[\da-f]+)\}|(?<character>.))/gv;
21
+ // Only these short escapes can be mistaken for path separators; other escapes keep their meaning.
20
22
  const MEANINGS = {n: 'a line break', t: 'a tab'};
21
- // What the author meant by each short escape: `\n` and `\t` are a backslash and a letter, as in the path, and the others mean what they say.
22
- const MEANT = {
23
- '\\': '\\', '"': '"', n: String.raw`\n`, t: String.raw`\t`,
24
- };
25
23
 
26
24
  /**
27
25
  @param {SomlContext} context
@@ -47,8 +45,9 @@ const create = context => {
47
45
  return;
48
46
  }
49
47
 
50
- // What the author meant.
51
- const value = content.replaceAll(ESCAPE, (escape, codePoint, character) => codePoint === undefined ? MEANT[character] : String.fromCodePoint(Number.parseInt(codePoint, 16)));
48
+ // What the author meant: make `\n` and `\t` literal, and let the parser decode every other escape. This cannot throw, because the string already parsed, so every escape in it is valid, and `\\n` and `\\t` are valid too.
49
+ const corrected = content.replaceAll(ESCAPE, (escape, codePoint, character) => Object.hasOwn(MEANINGS, character) ? `\\${escape}` : escape);
50
+ const [value] = parse(`["${corrected}"]`);
52
51
  const replacement = canBeLiteral(value) ? `'${value}'` : toEscapedString(value);
53
52
  const {character} = mistake.groups;
54
53
  const suggestions = [];
@@ -20,6 +20,11 @@ const messages = {
20
20
  [MESSAGE_ID_COMMENT]: 'The character U+{{codePoint}} is invisible or looks like a space. Remove it.',
21
21
  };
22
22
 
23
+ /*
24
+ In a single-line string or a quoted key, a tab also looks like spaces. In a block string and a comment, a tab is allowed, because it indents.
25
+ */
26
+ const singleLineCharacters = new RegExp(String.raw`${invisibleCharacters.source}|\t`, 'gv');
27
+
23
28
  /**
24
29
  @param {SomlContext} context
25
30
  */
@@ -43,13 +48,14 @@ const create = context => {
43
48
  messageId = MESSAGE_ID_COMMENT;
44
49
  }
45
50
 
51
+ const characters = isComment(node) || node.block ? invisibleCharacters : singleLineCharacters;
46
52
  let isFirst = true;
47
53
 
48
- for (const match of sourceCode.getText(node).matchAll(invisibleCharacters)) {
54
+ for (const match of sourceCode.getText(node).matchAll(characters)) {
49
55
  const [character] = match;
50
56
  const start = nodeStart + match.index;
51
57
  const end = start + character.length;
52
- const escape = toUnicodeEscape(character);
58
+ const escape = character === '\t' ? String.raw`\t` : toUnicodeEscape(character);
53
59
  let fix;
54
60
 
55
61
  if (isEscaped) {
@@ -11,41 +11,56 @@ const messages = {
11
11
  };
12
12
 
13
13
  const QUOTE_RUNS = {'\'': /'+/gv, '"': /"+/gv};
14
- // An odd number of backslashes at the end, so the character after them is escaped.
15
- const ESCAPING_BACKSLASHES = /(?:^|[^\\])(?:\\\\)*\\$/v;
16
14
 
17
15
  /*
18
- Whether a run of quotes at `index` closes a quote that the line opened, as in `Say '''hi'''`, because an odd number of runs of the same length come before it.
16
+ The runs of the delimiter's quote character on the line, each with its index, its length, and where it ends. In a `"""` block, `\"` is an escaped quote, so it is not part of the run.
19
17
  */
20
- function isClosingQuote(line, index, delimiter) {
21
- const runs = line.slice(0, index).match(QUOTE_RUNS[delimiter[0]]) ?? [];
22
- return runs.filter(run => run.length === delimiter.length).length % 2 === 1;
18
+ function getQuoteRuns(line, quote, isEscaped) {
19
+ return line.matchAll(QUOTE_RUNS[quote]).map(match => {
20
+ let backslashCount = 0;
21
+ if (isEscaped) {
22
+ for (let index = match.index - 1; line[index] === '\\'; index--) {
23
+ backslashCount++;
24
+ }
25
+ }
26
+
27
+ // An odd number of backslashes immediately before a quote means it is escaped.
28
+ const isFirstQuoteEscaped = backslashCount % 2 === 1;
29
+
30
+ return {
31
+ index: match.index + (isFirstQuoteEscaped ? 1 : 0),
32
+ length: match[0].length - (isFirstQuoteEscaped ? 1 : 0),
33
+ end: match.index + match[0].length,
34
+ };
35
+ }).toArray();
23
36
  }
24
37
 
25
38
  /*
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.
39
+ Whether the run at `runIndex` closes a quote that the line opened, as in `Say '''hi'''`, because an odd number of runs of the same length come before it.
27
40
  */
28
- const AFTER_DELIMITER = /[\t ]*(?:[,\]\}][\t ]*)*(?:(?:#|\/\*).*)?$/vy;
41
+ function isClosingQuote(runs, runIndex, delimiterLength) {
42
+ return runs.slice(0, runIndex).filter(run => run.length === delimiterLength).length % 2 === 1;
43
+ }
44
+
45
+ /*
46
+ 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. The `s` flag lets the comment hold U+2028 or U+2029, which do not end a SOML line.
47
+ */
48
+ const AFTER_DELIMITER = /[\t ]*(?:[,\]\}][\t ]*)*(?:(?:#|\/\*).*)?$/svy;
29
49
 
30
50
  /*
31
51
  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
52
  */
33
53
  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;
54
+ const runs = getQuoteRuns(line, delimiter[0], isEscaped);
55
+
56
+ for (const [runIndex, {index, length, end}] of runs.entries()) {
37
57
  AFTER_DELIMITER.lastIndex = end;
38
58
 
39
59
  if (!AFTER_DELIMITER.test(line)) {
40
60
  continue;
41
61
  }
42
62
 
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;
63
+ return index > 0 && length === delimiter.length && !isClosingQuote(runs, runIndex, delimiter.length) ? index : undefined;
49
64
  }
50
65
  }
51
66
 
@@ -0,0 +1,120 @@
1
+ import {parseTree} 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_ERROR = 'no-quoted-values-in-arrays/error';
9
+ const MESSAGE_ID_SUGGESTION = 'no-quoted-values-in-arrays/suggestion';
10
+ const messages = {
11
+ [MESSAGE_ID_ERROR]: '`{{string}}` is a string, but this array holds {{type}}.',
12
+ [MESSAGE_ID_SUGGESTION]: 'Write it as `{{value}}`.',
13
+ };
14
+
15
+ /*
16
+ The types a string can spell. A boolean or `null` in quotes is for `no-quoted-keywords`. A float in quotes is not checked, because it is most often a version, as in `[1.21, 1.22, '1.23']`, where the floats are the mistake.
17
+ */
18
+ const TYPES = new Map([
19
+ ['Integer', 'ints'],
20
+ ['Instant', 'instants'],
21
+ ['Duration', 'durations'],
22
+ ]);
23
+
24
+ /**
25
+ The type of the one value that `text` is the exact spelling of, such as `'Integer'` for `443` and `'Duration'` for `2s`, or `undefined` when it is not one value.
26
+
27
+ @param {string} text
28
+ @returns {string | undefined}
29
+ */
30
+ function getSpelledType(text) {
31
+ let element;
32
+ try {
33
+ [element] = parseTree(`[${text}]`).body.elements;
34
+ } catch {
35
+ return;
36
+ }
37
+
38
+ if (element === undefined) {
39
+ return;
40
+ }
41
+
42
+ // eslint-disable-next-line internal/no-restricted-property-access -- A node of another document, not of this file.
43
+ const [start, end] = element.range;
44
+
45
+ // The value must be all of the text, so whitespace, a comment, or a second item around it does not count.
46
+ if (start === 1 && end === text.length + 1) {
47
+ return element.type;
48
+ }
49
+ }
50
+
51
+ /**
52
+ @param {SomlContext} context
53
+ */
54
+ const create = context => {
55
+ context.on('Array', function * (node) {
56
+ const strings = [];
57
+ const typed = [];
58
+
59
+ // `null` is the missing value in a list of any type, so it does not count.
60
+ for (const element of node.elements) {
61
+ if (element.type === 'String') {
62
+ strings.push(element);
63
+ } else if (element.type !== 'Null') {
64
+ typed.push(element);
65
+ }
66
+ }
67
+
68
+ const type = typed[0]?.type;
69
+
70
+ // Each string is reported only when the array would hold one type without quotes, so an array with other text, such as `[80, 443, 'auto', '8080']`, is not. The quoted items must also be no more than the others, or the others are more likely the mistake.
71
+ if (
72
+ !TYPES.has(type)
73
+ || strings.length === 0
74
+ || strings.length > typed.length
75
+ || typed.some(element => element.type !== type)
76
+ || strings.some(string => string.block || getSpelledType(string.value) !== type)
77
+ ) {
78
+ return;
79
+ }
80
+
81
+ for (const string of strings) {
82
+ const {value} = string;
83
+
84
+ yield {
85
+ node: string,
86
+ messageId: MESSAGE_ID_ERROR,
87
+ data: {
88
+ string: context.sourceCode.getText(string),
89
+ type: TYPES.get(type),
90
+ },
91
+ suggest: [
92
+ {
93
+ messageId: MESSAGE_ID_SUGGESTION,
94
+ data: {value},
95
+ fix: fixer => fixer.replaceText(string, value),
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ });
101
+ };
102
+
103
+ /**
104
+ @type {SomlRule}
105
+ */
106
+ const config = {
107
+ create,
108
+ meta: {
109
+ type: 'problem',
110
+ docs: {
111
+ description: 'Disallow quoted values in an array of that type, such as `\'443\'` in `[80, \'443\']`.',
112
+ recommended: true,
113
+ },
114
+ hasSuggestions: true,
115
+ messages,
116
+ languages: ['soml/soml'],
117
+ },
118
+ };
119
+
120
+ export default config;
@@ -1,4 +1,4 @@
1
- import {getKeyWords} from './utils/index.js';
1
+ import {escapeInvisibleCharacters, getKeyWords} from './utils/index.js';
2
2
 
3
3
  /**
4
4
  @import {SomlContext} from './rule/soml-context.js';
@@ -86,7 +86,7 @@ const create = context => {
86
86
  messageId: MESSAGE_ID_ERROR,
87
87
  data: {
88
88
  duration,
89
- key: context.sourceCode.getText(key),
89
+ key: escapeInvisibleCharacters(context.sourceCode.getText(key)),
90
90
  },
91
91
  suggest: [
92
92
  {
@@ -0,0 +1,53 @@
1
+ import {escapeInvisibleCharacters, trimWhitespace} from './utils/index.js';
2
+
3
+ /**
4
+ @import {SomlContext} from './rule/soml-context.js';
5
+ @import {SomlRule} from './rule/to-eslint-rule.js';
6
+ */
7
+
8
+ const MESSAGE_ID = 'no-surrounding-whitespace-in-keys';
9
+ const messages = {
10
+ [MESSAGE_ID]: 'The whitespace at the start or end of `{{key}}` is part of the key, so a program that reads `{{trimmed}}` does not find it.',
11
+ };
12
+
13
+ /**
14
+ @param {SomlContext} context
15
+ */
16
+ const create = context => {
17
+ context.on('Key', node => {
18
+ const trimmed = trimWhitespace(node.value);
19
+
20
+ // A key of only whitespace is for `no-empty-keys`.
21
+ if (trimmed === '' || trimmed === node.value) {
22
+ return;
23
+ }
24
+
25
+ return {
26
+ node,
27
+ messageId: MESSAGE_ID,
28
+ data: {
29
+ // The source text shows the quotes, so the whitespace is visible, and an invisible character is escaped.
30
+ key: escapeInvisibleCharacters(context.sourceCode.getText(node)),
31
+ trimmed: escapeInvisibleCharacters(trimmed),
32
+ },
33
+ };
34
+ });
35
+ };
36
+
37
+ /**
38
+ @type {SomlRule}
39
+ */
40
+ const config = {
41
+ create,
42
+ meta: {
43
+ type: 'problem',
44
+ docs: {
45
+ description: 'Disallow whitespace at the start and end of keys.',
46
+ recommended: true,
47
+ },
48
+ messages,
49
+ languages: ['soml/soml'],
50
+ },
51
+ };
52
+
53
+ export default config;
@@ -0,0 +1,129 @@
1
+ import {
2
+ escapeInvisibleCharacters,
3
+ getMeantEscape,
4
+ hasDoubledBackslashes,
5
+ hasInvisibleCharacter,
6
+ trimEnd,
7
+ } from './utils/index.js';
8
+
9
+ /**
10
+ @import {SomlContext} from './rule/soml-context.js';
11
+ @import {SomlRule} from './rule/to-eslint-rule.js';
12
+ */
13
+
14
+ const MESSAGE_ID = 'prefer-block-strings';
15
+ const messages = {
16
+ [MESSAGE_ID]: 'A string of three lines or more is easier to read as a block string.',
17
+ };
18
+
19
+ const MINIMUM_LINES = 3;
20
+
21
+ const hasTrailingWhitespace = line => trimEnd(line, '\t ') !== line;
22
+
23
+ /**
24
+ Write the lines of a `"""` block string. Every escape that `"…"` has works in it, so the line breaks at the edges, which a block string would drop, and the whitespace at the end of a line, which an editor can remove, are written as escapes.
25
+
26
+ @param {string[]} lines
27
+ @param {number} leadingCount The number of line breaks at the start of the value.
28
+ @param {number} trailingCount The number of line breaks at the end of the value.
29
+ @returns {string[]}
30
+ */
31
+ function getEscapedLines(lines, leadingCount, trailingCount) {
32
+ const escapedLines = lines.map(line => {
33
+ const text = trimEnd(line, '\t ');
34
+ const whitespace = line.slice(text.length).replaceAll(' ', String.raw`\u{20}`).replaceAll('\t', String.raw`\t`);
35
+ return escapeInvisibleCharacters(text.replaceAll('\\', '\\\\')) + whitespace;
36
+ });
37
+
38
+ escapedLines[0] = String.raw`\n`.repeat(leadingCount) + escapedLines[0];
39
+ escapedLines[escapedLines.length - 1] += String.raw`\n`.repeat(trailingCount);
40
+
41
+ return escapedLines;
42
+ }
43
+
44
+ /**
45
+ The block string for `value`, with its lines at `indent`, or `undefined` when there is none. A `'''` block string is used when it holds the value as it is, as `prefer-literal-strings` asks, and a `"""` block string otherwise.
46
+
47
+ @param {string} value
48
+ @param {string} indent
49
+ @returns {string | undefined}
50
+ */
51
+ function getBlockString(value, indent) {
52
+ const withoutTrailing = trimEnd(value, '\n');
53
+ const text = withoutTrailing.replace(/^\n+/v, '');
54
+ const leadingCount = withoutTrailing.length - text.length;
55
+ const trailingCount = value.length - withoutTrailing.length;
56
+ const lines = text.split('\n');
57
+
58
+ if (lines.length < MINIMUM_LINES) {
59
+ return;
60
+ }
61
+
62
+ // A line break at the start or end, and whitespace at the end of a line, would be lost or invisible in `'''`, which has no escapes. A `'''` in the text could end it. A doubled backslash reads as a mistake, which `no-doubled-backslashes-in-literal-strings` reports, and so does a backslash that `getMeantEscape()` reads as an escape, such as `\u{41}`.
63
+ const isLiteral = leadingCount === 0
64
+ && trailingCount === 0
65
+ && !text.includes('\'\'\'')
66
+ && !hasInvisibleCharacter(text)
67
+ && !hasDoubledBackslashes(text)
68
+ && getMeantEscape(text) === undefined
69
+ && lines.every(line => !hasTrailingWhitespace(line));
70
+
71
+ if (!isLiteral && text.includes('"""')) {
72
+ return;
73
+ }
74
+
75
+ const delimiter = isLiteral ? '\'\'\'' : '"""';
76
+ const contentLines = isLiteral ? lines : getEscapedLines(lines, leadingCount, trailingCount);
77
+
78
+ // An empty line needs no indentation.
79
+ const content = contentLines.map(line => line === '' ? '' : indent + line).join('\n');
80
+ return `${delimiter}\n${content}\n${indent}${delimiter}`;
81
+ }
82
+
83
+ /**
84
+ @param {SomlContext} context
85
+ */
86
+ const create = context => {
87
+ const {sourceCode} = context;
88
+
89
+ context.on('String', node => {
90
+ if (node.style !== 'escaped' || node.block || !node.value.includes('\n')) {
91
+ return;
92
+ }
93
+
94
+ // A value that starts its own line is already where the formatter puts a block string. One after a key or another value goes one level deeper. The `format` rule then moves the opening delimiter to the next line, as the formatter does.
95
+ const {start} = sourceCode.getLoc(node);
96
+ const [lineIndent] = /^[\t ]*/v.exec(sourceCode.lines[start.line - 1]);
97
+ const indent = start.column === lineIndent.length ? lineIndent : `${lineIndent}\t`;
98
+ const blockString = getBlockString(node.value, indent);
99
+
100
+ if (blockString === undefined) {
101
+ return;
102
+ }
103
+
104
+ return {
105
+ node,
106
+ messageId: MESSAGE_ID,
107
+ fix: fixer => fixer.replaceText(node, blockString),
108
+ };
109
+ });
110
+ };
111
+
112
+ /**
113
+ @type {SomlRule}
114
+ */
115
+ const config = {
116
+ create,
117
+ meta: {
118
+ type: 'suggestion',
119
+ docs: {
120
+ description: 'Prefer block strings for strings of three lines or more.',
121
+ recommended: true,
122
+ },
123
+ fixable: 'code',
124
+ messages,
125
+ languages: ['soml/soml'],
126
+ },
127
+ };
128
+
129
+ export default config;
@@ -1,4 +1,4 @@
1
- import {canBeLiteral, hasDoubledBackslashes, hasInvisibleCharacter} from './utils/index.js';
1
+ import {canBeLiteral, hasInvisibleCharacter} from './utils/index.js';
2
2
 
3
3
  /**
4
4
  @import {SomlContext} from './rule/soml-context.js';
@@ -29,8 +29,8 @@ function getLiteral(node, text) {
29
29
  return delimiter + text.slice(delimiter.length, -delimiter.length) + delimiter;
30
30
  }
31
31
 
32
- // A value such as `^\\d+$` reads as a mistake in a literal string, which `no-doubled-backslashes-in-literal-strings` reports.
33
- if (canBeLiteral(node.value) && !hasDoubledBackslashes(node.value)) {
32
+ // A value such as `^\\d+$` or `Done.\n` reads as a mistake in a literal string, so `canBeLiteral()` keeps it escaped.
33
+ if (canBeLiteral(node.value)) {
34
34
  return `'${node.value}'`;
35
35
  }
36
36
  }
@@ -1,5 +1,5 @@
1
1
  import {compareKeys} from 'soml-lang';
2
- import {isDisabledAnywhere, reorderMembers} from './utils/index.js';
2
+ import {escapeInvisibleCharacters, isDisabledAnywhere, reorderMembers} from './utils/index.js';
3
3
 
4
4
  /**
5
5
  @import {SomlContext} from './rule/soml-context.js';
@@ -147,8 +147,8 @@ const create = context => {
147
147
  node: member.key,
148
148
  messageId: MESSAGE_ID,
149
149
  data: {
150
- thisName: sourceCode.getText(member.key),
151
- previousName: sourceCode.getText(previous.key),
150
+ thisName: escapeInvisibleCharacters(sourceCode.getText(member.key)),
151
+ previousName: escapeInvisibleCharacters(sourceCode.getText(previous.key)),
152
152
  direction: direction === 'asc' ? 'ascending' : 'descending',
153
153
  sensitivity: caseSensitive ? 'sensitive' : 'insensitive',
154
154
  sortName: natural ? 'natural' : 'code point',
@@ -18,9 +18,9 @@ const BLOCK_START = /^\**(?=[^\s*])/v;
18
18
  const BLOCK_END = /(?<=[^\s*])\**$/v;
19
19
 
20
20
  /*
21
- A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it, also with whitespace or another comment after it, as in `#ff0000 # red`.
21
+ A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it, also with a comma, whitespace, or another comment after it, as in `#ff0000,` or `#ff0000 # red`. The `s` flag lets that comment hold U+2028 or U+2029, which do not end a SOML line.
22
22
  */
23
- const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8}),?(?:[\t ]+(?:#|\/\*).*|[\t ]*)$/iv;
23
+ const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})(?:[\t ]*,)?(?:[\t ]*$|[\t ]+(?:#|\/\*).*$)/isv;
24
24
 
25
25
  /**
26
26
  @param {SomlContext} context
@@ -1,4 +1,6 @@
1
1
  import {invisibleCharacters, hasInvisibleCharacter} from './invisible-characters.js';
2
+ import hasDoubledBackslashes from './has-doubled-backslashes.js';
3
+ import getMeantEscape from './get-meant-escape.js';
2
4
 
3
5
  const NOT_LITERAL = /[\p{Control}']/v;
4
6
 
@@ -13,13 +15,16 @@ export function toUnicodeEscape(character) {
13
15
  }
14
16
 
15
17
  /**
16
- Write every invisible character in `text` as a `\u{…}` escape, such as for a decoded value in a message, where a control character could change the terminal and a bidirectional control could reorder the text.
18
+ Write line feeds and tabs in `text` as short escapes and every other invisible character as a `\u{…}` escape, such as for a decoded value in a message, where a control character could change the terminal and a bidirectional control could reorder the text.
17
19
 
18
20
  @param {string} text
19
21
  @returns {string}
20
22
  */
21
23
  export function escapeInvisibleCharacters(text) {
22
- return text.replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
24
+ return text
25
+ .replaceAll(invisibleCharacters, character => toUnicodeEscape(character))
26
+ .replaceAll('\n', String.raw`\n`)
27
+ .replaceAll('\t', String.raw`\t`);
23
28
  }
24
29
 
25
30
  /**
@@ -39,11 +44,14 @@ export function toEscapedString(value) {
39
44
  }
40
45
 
41
46
  /**
42
- Whether a value should be written as a `'...'` string: it has no `'`, which would end it, and no line feed, tab, or other control character, which `'...'` cannot hold or which are invisible in it. A value with an invisible character also stays escaped, as `no-invisible-characters` asks. This is stricter than canonical form, which writes C1 controls and other invisible characters in `'...'`.
47
+ Whether a value should be written as a `'...'` string: it has no `'`, which would end it, and no line feed, tab, or other control character, which `'...'` cannot hold or which are invisible in it. A value with an invisible character also stays escaped, as `no-invisible-characters` asks. So does a value that reads as a mistake in `'...'`: one such as `^\\d+$`, which `no-doubled-backslashes-in-literal-strings` reports, and one such as `Done.\n`, with a backslash and `n`, which `no-escapes-in-literal-strings` reports. This is stricter than canonical form, which writes C1 controls and other invisible characters in `'...'`.
43
48
 
44
49
  @param {string} value
45
50
  @returns {boolean}
46
51
  */
47
52
  export function canBeLiteral(value) {
48
- return !NOT_LITERAL.test(value) && !hasInvisibleCharacter(value);
53
+ return !NOT_LITERAL.test(value)
54
+ && !hasInvisibleCharacter(value)
55
+ && !hasDoubledBackslashes(value)
56
+ && getMeantEscape(value) === undefined;
49
57
  }
@@ -0,0 +1,46 @@
1
+ /*
2
+ A backslash and what follows it, as a `"…"` string reads them. A `\u{…}` in another spelling, such as `\u{E9}` or `\u0041`, is not a SOML escape, so it goes to `other`, and so does a backslash at the end.
3
+ */
4
+ const BACKSLASH = /\\(?:(?<escape>[\\nt]|u\{(?:0|[1-9a-f][\da-f]{0,5})\})|(?<other>.?))/gsv;
5
+
6
+ /*
7
+ A Windows path, which starts with a drive, as in `C:\`, a network share, as in `\\server`, or a relative folder, as in `.\` and `..\`.
8
+ */
9
+ const PATH = /^(?:[A-Za-z]:\\|\\\\|\.\.?\\)/v;
10
+ const WHITESPACE = /[\t ]/v;
11
+ const NAME_START = /[\p{Letter}\p{Number}]/v;
12
+
13
+ /**
14
+ The first escape in a value that was likely meant as one, such as `\n` in `Line one\nLine two`, or `undefined` when there is none. In a literal string, `'…'`, an escape is a backslash and the characters after it, as it is in TOML and in a shell, but in JavaScript and Python, `'…'` reads escapes.
15
+
16
+ A value is only checked when every backslash in it starts a SOML escape, so a regular expression such as `^\d+\n$`, which has `\d`, is not. A Windows path is not checked either, and neither is a value with a `"`, because it is code with strings in it, such as JSON text or a shell command with `printf "%s\n"`, which keeps its escapes for the program that runs it.
17
+
18
+ A `\u{…}` escape is always likely meant. A `\n` or `\t` is when the value has a space or a tab, as text does, or when no letter or digit follows it, as at the end of `Done.\n`. A `\n` or `\t` before a name without whitespace in the value, as in `src\test`, is more likely a path.
19
+
20
+ @param {string} value
21
+ @returns {string | undefined}
22
+ */
23
+ export default function getMeantEscape(value) {
24
+ if (value.includes('"') || PATH.test(value)) {
25
+ return;
26
+ }
27
+
28
+ const backslashes = value.matchAll(BACKSLASH).toArray();
29
+
30
+ if (backslashes.some(({groups}) => groups.escape === undefined)) {
31
+ return;
32
+ }
33
+
34
+ const hasWhitespace = WHITESPACE.test(value);
35
+ // Only the character after the escape is read, so a value with many escapes takes linear time.
36
+ const isBeforeName = index => {
37
+ const codePoint = value.codePointAt(index);
38
+ return codePoint !== undefined && NAME_START.test(String.fromCodePoint(codePoint));
39
+ };
40
+
41
+ const isMeant = ({0: escape, index}) => escape.startsWith(String.raw`\u`)
42
+ || hasWhitespace
43
+ || !isBeforeName(index + escape.length);
44
+
45
+ return backslashes.find(match => match[0] !== '\\\\' && isMeant(match))?.[0];
46
+ }
@@ -11,5 +11,6 @@ export {isComment, isPunctuator} from './token.js';
11
11
  export {default as trimEnd} from './trim-end.js';
12
12
  export {default as getLines} from './get-lines.js';
13
13
  export {default as hasDoubledBackslashes} from './has-doubled-backslashes.js';
14
+ export {default as getMeantEscape} from './get-meant-escape.js';
14
15
  export {default as getKeyWords} from './get-key-words.js';
15
16
  export {default as isDisabledAnywhere} from './is-disabled-anywhere.js';
@@ -26,7 +26,7 @@ export function hasAdjacentComment(context, node) {
26
26
  /**
27
27
  A fix that puts `members` in the order of `ordered`, by replacing the text of each member with the text of the member that belongs there. The separators stay where they are. Object members have no order in SOML, so this keeps the value.
28
28
 
29
- It aborts when a comment is next to any of the members, because the comment would end up next to the wrong one.
29
+ It aborts when a comment is next to any member or inside a moved member, so comments stay where they are.
30
30
 
31
31
  @param {SomlContext} context
32
32
  @param {readonly MemberNode[]} members The members in source order.
@@ -39,9 +39,15 @@ export function reorderMembers(context, members, ordered) {
39
39
  }
40
40
 
41
41
  for (const [index, member] of members.entries()) {
42
- if (ordered[index] !== member) {
43
- yield fixer.replaceText(member, context.sourceCode.getText(ordered[index]));
42
+ if (ordered[index] === member) {
43
+ continue;
44
44
  }
45
+
46
+ if (context.sourceCode.getTokens(member, {includeComments: true}).some(token => isComment(token))) {
47
+ abort();
48
+ }
49
+
50
+ yield fixer.replaceText(member, context.sourceCode.getText(ordered[index]));
45
51
  }
46
52
  };
47
53
  }