eslint-soml 0.0.1

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.
Files changed (37) hide show
  1. package/index.d.ts +33 -0
  2. package/index.js +47 -0
  3. package/language/soml-language.js +72 -0
  4. package/language/soml-source-code.js +277 -0
  5. package/license +9 -0
  6. package/package.json +83 -0
  7. package/readme.md +175 -0
  8. package/rules/format.js +134 -0
  9. package/rules/index.js +17 -0
  10. package/rules/key-name-casing.js +94 -0
  11. package/rules/no-block-string-trailing-whitespace.js +64 -0
  12. package/rules/no-empty-keys.js +43 -0
  13. package/rules/no-invisible-characters.js +90 -0
  14. package/rules/no-loss-of-precision.js +96 -0
  15. package/rules/no-mixed-number-types.js +81 -0
  16. package/rules/no-numeric-dotted-keys.js +81 -0
  17. package/rules/no-unnormalized-keys.js +62 -0
  18. package/rules/numeric-separators-style.js +136 -0
  19. package/rules/prefer-bare-keys.js +63 -0
  20. package/rules/prefer-literal-strings.js +85 -0
  21. package/rules/prefer-utc-instants.js +53 -0
  22. package/rules/rule/index.js +7 -0
  23. package/rules/rule/soml-context.js +47 -0
  24. package/rules/rule/soml-listeners.js +63 -0
  25. package/rules/rule/to-eslint-create.js +34 -0
  26. package/rules/rule/to-eslint-listener.js +42 -0
  27. package/rules/rule/to-eslint-problem.js +53 -0
  28. package/rules/rule/to-eslint-rule-fixer.js +56 -0
  29. package/rules/rule/to-eslint-rule.js +36 -0
  30. package/rules/rule/to-eslint-rules.js +9 -0
  31. package/rules/rule/utilities.js +29 -0
  32. package/rules/sort-keys.js +187 -0
  33. package/rules/spaced-comment.js +83 -0
  34. package/rules/utils/escape-string.js +30 -0
  35. package/rules/utils/get-documentation-url.js +15 -0
  36. package/rules/utils/index.js +2 -0
  37. package/rules/utils/invisible-characters.js +26 -0
@@ -0,0 +1,56 @@
1
+ import {forEachFixOrProblem} from './utilities.js';
2
+
3
+ /**
4
+ @import {RuleTextEditor} from '@eslint/core';
5
+ @import {Node} from 'soml-lang';
6
+ @import {EslintProblem} from './to-eslint-problem.js';
7
+ */
8
+
9
+ class FixAbortError extends Error {
10
+ name = 'FixAbortError';
11
+ }
12
+
13
+ const fixOptions = {
14
+ abort() {
15
+ throw new FixAbortError('Fix aborted.');
16
+ },
17
+ };
18
+
19
+ /**
20
+ @typedef {NonNullable<EslintProblem['fix']>} EslintRuleFixer
21
+ @typedef {RuleTextEditor<Node>} SomlFixer
22
+ @typedef {ReturnType<SomlFixer['replaceText']> | void} EslintReportFixer
23
+ @typedef {EslintReportFixer | Iterable<EslintReportFixer>} SomlReportFixer
24
+ @typedef {(fixer: SomlFixer, options: typeof fixOptions) => SomlReportFixer} SomlRuleFixer
25
+ */
26
+
27
+ /**
28
+ Convert Soml style fix function to ESLint style fix function
29
+
30
+ @param {SomlRuleFixer} fix
31
+ @returns {EslintRuleFixer}
32
+ */
33
+ export default function toEslintRuleFixer(fix) {
34
+ /**
35
+ @param {SomlFixer} fixer
36
+ */
37
+ return fixer => {
38
+ const eslintReport = [];
39
+
40
+ try {
41
+ const somlReport = fix(fixer, fixOptions);
42
+ forEachFixOrProblem(somlReport, eslintFix => {
43
+ eslintReport.push(eslintFix);
44
+ });
45
+
46
+ return eslintReport;
47
+ } catch (error) {
48
+ if (error instanceof FixAbortError) {
49
+ return [];
50
+ }
51
+
52
+ /* c8 ignore next */
53
+ throw error;
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,36 @@
1
+ import getDocumentationUrl from '../utils/get-documentation-url.js';
2
+ import toEslintCreate from './to-eslint-create.js';
3
+
4
+ /**
5
+ @import {RuleDefinition} from '@eslint/core';
6
+ @import {SomlCreate} from './to-eslint-create.js';
7
+ */
8
+
9
+ /**
10
+ @typedef {Omit<RuleDefinition, 'create'> & {
11
+ create: SomlCreate
12
+ }} SomlRule
13
+ */
14
+
15
+ /**
16
+ Convert Soml rule to ESLint rule
17
+
18
+ @param {string} ruleId
19
+ @param {SomlRule} somlRule
20
+ @returns {RuleDefinition}
21
+ */
22
+ export default function toEslintRule(ruleId, somlRule) {
23
+ return {
24
+ meta: {
25
+ // If there are no options, add `[]` so ESLint can validate that no data is passed to the rule.
26
+ // https://github.com/not-an-aardvark/eslint-plugin-eslint-plugin/blob/master/docs/rules/require-meta-schema.md
27
+ schema: [],
28
+ ...somlRule.meta,
29
+ docs: {
30
+ ...somlRule.meta.docs,
31
+ url: getDocumentationUrl(ruleId),
32
+ },
33
+ },
34
+ create: toEslintCreate(somlRule.create),
35
+ };
36
+ }
@@ -0,0 +1,9 @@
1
+ import toEslintRule from './to-eslint-rule.js';
2
+
3
+ export default function toEslintRules(rules) {
4
+ return Object.fromEntries(Object.entries(rules).map(([ruleId, rule]) => [
5
+ ruleId,
6
+ toEslintRule(ruleId, rule),
7
+ ]));
8
+ }
9
+
@@ -0,0 +1,29 @@
1
+ const isIterable = object => typeof object?.[Symbol.iterator] === 'function';
2
+
3
+ /**
4
+ @import * as ESLint from 'eslint';
5
+ @import {SomlReportFixer} from './to-eslint-rule-fixer.js';
6
+ @import {SomlProblems, SomlProblem} from './to-eslint-problem.js';
7
+ */
8
+
9
+ /**
10
+ Call `callback` for each ESLint fix or ESLint problem in `value`, flattening nested iterables.
11
+
12
+ This runs for every listener call of every rule, so it deliberately avoids generators and intermediate arrays.
13
+
14
+ @template {SomlReportFixer | SomlProblems} ValueType
15
+
16
+ @param {ValueType} value
17
+ @param {(value: ValueType extends SomlReportFixer ? ESLint.Rule.Fix : SomlProblem) => void} callback
18
+ @returns {void}
19
+ */
20
+ export function forEachFixOrProblem(value, callback) {
21
+ if (!isIterable(value)) {
22
+ callback(value);
23
+ return;
24
+ }
25
+
26
+ for (const element of value) {
27
+ forEachFixOrProblem(element, callback);
28
+ }
29
+ }
@@ -0,0 +1,187 @@
1
+ import naturalCompare from 'natural-compare';
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 = 'sort-keys';
10
+ const messages = {
11
+ [MESSAGE_ID]: 'Expected keys to be in {{sortName}} case-{{sensitivity}} {{direction}} order. `{{thisName}}` should be before `{{previousName}}`.',
12
+ };
13
+
14
+ const compareAlphanumeric = (first, second) => {
15
+ if (first === second) {
16
+ return 0;
17
+ }
18
+
19
+ return first < second ? -1 : 1;
20
+ };
21
+
22
+ /*
23
+ The decoded key path, so `'a'` and `a` sort the same.
24
+ */
25
+ const getName = member => member.key.segments.map(segment => segment.value).join('.');
26
+
27
+ /**
28
+ @param {SomlContext} context
29
+ */
30
+ const create = context => {
31
+ const {sourceCode} = context;
32
+ const [direction, {caseSensitive, natural, minKeys, allowLineSeparatedGroups}] = context.options;
33
+ const compare = natural ? naturalCompare : compareAlphanumeric;
34
+ const normalize = caseSensitive ? name => name : name => name.toLowerCase();
35
+ const sign = direction === 'asc' ? 1 : -1;
36
+ const isInOrder = (previous, next) => sign * compare(normalize(previous), normalize(next)) <= 0;
37
+
38
+ /**
39
+ Whether a blank line separates two members. A line that holds a comment is not blank.
40
+
41
+ @param {MemberNode} previous
42
+ @param {MemberNode} member
43
+ */
44
+ function isLineSeparated(previous, member) {
45
+ const lines = sourceCode.lines.slice(sourceCode.getLoc(previous).end.line, sourceCode.getLoc(member).start.line - 1);
46
+ const commentLines = new Set(sourceCode.getAllComments().flatMap(comment => {
47
+ const {start, end} = sourceCode.getLoc(comment);
48
+ return Array.from({length: end.line - start.line + 1}, (_, index) => start.line + index);
49
+ }));
50
+
51
+ return lines.some((line, index) => line.trim() === '' && !commentLines.has(sourceCode.getLoc(previous).end.line + index + 1));
52
+ }
53
+
54
+ /**
55
+ Whether a comment is directly before or after a member, so that swapping members would move it to the wrong one.
56
+
57
+ @param {MemberNode} member
58
+ */
59
+ function hasAdjacentComment(member) {
60
+ const isComment = token => token?.type === 'Line' || token?.type === 'Block';
61
+ let after = sourceCode.getTokenAfter(member, {includeComments: true});
62
+
63
+ if (after?.value === ',') {
64
+ after = sourceCode.getTokenAfter(after, {includeComments: true});
65
+ }
66
+
67
+ return isComment(sourceCode.getTokenBefore(member, {includeComments: true})) || isComment(after);
68
+ }
69
+
70
+ context.on('Object', function * (node) {
71
+ if (node.members.length < minKeys) {
72
+ return;
73
+ }
74
+
75
+ // A blank line starts a group that is sorted on its own, when that is allowed.
76
+ const groups = [];
77
+
78
+ for (const [index, member] of node.members.entries()) {
79
+ const previous = node.members[index - 1];
80
+
81
+ if (previous === undefined || (allowLineSeparatedGroups && isLineSeparated(previous, member))) {
82
+ groups.push([]);
83
+ }
84
+
85
+ groups.at(-1).push(member);
86
+ }
87
+
88
+ /*
89
+ Sorts the whole object at once, by putting each member's text where it belongs, so one run of `--fix` is enough. Object members have no order in SOML, so this keeps the value.
90
+ */
91
+ function * fix(fixer, {abort}) {
92
+ if (node.members.some(member => hasAdjacentComment(member))) {
93
+ abort();
94
+ }
95
+
96
+ for (const group of groups) {
97
+ const sorted = group.toSorted((first, second) => sign * compare(normalize(getName(first)), normalize(getName(second))));
98
+
99
+ for (const [index, member] of group.entries()) {
100
+ if (sorted[index] !== member) {
101
+ yield fixer.replaceText(member, sourceCode.getText(sorted[index]));
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ for (const group of groups) {
108
+ for (const [index, member] of group.entries()) {
109
+ const previous = group[index - 1];
110
+
111
+ if (previous === undefined || isInOrder(getName(previous), getName(member))) {
112
+ continue;
113
+ }
114
+
115
+ yield {
116
+ node: member.key,
117
+ messageId: MESSAGE_ID,
118
+ data: {
119
+ thisName: sourceCode.getText(member.key),
120
+ previousName: sourceCode.getText(previous.key),
121
+ direction: direction === 'asc' ? 'ascending' : 'descending',
122
+ sensitivity: caseSensitive ? 'sensitive' : 'insensitive',
123
+ sortName: natural ? 'natural' : 'alphanumeric',
124
+ },
125
+ fix,
126
+ };
127
+ }
128
+ }
129
+ });
130
+ };
131
+
132
+ /**
133
+ @type {SomlRule}
134
+ */
135
+ const config = {
136
+ create,
137
+ meta: {
138
+ type: 'suggestion',
139
+ docs: {
140
+ description: 'Require object keys to be sorted.',
141
+ recommended: false,
142
+ },
143
+ fixable: 'code',
144
+ schema: [
145
+ {
146
+ enum: ['asc', 'desc'],
147
+ description: 'The sort direction.',
148
+ },
149
+ {
150
+ type: 'object',
151
+ additionalProperties: false,
152
+ properties: {
153
+ caseSensitive: {
154
+ type: 'boolean',
155
+ description: 'Whether uppercase and lowercase letters are sorted apart.',
156
+ },
157
+ natural: {
158
+ type: 'boolean',
159
+ description: 'Whether numbers in keys are sorted by their value, so `a2` comes before `a10`.',
160
+ },
161
+ minKeys: {
162
+ type: 'integer',
163
+ minimum: 2,
164
+ description: 'The number of keys from which an object must be sorted.',
165
+ },
166
+ allowLineSeparatedGroups: {
167
+ type: 'boolean',
168
+ description: 'Whether a blank line starts a new group that is sorted on its own.',
169
+ },
170
+ },
171
+ },
172
+ ],
173
+ defaultOptions: [
174
+ 'asc',
175
+ {
176
+ caseSensitive: true,
177
+ natural: false,
178
+ minKeys: 2,
179
+ allowLineSeparatedGroups: false,
180
+ },
181
+ ],
182
+ messages,
183
+ languages: ['soml/soml'],
184
+ },
185
+ };
186
+
187
+ export default config;
@@ -0,0 +1,83 @@
1
+ /**
2
+ @import {SomlContext} from './rule/soml-context.js';
3
+ @import {SomlRule} from './rule/to-eslint-rule.js';
4
+ */
5
+
6
+ const MESSAGE_ID_START = 'spaced-comment/start';
7
+ const MESSAGE_ID_END = 'spaced-comment/end';
8
+ const messages = {
9
+ [MESSAGE_ID_START]: 'Add a space after `{{marker}}`.',
10
+ [MESSAGE_ID_END]: 'Add a space before `*/`.',
11
+ };
12
+
13
+ /*
14
+ A line comment may start with more `#`, as in `## Section`. A block comment may start or end with more `*`, as in a banner.
15
+ */
16
+ const LINE_START = /^[\s#]/v;
17
+ const BLOCK_START = /^[\s*]/v;
18
+ const BLOCK_END = /[\s*]$/v;
19
+
20
+ /**
21
+ @param {SomlContext} context
22
+ */
23
+ const create = context => {
24
+ context.on('Document', function * () {
25
+ for (const comment of context.sourceCode.getAllComments()) {
26
+ const {value} = comment;
27
+ const [start, end] = context.sourceCode.getRange(comment);
28
+
29
+ if (value === '') {
30
+ continue;
31
+ }
32
+
33
+ if (comment.type === 'Line') {
34
+ if (!LINE_START.test(value)) {
35
+ yield {
36
+ node: comment,
37
+ messageId: MESSAGE_ID_START,
38
+ data: {marker: '#'},
39
+ fix: fixer => fixer.insertTextAfterRange([start, start + 1], ' '),
40
+ };
41
+ }
42
+
43
+ continue;
44
+ }
45
+
46
+ if (!BLOCK_START.test(value)) {
47
+ yield {
48
+ node: comment,
49
+ messageId: MESSAGE_ID_START,
50
+ data: {marker: '/*'},
51
+ fix: fixer => fixer.insertTextAfterRange([start, start + 2], ' '),
52
+ };
53
+ }
54
+
55
+ if (!BLOCK_END.test(value)) {
56
+ yield {
57
+ node: comment,
58
+ messageId: MESSAGE_ID_END,
59
+ fix: fixer => fixer.insertTextBeforeRange([end - 2, end], ' '),
60
+ };
61
+ }
62
+ }
63
+ });
64
+ };
65
+
66
+ /**
67
+ @type {SomlRule}
68
+ */
69
+ const config = {
70
+ create,
71
+ meta: {
72
+ type: 'layout',
73
+ docs: {
74
+ description: 'Require a space after `#` and `/*`, and before `*/`.',
75
+ recommended: true,
76
+ },
77
+ fixable: 'whitespace',
78
+ messages,
79
+ languages: ['soml/soml'],
80
+ },
81
+ };
82
+
83
+ export default config;
@@ -0,0 +1,30 @@
1
+ import {invisibleCharacterPattern} from './invisible-characters.js';
2
+
3
+ const invisibleCharacters = new RegExp(invisibleCharacterPattern, 'gv');
4
+
5
+ /**
6
+ Write a character as a `\u{…}` escape, in the one spelling SOML allows: lowercase hex digits and no leading zero.
7
+
8
+ @param {string} character
9
+ @returns {string}
10
+ */
11
+ export function toUnicodeEscape(character) {
12
+ return String.raw`\u{${character.codePointAt(0).toString(16)}}`;
13
+ }
14
+
15
+ /**
16
+ Write `value` as a `"..."` string. `\`, `"`, LF, and tab use their short escapes, and every invisible character uses a `\u{…}` escape.
17
+
18
+ @param {string} value
19
+ @returns {string}
20
+ */
21
+ export function toEscapedString(value) {
22
+ const escaped = value
23
+ .replaceAll('\\', '\\\\')
24
+ .replaceAll('"', String.raw`\"`)
25
+ .replaceAll('\n', String.raw`\n`)
26
+ .replaceAll('\t', String.raw`\t`)
27
+ .replaceAll(invisibleCharacters, character => toUnicodeEscape(character));
28
+
29
+ return `"${escaped}"`;
30
+ }
@@ -0,0 +1,15 @@
1
+ import path from 'node:path';
2
+ import packageJson from '../../package.json' with {type: 'json'};
3
+
4
+ const repositoryUrl = 'https://github.com/sindresorhus/eslint-soml';
5
+
6
+ /**
7
+ The URL of a rule's documentation, for the version of this package.
8
+
9
+ @param {string} filename The rule's file name or path.
10
+ @returns {string}
11
+ */
12
+ export default function getDocumentationUrl(filename) {
13
+ const ruleName = path.basename(filename, '.js');
14
+ return `${repositoryUrl}/blob/v${packageJson.version}/docs/rules/${ruleName}.md`;
15
+ }
@@ -0,0 +1,2 @@
1
+ export {invisibleCharacterPattern, matchInvisibleCharacters, hasInvisibleCharacter} from './invisible-characters.js';
2
+ export {toUnicodeEscape, toEscapedString} from './escape-string.js';
@@ -0,0 +1,26 @@
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.
3
+
4
+ It has no `g` flag, so it holds no state between uses. Use `matchInvisibleCharacters()` to find each one.
5
+ */
6
+ export const invisibleCharacterPattern = /[[\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;
7
+
8
+ /**
9
+ Every invisible character in `text`, as matches with an `index`.
10
+
11
+ @param {string} text
12
+ @returns {IterableIterator<RegExpExecArray>}
13
+ */
14
+ export function matchInvisibleCharacters(text) {
15
+ return text.matchAll(new RegExp(invisibleCharacterPattern, 'gv'));
16
+ }
17
+
18
+ /**
19
+ Whether `text` has an invisible character.
20
+
21
+ @param {string} text
22
+ @returns {boolean}
23
+ */
24
+ export function hasInvisibleCharacter(text) {
25
+ return invisibleCharacterPattern.test(text);
26
+ }