eslint-soml 0.0.2 → 0.0.3

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
@@ -7,7 +7,6 @@ export type {
7
7
  MemberNode,
8
8
  ArrayNode,
9
9
  KeyNode,
10
- KeySegmentNode,
11
10
  ValueNode,
12
11
  StringNode,
13
12
  IntegerNode,
@@ -16,6 +16,28 @@ const INLINE_CONFIG = /^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|
16
16
 
17
17
  const isComment = item => item.type === 'Line' || item.type === 'Block';
18
18
 
19
+ /*
20
+ Merges the tokens and the comments, which are each in source order, into one list in source order. A merge takes linear time, where a sort would not.
21
+ */
22
+ function mergeByStart(tokens, comments) {
23
+ const items = [];
24
+ let commentIndex = 0;
25
+
26
+ for (const token of tokens) {
27
+ while (commentIndex < comments.length && comments[commentIndex].range[0] < token.range[0]) {
28
+ items.push(comments[commentIndex++]);
29
+ }
30
+
31
+ items.push(token);
32
+ }
33
+
34
+ while (commentIndex < comments.length) {
35
+ items.push(comments[commentIndex++]);
36
+ }
37
+
38
+ return items;
39
+ }
40
+
19
41
  /**
20
42
  The source code of a SOML document, which rules get as `context.sourceCode`.
21
43
  */
@@ -48,7 +70,7 @@ export default class SomlSourceCode extends TextSourceCodeBase {
48
70
  // SOML has no carriage returns, so a line ends only at a line feed.
49
71
  super({text, ast, lineEndingPattern: /\n/v});
50
72
  this.ast = ast;
51
- this.#items = [...ast.tokens, ...ast.comments].toSorted((first, second) => first.range[0] - second.range[0]);
73
+ this.#items = mergeByStart(ast.tokens, ast.comments);
52
74
  }
53
75
 
54
76
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eslint-soml",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "ESLint plugin for SOML, a config format for humans",
5
5
  "license": "MIT",
6
6
  "repository": "github:soml-lang/eslint-soml",
@@ -52,8 +52,7 @@
52
52
  ],
53
53
  "dependencies": {
54
54
  "@eslint/plugin-kit": "^0.7.3",
55
- "natural-compare": "^1.4.0",
56
- "soml-lang": "^0.0.3"
55
+ "soml-lang": "^0.0.4"
57
56
  },
58
57
  "devDependencies": {
59
58
  "@babel/code-frame": "^8.0.6",
package/readme.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  > ESLint plugin for [SOML](https://github.com/soml-lang/soml), a config format for humans
4
4
 
5
+ [`eslint-plugin-unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn#soml) has more rules that also lint SOML, such as `prefer-https` and `name-replacements`.
6
+
5
7
  [**Propose a new rule ➡**](.github/contributing.md)
6
8
 
7
9
  *We do not accept pull requests because of too much AI slop.*
@@ -61,7 +63,7 @@ Disable a rule with a comment, in either comment style:
61
63
 
62
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`.
63
65
 
64
- ESLint's [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) rule works too, with the [node types](#writing-rules) of the tree. For example, to disallow a key named `password`:
66
+ ESLint's [`no-restricted-syntax`](https://eslint.org/docs/latest/rules/no-restricted-syntax) rule works too. For example, to disallow a key named `password`:
65
67
 
66
68
  ```js
67
69
  import soml from 'eslint-soml';
@@ -75,7 +77,7 @@ export default defineConfig([
75
77
  'no-restricted-syntax': [
76
78
  'error',
77
79
  {
78
- selector: 'Member > Key > KeySegment[value="password"]',
80
+ selector: 'Member > Key[value="password"]',
79
81
  message: 'Read the password from the environment.',
80
82
  },
81
83
  ],
@@ -113,10 +115,8 @@ export default defineConfig([
113
115
  | [no-misplaced-block-string-delimiters](docs/rules/no-misplaced-block-string-delimiters.md) | Disallow a block string delimiter at the end of a content line. | ✅ ☑️ | | |
114
116
  | [no-mixed-number-types](docs/rules/no-mixed-number-types.md) | Disallow arrays that mix ints and floats. | ✅ | | 💡 |
115
117
  | [no-negative-zero](docs/rules/no-negative-zero.md) | Disallow a sign on zero. | ✅ | 🔧 | |
116
- | [no-numeric-dotted-keys](docs/rules/no-numeric-dotted-keys.md) | Disallow dotted keys that read as one number. | ✅ ☑️ | | 💡 |
117
118
  | [no-placeholder-values](docs/rules/no-placeholder-values.md) | Disallow placeholder values, such as `'changeme'`. | ✅ | | |
118
119
  | [no-quoted-keywords](docs/rules/no-quoted-keywords.md) | Disallow the strings `'true'`, `'false'`, and `'null'`. | ✅ | | 💡 |
119
- | [no-scattered-dotted-keys](docs/rules/no-scattered-dotted-keys.md) | Require the dotted keys under one prefix to be next to each other. | ✅ | 🔧 | |
120
120
  | [no-sizes-as-durations](docs/rules/no-sizes-as-durations.md) | Disallow sizes that are durations, such as `memory: 512m`. | ✅ ☑️ | | 💡 |
121
121
  | [no-unnormalized-keys](docs/rules/no-unnormalized-keys.md) | Disallow keys that are not in a Unicode normalization form. | ✅ ☑️ | | |
122
122
  | [no-zero-duration-parts](docs/rules/no-zero-duration-parts.md) | Disallow duration parts that are zero. | ✅ | 🔧 | |
package/rules/format.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {formatEdits} from 'soml-lang';
2
+ import {isDisabledAnywhere} from './utils/index.js';
2
3
 
3
4
  /**
4
5
  @import {SomlContext} from './rule/soml-context.js';
@@ -32,6 +33,12 @@ const create = context => {
32
33
  const {text} = sourceCode;
33
34
  const edits = formatEdits(text);
34
35
 
36
+ if (edits.length === 0) {
37
+ return;
38
+ }
39
+
40
+ const isFixable = !isDisabledAnywhere(context);
41
+
35
42
  // eslint-disable-next-line internal/no-restricted-property-access -- An edit is not a node.
36
43
  for (const [index, {range, text: inserted}] of edits.entries()) {
37
44
  const [start, end] = range;
@@ -54,8 +61,8 @@ const create = context => {
54
61
  deleted: showInvisibles(deleted),
55
62
  inserted: showInvisibles(inserted),
56
63
  },
57
- // Every edit is in one fix, so the formatting is applied at once. Applying only some edits, when a fix from another rule overlaps one of them, could leave a block string half moved. Only the first problem carries the fix, because a copy of every edit in every problem would make the output grow with the file size times the problem count.
58
- fix: index === 0
64
+ // Every edit is in one fix, so the formatting is applied at once. Applying only some edits, when a fix from another rule overlaps one of them, could leave a block string half moved. Only the first problem carries the fix, because a copy of every edit in every problem would make the output grow with the file size times the problem count. There is no fix when a directive turns the rule off anywhere, because the fix would change those lines too.
65
+ fix: index === 0 && isFixable
59
66
  ? function * (fixer) {
60
67
  // eslint-disable-next-line internal/no-restricted-property-access -- An edit is not a node.
61
68
  for (const {range, text} of edits) {
package/rules/index.js CHANGED
@@ -16,10 +16,8 @@ export {default as 'no-loss-of-precision'} from './no-loss-of-precision.js';
16
16
  export {default as 'no-misplaced-block-string-delimiters'} from './no-misplaced-block-string-delimiters.js';
17
17
  export {default as 'no-mixed-number-types'} from './no-mixed-number-types.js';
18
18
  export {default as 'no-negative-zero'} from './no-negative-zero.js';
19
- export {default as 'no-numeric-dotted-keys'} from './no-numeric-dotted-keys.js';
20
19
  export {default as 'no-placeholder-values'} from './no-placeholder-values.js';
21
20
  export {default as 'no-quoted-keywords'} from './no-quoted-keywords.js';
22
- export {default as 'no-scattered-dotted-keys'} from './no-scattered-dotted-keys.js';
23
21
  export {default as 'no-sizes-as-durations'} from './no-sizes-as-durations.js';
24
22
  export {default as 'no-unnormalized-keys'} from './no-unnormalized-keys.js';
25
23
  export {default as 'no-zero-duration-parts'} from './no-zero-duration-parts.js';
@@ -27,7 +27,7 @@ const create = context => {
27
27
  const pattern = CASES[keyCase];
28
28
  const ignorePatterns = ignore.map(source => new RegExp(source, 'v'));
29
29
 
30
- context.on('KeySegment', node => {
30
+ context.on('Key', node => {
31
31
  // A quoted key holds data rather than a name, such as a path or a URL, so it is not checked.
32
32
  if (
33
33
  node.style !== 'bare'
@@ -21,37 +21,28 @@ const fold = key => trimWhitespace(key).toLowerCase().replaceAll('-', '_');
21
21
  const create = context => {
22
22
  context.on('Object', function * (node) {
23
23
  /*
24
- The first key seen for each folded key, as a tree that follows the key paths. A dotted key adds a key to each object on its path, so the keys under two spellings of one prefix are compared as one object.
24
+ The first key seen for each folded key.
25
25
  */
26
- const root = new Map();
26
+ const firstKeys = new Map();
27
27
 
28
28
  for (const {key} of node.members) {
29
- let keys = root;
29
+ const folded = fold(key.value);
30
+ const firstKey = firstKeys.get(folded);
30
31
 
31
- for (const segment of key.segments) {
32
- const folded = fold(segment.value);
33
- let entry = keys.get(folded);
34
-
35
- if (entry === undefined) {
36
- entry = {first: segment, keys: new Map(), reported: new Set()};
37
- keys.set(folded, entry);
38
- } else if (entry.first.value !== segment.value && !entry.reported.has(segment.value)) {
39
- // Each pair of spellings is reported once, even when a dotted key repeats one of them.
40
- entry.reported.add(segment.value);
41
-
42
- yield {
43
- node: segment,
44
- messageId: MESSAGE_ID,
45
- // The source text shows the quotes, so a key with whitespace stays readable.
46
- data: {
47
- key: context.sourceCode.getText(segment),
48
- otherKey: context.sourceCode.getText(entry.first),
49
- },
50
- };
51
- }
52
-
53
- keys = entry.keys;
32
+ if (firstKey === undefined) {
33
+ firstKeys.set(folded, key);
34
+ continue;
54
35
  }
36
+
37
+ yield {
38
+ node: key,
39
+ messageId: MESSAGE_ID,
40
+ // The source text shows the quotes, so a key with whitespace stays readable.
41
+ data: {
42
+ key: context.sourceCode.getText(key),
43
+ otherKey: context.sourceCode.getText(firstKey),
44
+ },
45
+ };
55
46
  }
56
47
  });
57
48
  };
@@ -1,3 +1,5 @@
1
+ import {getKeyWords} from './utils/index.js';
2
+
1
3
  /**
2
4
  @import {SomlContext} from './rule/soml-context.js';
3
5
  @import {SomlRule} from './rule/to-eslint-rule.js';
@@ -11,15 +13,20 @@ const messages = {
11
13
  };
12
14
 
13
15
  /*
14
- A key for a file mode: `mode`, `perm`, `perms`, `permissions`, or `chmod`, alone or after `-` or `_` (`file-mode`), or `Mode` at the end of a camelCase key (`defaultMode`). A umask is left out, because it lists the permissions to remove, so what makes a mode sensible does not apply to it.
16
+ The last word of a key for a file mode, as in `mode`, `file-mode`, `defaultMode`, `FILE_UPLOAD_PERMISSIONS` (Django), or `'listen.mode'` (PHP-FPM). A umask is left out, because it lists the permissions to remove, so what makes a mode sensible does not apply to it.
15
17
  */
16
- const MODE_KEY = /(?:^|[\-_])(?:chmod|mode|perms?|permissions)$|[a-z]Mode$/v;
18
+ const MODE_WORDS = new Set(['chmod', 'mode', 'perm', 'perms', 'permissions']);
17
19
  const OCTAL_DIGITS = /^[0-7]{3,4}$/v;
18
20
 
19
21
  /*
20
22
  Whether a number is a sensible file mode, as ansible-lint's `risky-octal` rule decides: no write or execute permission without read permission, except execute alone when the owner can execute, and the owner has at least the permissions of the group, and the group at least those of others.
21
23
  */
22
24
  function isSensibleMode(mode) {
25
+ // A mode has 12 bits, so a larger number is not one, whatever its low digits are.
26
+ if (mode > 0o7777) {
27
+ return false;
28
+ }
29
+
23
30
  const user = Math.trunc(mode / 64) % 8;
24
31
  const group = Math.trunc(mode / 8) % 8;
25
32
  const other = mode % 8;
@@ -41,7 +48,7 @@ const create = context => {
41
48
  context.on('Member', member => {
42
49
  const {key, value} = member;
43
50
 
44
- if (value.type !== 'Integer' || value.radix !== 10 || !MODE_KEY.test(key.segments.at(-1).value)) {
51
+ if (value.type !== 'Integer' || value.radix !== 10 || !MODE_WORDS.has(getKeyWords(key.value).at(-1))) {
45
52
  return;
46
53
  }
47
54
 
@@ -1,3 +1,4 @@
1
+ import {stringifyValue} from 'soml-lang';
1
2
  import {hasAdjacentComment, isPunctuator} from './utils/index.js';
2
3
 
3
4
  /**
@@ -71,7 +72,8 @@ const create = context => {
71
72
  node: item,
72
73
  messageId: MESSAGE_ID_ERROR,
73
74
  data: {
74
- item: sourceCode.getText(item),
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
77
  line: sourceCode.getLoc(first).start.line,
76
78
  },
77
79
  // Removing an item changes the value, so it is a suggestion. It is left out when it would remove a comment.
@@ -14,7 +14,7 @@ const messages = {
14
14
  @param {SomlContext} context
15
15
  */
16
16
  const create = context => {
17
- context.on('KeySegment', node => {
17
+ context.on('Key', node => {
18
18
  if (trimWhitespace(node.value) !== '') {
19
19
  return;
20
20
  }
@@ -8,7 +8,7 @@ import {
8
8
  /**
9
9
  @import {SomlContext} from './rule/soml-context.js';
10
10
  @import {SomlRule} from './rule/to-eslint-rule.js';
11
- @import {StringNode, KeySegmentNode, Comment} from 'soml-lang';
11
+ @import {StringNode, KeyNode, Comment} from 'soml-lang';
12
12
  */
13
13
 
14
14
  const MESSAGE_ID_ESCAPE = 'no-invisible-characters/escape';
@@ -29,7 +29,7 @@ const create = context => {
29
29
  /**
30
30
  One problem per invisible character in `node`.
31
31
 
32
- @param {StringNode | KeySegmentNode | Comment} node
32
+ @param {StringNode | KeyNode | Comment} node
33
33
  */
34
34
  function * getProblems(node) {
35
35
  const [nodeStart] = sourceCode.getRange(node);
@@ -78,7 +78,7 @@ const create = context => {
78
78
  }
79
79
  }
80
80
 
81
- context.on(['String', 'KeySegment'], node => {
81
+ context.on(['String', 'Key'], node => {
82
82
  if (node.style !== 'bare') {
83
83
  return getProblems(node);
84
84
  }
@@ -47,15 +47,22 @@ const create = context => {
47
47
  content = trimEnd(content.slice(0, -1), ' \t');
48
48
  }
49
49
 
50
- const index = content.length - delimiter.length;
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
+ }
51
61
 
52
62
  if (
53
63
  index > 0
54
- && content.endsWith(delimiter)
55
- && content[index - 1] !== delimiter[0]
64
+ && content.length - index === delimiter.length
56
65
  && !isClosingQuote(content, index, delimiter)
57
- // In a `"""` block, `\"` is an escaped quote, so the run is shorter than the delimiter.
58
- && !(node.style === 'escaped' && ESCAPING_BACKSLASHES.test(content.slice(0, index)))
59
66
  ) {
60
67
  const start = offset + index;
61
68
 
@@ -1,3 +1,5 @@
1
+ import {escapeInvisibleCharacters} from './utils/index.js';
2
+
1
3
  /**
2
4
  @import {SomlContext} from './rule/soml-context.js';
3
5
  @import {SomlRule} from './rule/to-eslint-rule.js';
@@ -9,9 +11,9 @@ const messages = {
9
11
  };
10
12
 
11
13
  /*
12
- Words that a placeholder uses to label itself, as a whole token, so that `replacements` does not match: `changeme`, `CHANGE_ME`, `replace-me`, and `your_api_key_here`. The words are joined by `-` or `_`, not a space, so that prose such as "Set your password here" does not match. The part between `your` and `here` is limited, so a long string cannot make the search slow.
14
+ Words that a placeholder uses to label itself, as a whole token, so that `replacements` does not match: `changeme`, `CHANGE_ME`, `replace-me`, and `your_api_key_here`. Digits may follow, as in `ChangeMe123!`, because a password policy often asks for them. The words are joined by `-` or `_`, not a space, so that prose such as "Set your password here" does not match. The part between `your` and `here` is limited, so a long string cannot make the search slow.
13
15
  */
14
- const PLACEHOLDER_WORDS = /(?:^|[^\da-z])(?:(?:change|replace)[\-_]?me|your[\-_][\w\-]{0,40}?[\-_]here)(?:$|[^\da-z])/iv;
16
+ const PLACEHOLDER_WORDS = /(?:^|[^\da-z])(?:(?:change|replace)[\-_]?me|your[\-_][\w\-]{0,40}?[\-_]here)(?:$|[^a-z])/iv;
15
17
 
16
18
  /*
17
19
  A whole value that marks missing work. In uppercase only, because `Todo` is also a word.
@@ -22,7 +24,24 @@ const MARKER = /^(?:FIXME|TBD|TODO)$/v;
22
24
  @param {SomlContext} context
23
25
  */
24
26
  const create = context => {
25
- context.on('String', node => {
27
+ /*
28
+ Whether each array is a list of markers, found once per array.
29
+ */
30
+ const markerLists = new WeakMap();
31
+
32
+ /*
33
+ Whether an array holds two different markers, as a linter's list of comment tags does, such as `['TODO', 'FIXME']`. Then the markers are data, not placeholders.
34
+ */
35
+ const isMarkerList = array => {
36
+ if (!markerLists.has(array)) {
37
+ const markers = new Set(array.elements.filter(element => element.type === 'String' && MARKER.test(element.value)).map(element => element.value));
38
+ markerLists.set(array, markers.size > 1);
39
+ }
40
+
41
+ return markerLists.get(array);
42
+ };
43
+
44
+ context.on('String', (node, parent) => {
26
45
  const {value} = node;
27
46
  const isMarker = MARKER.test(value);
28
47
 
@@ -30,11 +49,15 @@ const create = context => {
30
49
  return;
31
50
  }
32
51
 
52
+ if (isMarker && parent.type === 'Array' && isMarkerList(parent)) {
53
+ return;
54
+ }
55
+
33
56
  return {
34
57
  node,
35
58
  messageId: MESSAGE_ID,
36
59
  // A placeholder word never spans lines, so the message shows only its line of a block string. A marker counts only as the whole value, so a `TODO` line is not that line.
37
- data: {value: isMarker ? value : value.split('\n').find(line => PLACEHOLDER_WORDS.test(line))},
60
+ data: {value: escapeInvisibleCharacters(isMarker ? value : value.split('\n').find(line => PLACEHOLDER_WORDS.test(line)))},
38
61
  };
39
62
  });
40
63
  };
@@ -23,14 +23,12 @@ Words that name a size at the end of a longer key, as in `max-size` or `buffer_b
23
23
  const SIZE_ENDINGS = new Set(['bytes', 'size']);
24
24
 
25
25
  /*
26
- Words that make a key about time, such as `window-size`, `cpu-time`, `chunk-size` in a time-series database, or `cache-expiry`. A plural, such as `intervals`, counts too.
26
+ Words that make a key about time, such as `window-size`, `cpu-time`, or `cache-expiry`. A plural, such as `intervals`, counts too. `block` and `chunk` are not, because a block size or a chunk size is in bytes, as in Hadoop, Spark, RocksDB, and Fluentd, and a block or chunk duration is named with `duration`.
27
27
  */
28
28
  const TIME_WORDS = new Set([
29
29
  'age',
30
30
  'backoff',
31
- 'block',
32
31
  'bucket',
33
- 'chunk',
34
32
  'cooldown',
35
33
  'deadline',
36
34
  'delay',
@@ -75,7 +73,7 @@ function isSizeKey(key) {
75
73
  */
76
74
  const create = context => {
77
75
  context.on('Member', ({key, value}) => {
78
- if (value.type !== 'Duration' || !isSizeLike(value) || !isSizeKey(key.segments.at(-1).value)) {
76
+ if (value.type !== 'Duration' || !isSizeLike(value) || !isSizeKey(key.value)) {
79
77
  return;
80
78
  }
81
79
 
@@ -1,3 +1,5 @@
1
+ import {escapeInvisibleCharacters} from './utils/index.js';
2
+
1
3
  /**
2
4
  @import {SomlContext} from './rule/soml-context.js';
3
5
  @import {SomlRule} from './rule/to-eslint-rule.js';
@@ -14,7 +16,7 @@ const messages = {
14
16
  const create = context => {
15
17
  const [{form}] = context.options;
16
18
 
17
- context.on('KeySegment', node => {
19
+ context.on('Key', node => {
18
20
  if (node.value.normalize(form) === node.value) {
19
21
  return;
20
22
  }
@@ -23,7 +25,7 @@ const create = context => {
23
25
  node,
24
26
  messageId: MESSAGE_ID,
25
27
  data: {
26
- key: node.value,
28
+ key: escapeInvisibleCharacters(node.value),
27
29
  form,
28
30
  },
29
31
  };
@@ -1,5 +1,4 @@
1
1
  import {isBareKey} from 'soml-lang';
2
- import {isDecimalPoint} from './utils/index.js';
3
2
 
4
3
  /**
5
4
  @import {SomlContext} from './rule/soml-context.js';
@@ -11,36 +10,22 @@ const messages = {
11
10
  [MESSAGE_ID]: 'The key `{{key}}` does not need quotes.',
12
11
  };
13
12
 
14
- /*
15
- The value of a segment that is bare or can be, or an empty string for a segment that must be quoted or is missing.
16
- */
17
- const getBareValue = segment => segment !== undefined && isBareKey(segment.value) ? segment.value : '';
18
-
19
13
  /**
20
14
  @param {SomlContext} context
21
15
  */
22
16
  const create = context => {
23
- context.on('Key', function * (key) {
24
- for (const [index, node] of key.segments.entries()) {
25
- if (node.style === 'bare' || !isBareKey(node.value)) {
26
- continue;
27
- }
28
-
29
- // Without quotes, a digit on both sides of a `.` reads as a number, as in `1.5`, which `no-numeric-dotted-keys` reports. A neighbor that must keep its quotes, as in `'a 1'`, does not.
30
- const previous = getBareValue(key.segments[index - 1]);
31
- const next = getBareValue(key.segments[index + 1]);
32
-
33
- if (isDecimalPoint(previous, node.value) || isDecimalPoint(node.value, next)) {
34
- continue;
35
- }
36
-
37
- yield {
38
- node,
39
- messageId: MESSAGE_ID,
40
- data: {key: node.value},
41
- fix: fixer => fixer.replaceText(node, node.value),
42
- };
17
+ context.on('Key', node => {
18
+ // A key with a `.` is not a bare key, so `'a.b'` keeps its quotes. Without them, `a.b` is a parse error.
19
+ if (node.style === 'bare' || !isBareKey(node.value)) {
20
+ return;
43
21
  }
22
+
23
+ return {
24
+ node,
25
+ messageId: MESSAGE_ID,
26
+ data: {key: node.value},
27
+ fix: fixer => fixer.replaceText(node, node.value),
28
+ };
44
29
  });
45
30
  };
46
31
 
@@ -3,7 +3,7 @@ import {canBeLiteral, hasDoubledBackslashes, hasInvisibleCharacter} from './util
3
3
  /**
4
4
  @import {SomlContext} from './rule/soml-context.js';
5
5
  @import {SomlRule} from './rule/to-eslint-rule.js';
6
- @import {StringNode, KeySegmentNode} from 'soml-lang';
6
+ @import {StringNode, KeyNode} from 'soml-lang';
7
7
  */
8
8
 
9
9
  const MESSAGE_ID = 'prefer-literal-strings';
@@ -14,7 +14,7 @@ const messages = {
14
14
  /**
15
15
  The literal spelling of a string or key with the same value, or `undefined` when there is none.
16
16
 
17
- @param {StringNode | KeySegmentNode} node
17
+ @param {StringNode | KeyNode} node
18
18
  @param {string} text The source text of the node.
19
19
  @returns {string | undefined}
20
20
  */
@@ -39,7 +39,7 @@ function getLiteral(node, text) {
39
39
  @param {SomlContext} context
40
40
  */
41
41
  const create = context => {
42
- context.on(['String', 'KeySegment'], node => {
42
+ context.on(['String', 'Key'], node => {
43
43
  if (node.style !== 'escaped') {
44
44
  return;
45
45
  }
@@ -1,6 +1,5 @@
1
- import naturalCompare from 'natural-compare';
2
1
  import {compareKeys} from 'soml-lang';
3
- import {reorderMembers} from './utils/index.js';
2
+ import {isDisabledAnywhere, reorderMembers} from './utils/index.js';
4
3
 
5
4
  /**
6
5
  @import {SomlContext} from './rule/soml-context.js';
@@ -13,14 +12,71 @@ const messages = {
13
12
  [MESSAGE_ID]: 'Expected keys to be in {{sortName}} case-{{sensitivity}} {{direction}} order. `{{thisName}}` should be before `{{previousName}}`.',
14
13
  };
15
14
 
15
+ const isDigit = character => character >= '0' && character <= '9';
16
+
17
+ /*
18
+ The end of the run of digits that starts at `index`.
19
+ */
20
+ function getDigitsEnd(text, index) {
21
+ while (index < text.length && isDigit(text[index])) {
22
+ index++;
23
+ }
24
+
25
+ return index;
26
+ }
27
+
28
+ /*
29
+ Compares two keys by code point, as canonical form does, except that two runs of digits at the same place are compared by their value, so `a2` comes before `a10` and `a9` before `a010`. A value has any number of digits, so the runs are compared as text, without their leading zeros. When only the leading zeros differ, as in `a01` and `a1`, code point order decides, so two different keys are never equal.
30
+ */
31
+ function compareNatural(first, second) {
32
+ let firstIndex = 0;
33
+ let secondIndex = 0;
34
+
35
+ while (firstIndex < first.length && secondIndex < second.length) {
36
+ if (isDigit(first[firstIndex]) && isDigit(second[secondIndex])) {
37
+ const firstEnd = getDigitsEnd(first, firstIndex);
38
+ const secondEnd = getDigitsEnd(second, secondIndex);
39
+ const firstNumber = first.slice(firstIndex, firstEnd).replace(/^0+/v, '');
40
+ const secondNumber = second.slice(secondIndex, secondEnd).replace(/^0+/v, '');
41
+
42
+ if (firstNumber.length !== secondNumber.length) {
43
+ return firstNumber.length - secondNumber.length;
44
+ }
45
+
46
+ if (firstNumber !== secondNumber) {
47
+ return firstNumber < secondNumber ? -1 : 1;
48
+ }
49
+
50
+ firstIndex = firstEnd;
51
+ secondIndex = secondEnd;
52
+ continue;
53
+ }
54
+
55
+ const firstCodePoint = first.codePointAt(firstIndex);
56
+ const secondCodePoint = second.codePointAt(secondIndex);
57
+
58
+ if (firstCodePoint !== secondCodePoint) {
59
+ return firstCodePoint - secondCodePoint;
60
+ }
61
+
62
+ // The two code points are equal, so they have the same length.
63
+ const length = firstCodePoint > 0xFF_FF ? 2 : 1;
64
+ firstIndex += length;
65
+ secondIndex += length;
66
+ }
67
+
68
+ // A key that ends first comes first, as `a` before `a1`.
69
+ return (first.length - firstIndex) - (second.length - secondIndex) || compareKeys(first, second);
70
+ }
71
+
16
72
  /**
17
73
  @param {SomlContext} context
18
74
  */
19
75
  const create = context => {
20
76
  const {sourceCode} = context;
21
77
  const [direction, {caseSensitive, natural, minKeys, allowLineSeparatedGroups}] = context.options;
22
- // Without `natural`, keys are in the order of canonical form, by code point. The `<` operator compares UTF-16 code units, which puts an emoji before U+FF61.
23
- const compare = natural ? naturalCompare : compareKeys;
78
+ // Keys are in the order of canonical form, by code point. The `<` operator compares UTF-16 code units, which puts an emoji before U+FF61.
79
+ const compare = natural ? compareNatural : compareKeys;
24
80
  const normalize = caseSensitive ? name => name : name => name.toLowerCase();
25
81
  const sign = direction === 'asc' ? 1 : -1;
26
82
 
@@ -56,25 +112,11 @@ const create = context => {
56
112
  }
57
113
 
58
114
  /*
59
- The decoded key paths, so `'a'` and `a` sort the same. Paths are compared segment by segment, and a path sorts before the longer paths it starts, so the members under one dotted prefix stay together, and a quoted `'a.b'` is not mixed in with them.
115
+ The decoded keys, so `'a'` and `a` sort the same.
60
116
  */
61
- const paths = new Map(node.members.map(member => [member, member.key.segments.map(segment => normalize(segment.value))]));
62
-
63
- const compareMembers = (first, second) => {
64
- const firstPath = paths.get(first);
65
- const secondPath = paths.get(second);
66
-
67
- // eslint-disable-next-line unicorn/prefer-iterator-zip -- `Iterator.zip()` is not in Node.js 22.
68
- for (let index = 0; index < Math.min(firstPath.length, secondPath.length); index++) {
69
- const difference = compare(firstPath[index], secondPath[index]);
70
-
71
- if (difference !== 0) {
72
- return sign * difference;
73
- }
74
- }
117
+ const keys = new Map(node.members.map(member => [member, normalize(member.key.value)]));
75
118
 
76
- return sign * (firstPath.length - secondPath.length);
77
- };
119
+ const compareMembers = (first, second) => sign * compare(keys.get(first), keys.get(second));
78
120
 
79
121
  // A blank line starts a group that is sorted on its own, when that is allowed.
80
122
  const groups = [];
@@ -110,7 +152,7 @@ const create = context => {
110
152
  sortName: natural ? 'natural' : 'code point',
111
153
  },
112
154
  // The fix sorts the whole object at once, so one run of `--fix` is enough, and only the first problem carries it. Giving every problem the same fix would build it once per problem.
113
- fix: isFirst ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
155
+ fix: isFirst && !isDisabledAnywhere(context) ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
114
156
  };
115
157
 
116
158
  isFirst = false;
@@ -18,9 +18,9 @@ const BLOCK_START = /^\**(?=[^\s*])/v;
18
18
  const BLOCK_END = /(?<=[^\s*])\**$/v;
19
19
 
20
20
  /*
21
- A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it.
21
+ A line comment that is only a hex color, as in `#ff0000`, is almost always a color meant as an array item, which `#` turned into a comment. Adding a space would hide that mistake, so the rule leaves it, also with whitespace or another comment after it, as in `#ff0000 # red`.
22
22
  */
23
- const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8}),?$/iv;
23
+ const HEX_COLOR = /^(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8}),?(?:[\t ]+(?:#|\/\*).*|[\t ]*)$/iv;
24
24
 
25
25
  /**
26
26
  @param {SomlContext} context
@@ -12,6 +12,16 @@ export function toUnicodeEscape(character) {
12
12
  return String.raw`\u{${character.codePointAt(0).toString(16)}}`;
13
13
  }
14
14
 
15
+ /**
16
+ Write every invisible character in `text` as a `\u{…}` escape, such as for a decoded value in a message, where a control character could change the terminal and a bidirectional control could reorder the text.
17
+
18
+ @param {string} text
19
+ @returns {string}
20
+ */
21
+ export function escapeInvisibleCharacters(text) {
22
+ return text.replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
23
+ }
24
+
15
25
  /**
16
26
  Write `value` as a `"..."` string. `\`, `"`, LF, and tab use their short escapes, and every invisible character uses a `\u{…}` escape.
17
27
 
@@ -23,10 +33,9 @@ export function toEscapedString(value) {
23
33
  .replaceAll('\\', '\\\\')
24
34
  .replaceAll('"', String.raw`\"`)
25
35
  .replaceAll('\n', String.raw`\n`)
26
- .replaceAll('\t', String.raw`\t`)
27
- .replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
36
+ .replaceAll('\t', String.raw`\t`);
28
37
 
29
- return `"${escaped}"`;
38
+ return `"${escapeInvisibleCharacters(escaped)}"`;
30
39
  }
31
40
 
32
41
  /**
@@ -1,9 +1,11 @@
1
1
  /**
2
- The words of a key, in lowercase: `maxHeapSize`, `max-heap-size`, and `max_heap_size` all give `max`, `heap`, and `size`. A quoted key may hold dots, as Spark writes `'spark.executor.memory'`, so `.` separates words too.
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.
3
3
 
4
4
  @param {string} key
5
5
  @returns {string[]}
6
6
  */
7
7
  export default function getKeyWords(key) {
8
- return key.split(/[\-._]|(?<=[\da-z])(?=[A-Z])/v).map(word => word.toLowerCase());
8
+ return key.split(/[^\p{Letter}\p{Number}]|(?<=[\da-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/v)
9
+ .filter(word => word !== '')
10
+ .map(word => word.toLowerCase());
9
11
  }
@@ -1,16 +1,17 @@
1
1
  const BACKSLASH_RUN = /\\+/gv;
2
2
  const DOUBLED_ESCAPE = /\\\\[.DSWbdsw]/v;
3
+ const QUOTE = /["']/v;
3
4
 
4
5
  /**
5
6
  Whether every backslash in a value is doubled, as in `^\\d+$`, the way a JSON or JavaScript string writes a regex escape. In a literal string, `\\` is two backslashes, so such a value most likely came from a `"…"` string and was meant with one.
6
7
 
7
- A Windows network path, as in `\\server\share`, has a single backslash too, so it is not counted. A value that holds a `"`, as JSON text in a string does, is not counted either.
8
+ A Windows network path, as in `\\server\share`, has a single backslash too, so it is not counted. A value that holds a quote is not counted either, because it is code with strings in it, which doubles its backslashes on purpose, as JSON text does with `"` and a JavaScript or SQL snippet in a block string can with `'`.
8
9
 
9
10
  @param {string} value
10
11
  @returns {boolean}
11
12
  */
12
13
  export default function hasDoubledBackslashes(value) {
13
14
  return DOUBLED_ESCAPE.test(value)
14
- && !value.includes('"')
15
+ && !QUOTE.test(value)
15
16
  && value.match(BACKSLASH_RUN).every(run => run.length % 2 === 0);
16
17
  }
@@ -1,9 +1,14 @@
1
1
  export {invisibleCharacters, hasInvisibleCharacter} from './invisible-characters.js';
2
- export {toUnicodeEscape, toEscapedString, canBeLiteral} from './escape-string.js';
3
- export {default as isDecimalPoint} from './is-decimal-point.js';
2
+ export {
3
+ toUnicodeEscape,
4
+ escapeInvisibleCharacters,
5
+ toEscapedString,
6
+ canBeLiteral,
7
+ } from './escape-string.js';
4
8
  export {reorderMembers, hasAdjacentComment} from './reorder-members.js';
5
9
  export {default as trimWhitespace} from './trim-whitespace.js';
6
10
  export {isComment, isPunctuator} from './token.js';
7
11
  export {default as trimEnd} from './trim-end.js';
8
12
  export {default as hasDoubledBackslashes} from './has-doubled-backslashes.js';
9
13
  export {default as getKeyWords} from './get-key-words.js';
14
+ export {default as isDisabledAnywhere} from './is-disabled-anywhere.js';
@@ -1,7 +1,11 @@
1
1
  /**
2
- Characters that a reader cannot see, or cannot tell apart from a space: bidirectional controls (as in Trojan Source), zero-width characters, 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.
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
+
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.
3
5
  */
4
- const invisibleCharacter = /[[\p{Control}\p{White_Space}\u{AD}\u{61C}\u{200B}\u{200E}\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{FEFF}]--[\t\n ]]/v;
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;
8
+ const invisibleCharacter = new RegExp(`${characters.source}|${tags.source}`, 'v');
5
9
 
6
10
  /**
7
11
  The same characters, with the `g` flag. Use it only with `String#matchAll()` and `String#replaceAll()`, which start from the beginning when `lastIndex` is 0 and leave it at 0, so the shared regex holds no state between uses.
@@ -0,0 +1,19 @@
1
+ import {ConfigCommentParser} from '@eslint/plugin-kit';
2
+
3
+ /**
4
+ @import {SomlContext} from '../rule/soml-context.js';
5
+ */
6
+
7
+ const commentParser = new ConfigCommentParser();
8
+
9
+ /**
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
+
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.
13
+
14
+ @param {SomlContext} context
15
+ @returns {boolean}
16
+ */
17
+ export default function isDisabledAnywhere(context) {
18
+ return context.sourceCode.getDisableDirectives().directives.some(({type, value}) => type !== 'enable' && (value === '' || Object.hasOwn(commentParser.parseListConfig(value), context.id)));
19
+ }
@@ -1,69 +0,0 @@
1
- import {isDecimalPoint} 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_ERROR = 'no-numeric-dotted-keys/error';
9
- const MESSAGE_ID_SUGGESTION = 'no-numeric-dotted-keys/suggestion';
10
- const messages = {
11
- [MESSAGE_ID_ERROR]: '`{{key}}` is a nested key, because `.` separates key segments. It is not one key.',
12
- [MESSAGE_ID_SUGGESTION]: 'Quote it as one key: `{{replacement}}`.',
13
- };
14
-
15
- /**
16
- @param {SomlContext} context
17
- */
18
- const create = context => {
19
- context.on('Key', node => {
20
- const {segments} = node;
21
-
22
- // Only between bare segments, because a quoted segment shows that the dot separates.
23
- const hasDecimalPoint = segments.slice(1).some((next, index) => segments[index].style === 'bare' && next.style === 'bare' && isDecimalPoint(segments[index].value, next.value));
24
-
25
- if (!hasDecimalPoint) {
26
- return;
27
- }
28
-
29
- const key = context.sourceCode.getText(node);
30
- const problem = {
31
- node,
32
- messageId: MESSAGE_ID_ERROR,
33
- data: {key},
34
- };
35
-
36
- // A key with quoted segments has no single obvious meaning, so it gets no suggestion. Bare segments never contain `'`.
37
- if (segments.every(segment => segment.style === 'bare')) {
38
- const replacement = `'${key}'`;
39
- problem.suggest = [
40
- {
41
- messageId: MESSAGE_ID_SUGGESTION,
42
- data: {replacement},
43
- fix: fixer => fixer.replaceText(node, replacement),
44
- },
45
- ];
46
- }
47
-
48
- return problem;
49
- });
50
- };
51
-
52
- /**
53
- @type {SomlRule}
54
- */
55
- const config = {
56
- create,
57
- meta: {
58
- type: 'problem',
59
- docs: {
60
- description: 'Disallow dotted keys that read as one number.',
61
- recommended: 'unopinionated',
62
- },
63
- hasSuggestions: true,
64
- messages,
65
- languages: ['soml/soml'],
66
- },
67
- };
68
-
69
- export default config;
@@ -1,102 +0,0 @@
1
- import {reorderMembers} from './utils/index.js';
2
-
3
- /**
4
- @import {SomlContext} from './rule/soml-context.js';
5
- @import {SomlRule} from './rule/to-eslint-rule.js';
6
- @import {MemberNode} from 'soml-lang';
7
- */
8
-
9
- const MESSAGE_ID = 'no-scattered-dotted-keys';
10
- const messages = {
11
- [MESSAGE_ID]: 'Keep the keys under `{{prefix}}` together. Another key comes between them.',
12
- };
13
-
14
- /*
15
- The first `length` segments of a key path as one string, for a map. JSON keeps the segments apart, whatever characters they hold.
16
- */
17
- const getPrefix = (member, length) => JSON.stringify(member.key.segments.slice(0, length).map(segment => segment.value));
18
-
19
- /*
20
- The members grouped by their dotted prefixes, level by level, each group where its first member is. Members that are not under a shared prefix keep their order.
21
- */
22
- function group(members, depth = 0) {
23
- const groups = new Map();
24
-
25
- for (const member of members) {
26
- const {segments} = member.key;
27
- // A member with no segment past this depth is a group of its own.
28
- const groupKey = segments.length > depth + 1 ? segments[depth].value : Symbol('member');
29
-
30
- if (!groups.has(groupKey)) {
31
- groups.set(groupKey, []);
32
- }
33
-
34
- groups.get(groupKey).push(member);
35
- }
36
-
37
- return groups.values().flatMap(members => members.length > 1 ? group(members, depth + 1) : members).toArray();
38
- }
39
-
40
- /**
41
- @param {SomlContext} context
42
- */
43
- const create = context => {
44
- const {sourceCode} = context;
45
-
46
- context.on('Object', function * (node) {
47
- // The index of the last member under each prefix.
48
- const lastIndexes = new Map();
49
- let isFirst = true;
50
-
51
- for (const [index, member] of node.members.entries()) {
52
- // The shortest prefix whose members another key came between.
53
- let scatteredPrefix;
54
-
55
- for (let length = 1; length < member.key.segments.length; length++) {
56
- const prefix = getPrefix(member, length);
57
- const lastIndex = lastIndexes.get(prefix);
58
-
59
- if (scatteredPrefix === undefined && lastIndex !== undefined && lastIndex !== index - 1) {
60
- scatteredPrefix = member.key.segments.slice(0, length);
61
- }
62
-
63
- lastIndexes.set(prefix, index);
64
- }
65
-
66
- if (scatteredPrefix === undefined) {
67
- continue;
68
- }
69
-
70
- yield {
71
- node: member.key,
72
- messageId: MESSAGE_ID,
73
- data: {
74
- prefix: sourceCode.text.slice(sourceCode.getRange(scatteredPrefix[0])[0], sourceCode.getRange(scatteredPrefix.at(-1))[1]),
75
- },
76
- // The fix moves every member 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.
77
- fix: isFirst ? reorderMembers(context, node.members, group(node.members)) : undefined,
78
- };
79
-
80
- isFirst = false;
81
- }
82
- });
83
- };
84
-
85
- /**
86
- @type {SomlRule}
87
- */
88
- const config = {
89
- create,
90
- meta: {
91
- type: 'suggestion',
92
- docs: {
93
- description: 'Require the dotted keys under one prefix to be next to each other.',
94
- recommended: true,
95
- },
96
- fixable: 'code',
97
- messages,
98
- languages: ['soml/soml'],
99
- },
100
- };
101
-
102
- export default config;
@@ -1,12 +0,0 @@
1
- const isDigit = character => character >= '0' && character <= '9';
2
-
3
- /**
4
- Whether a `.` between two key segments has a digit on each side, so that the key reads as a number, as in `3.14` or `python3.12`, while it is two segments.
5
-
6
- @param {string} before The segment before the dot.
7
- @param {string} after The segment after the dot.
8
- @returns {boolean}
9
- */
10
- export default function isDecimalPoint(before, after) {
11
- return isDigit(before.at(-1)) && isDigit(after[0]);
12
- }