eslint-soml 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,4 +1,5 @@
1
- import {hasAdjacentComment, isPunctuator} from './utils/index.js';
1
+ import {stringifyValue} from 'soml-lang';
2
+ import {escapeInvisibleCharacters, hasAdjacentComment, isPunctuator} from './utils/index.js';
2
3
 
3
4
  /**
4
5
  @import {SomlContext} from './rule/soml-context.js';
@@ -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. 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
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
  }
@@ -81,7 +81,7 @@ const config = {
81
81
  meta: {
82
82
  type: 'problem',
83
83
  docs: {
84
- description: 'Disallow floats that cannot be stored exactly as written.',
84
+ description: 'Disallow floats whose written digits are lost when they are stored.',
85
85
  recommended: 'unopinionated',
86
86
  },
87
87
  messages,
@@ -1,4 +1,4 @@
1
- import {trimEnd} from './utils/index.js';
1
+ import {getLines} from './utils/index.js';
2
2
 
3
3
  /**
4
4
  @import {SomlContext} from './rule/soml-context.js';
@@ -22,6 +22,33 @@ function isClosingQuote(line, index, delimiter) {
22
22
  return runs.filter(run => run.length === delimiter.length).length % 2 === 1;
23
23
  }
24
24
 
25
+ /*
26
+ What may follow a closing delimiter on its line: whitespace, commas, closing brackets, and a comment. It is sticky, so it matches from `lastIndex`, which is set before every use.
27
+ */
28
+ const AFTER_DELIMITER = /[\t ]*(?:[,\]\}][\t ]*)*(?:(?:#|\/\*).*)?$/vy;
29
+
30
+ /*
31
+ Where a misplaced delimiter starts on a content line, or `undefined`: a run of exactly the delimiter's length with only what may follow a closing delimiter after it, as TOML and Java allow, in `Welcome!'''`, `Hi'''}`, or `Welcome!''' # greeting`. Only the first run with such a tail is checked, because what follows it is a comment or brackets.
32
+ */
33
+ function getMisplacedDelimiterIndex(line, delimiter, isEscaped) {
34
+ for (const match of line.matchAll(QUOTE_RUNS[delimiter[0]])) {
35
+ let {index} = match;
36
+ const end = index + match[0].length;
37
+ AFTER_DELIMITER.lastIndex = end;
38
+
39
+ if (!AFTER_DELIMITER.test(line)) {
40
+ continue;
41
+ }
42
+
43
+ // In a `"""` block, `\"` is an escaped quote, so it is not part of the run.
44
+ if (isEscaped && ESCAPING_BACKSLASHES.test(line.slice(0, index))) {
45
+ index++;
46
+ }
47
+
48
+ return index > 0 && end - index === delimiter.length && !isClosingQuote(line, index, delimiter) ? index : undefined;
49
+ }
50
+ }
51
+
25
52
  /**
26
53
  @param {SomlContext} context
27
54
  */
@@ -33,43 +60,24 @@ const create = context => {
33
60
  return;
34
61
  }
35
62
 
36
- const lines = sourceCode.getText(node).split('\n');
63
+ const lines = getLines(context, node);
37
64
  // The first line is the opening delimiter, so it is a run of one quote character.
38
- const delimiter = lines[0];
39
- let offset = sourceCode.getRange(node)[0] + delimiter.length + 1;
65
+ const delimiter = lines[0].text;
40
66
 
41
67
  // The last line holds the closing delimiter. Only the first misplaced delimiter is reported, because the lines after it, such as the opening of another block string, are content only because of it.
42
- for (const line of lines.slice(1, -1)) {
43
- // A run of exactly the delimiter's length at the end of a line, as TOML and Java allow, optionally followed by a comma.
44
- let content = trimEnd(line, ' \t');
45
-
46
- if (content.endsWith(',')) {
47
- content = trimEnd(content.slice(0, -1), ' \t');
48
- }
49
-
50
- const index = content.length - delimiter.length;
51
-
52
- if (
53
- index > 0
54
- && content.endsWith(delimiter)
55
- && content[index - 1] !== delimiter[0]
56
- && !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
- ) {
60
- const start = offset + index;
68
+ for (const {text, start} of lines.slice(1, -1)) {
69
+ const index = getMisplacedDelimiterIndex(text, delimiter, node.style === 'escaped');
61
70
 
71
+ if (index !== undefined) {
62
72
  return {
63
73
  loc: {
64
- start: sourceCode.getLocFromIndex(start),
65
- end: sourceCode.getLocFromIndex(start + delimiter.length),
74
+ start: sourceCode.getLocFromIndex(start + index),
75
+ end: sourceCode.getLocFromIndex(start + index + delimiter.length),
66
76
  },
67
77
  messageId: MESSAGE_ID,
68
78
  data: {delimiter},
69
79
  };
70
80
  }
71
-
72
- offset += line.length + 1;
73
81
  }
74
82
  });
75
83
  };
@@ -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
  }
@@ -66,7 +66,7 @@ const config = {
66
66
  meta: {
67
67
  type: 'suggestion',
68
68
  docs: {
69
- description: 'Prefer literal strings, `\'…\'`, when a string has no escapes.',
69
+ description: 'Prefer literal strings, `\'…\'`, when a string does not need escapes.',
70
70
  recommended: true,
71
71
  },
72
72
  fixable: 'code',
@@ -31,6 +31,7 @@ Convert a problem from a listener to an ESLint problem. A suggestion gets the `d
31
31
  */
32
32
  export default function toEslintProblem(somlProblem) {
33
33
  const {fix, suggest, ...problem} = somlProblem;
34
+
34
35
  /**
35
36
  @type {EslintProblem}
36
37
  */
@@ -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,70 @@ 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 a surrogate pair is followed by the same low surrogate in both.
63
+ firstIndex++;
64
+ secondIndex++;
65
+ }
66
+
67
+ // A key that ends first comes first, as `a` before `a1`.
68
+ return (first.length - firstIndex) - (second.length - secondIndex) || compareKeys(first, second);
69
+ }
70
+
16
71
  /**
17
72
  @param {SomlContext} context
18
73
  */
19
74
  const create = context => {
20
75
  const {sourceCode} = context;
21
76
  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;
77
+ // 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.
78
+ const compare = natural ? compareNatural : compareKeys;
24
79
  const normalize = caseSensitive ? name => name : name => name.toLowerCase();
25
80
  const sign = direction === 'asc' ? 1 : -1;
26
81
 
@@ -38,6 +93,9 @@ const create = context => {
38
93
  return commentLines;
39
94
  };
40
95
 
96
+ // Whether the fix is allowed, found once per file, because the directives would be parsed again for every object.
97
+ let isFixable;
98
+
41
99
  /**
42
100
  Whether a blank line separates two members. A line in a comment is not blank.
43
101
 
@@ -56,25 +114,11 @@ const create = context => {
56
114
  }
57
115
 
58
116
  /*
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.
117
+ The decoded keys, so `'a'` and `a` sort the same.
60
118
  */
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
- }
119
+ const keys = new Map(node.members.map(member => [member, normalize(member.key.value)]));
75
120
 
76
- return sign * (firstPath.length - secondPath.length);
77
- };
121
+ const compareMembers = (first, second) => sign * compare(keys.get(first), keys.get(second));
78
122
 
79
123
  // A blank line starts a group that is sorted on its own, when that is allowed.
80
124
  const groups = [];
@@ -110,7 +154,7 @@ const create = context => {
110
154
  sortName: natural ? 'natural' : 'code point',
111
155
  },
112
156
  // The fix sorts the whole object at once, so one run of `--fix` is enough, and only the first problem carries it. Giving every problem the same fix would build it once per problem.
113
- fix: isFirst ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
157
+ fix: isFirst && (isFixable ??= !isDisabledAnywhere(context)) ? reorderMembers(context, node.members, groups.flatMap(group => group.toSorted(compareMembers))) : undefined,
114
158
  };
115
159
 
116
160
  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`. A word after an acronym has at least two lowercase letters, so a plural acronym, as in `maxCPUs`, stays one word. Every character that is not a letter or a digit separates words, so a quoted key works too, as in `'spark.executor.memory'` (Spark) or `'php_admin_value[memory_limit]'` (PHP-FPM). A separator at an end or next to another one makes no empty word, so `_size` is one word.
3
3
 
4
4
  @param {string} key
5
5
  @returns {string[]}
6
6
  */
7
7
  export default function getKeyWords(key) {
8
- return key.split(/[\-._]|(?<=[\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]{2})/v)
9
+ .filter(word => word !== '')
10
+ .map(word => word.toLowerCase());
9
11
  }