Skip to content

Commit 9a4a58d

Browse files
feat(eslint-plugin): add rule to sort named exports from a source
1 parent d0afcad commit 9a4a58d

File tree

6 files changed

+254
-22
lines changed

6 files changed

+254
-22
lines changed

projects/eslint-plugin/configs/general.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const config = {
115115
'@liferay/padded-test-blocks': 'error',
116116
'@liferay/prefer-length-check': 'error',
117117
'@liferay/ref-name-suffix': 'error',
118+
'@liferay/sort-exports': 'error',
118119
'@liferay/sort-import-destructures': 'error',
119120
'@liferay/sort-imports': 'error',
120121
'@liferay/use-state-naming-pattern': 'error',

projects/eslint-plugin/rules/general/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ module.exports = {
2929
'prefer-length-check': require('./lib/rules/prefer-length-check'),
3030
'ref-name-suffix': require('./lib/rules/ref-name-suffix'),
3131
'sort-class-names': require('./lib/rules/sort-class-names'),
32+
'sort-exports': require('./lib/rules/sort-exports'),
3233
'sort-import-destructures': require('./lib/rules/sort-import-destructures'),
3334
'sort-imports': require('./lib/rules/sort-imports'),
3435
'trim-class-names': require('./lib/rules/trim-class-names'),

projects/eslint-plugin/rules/general/lib/common/imports.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ function getRequireStatement(node) {
103103
}
104104

105105
function getSource(node) {
106-
if (node.type === 'ImportDeclaration') {
106+
if (node.type === 'ExportNamedDeclaration') {
107+
return node.source?.value;
108+
}
109+
else if (node.type === 'ImportDeclaration') {
107110
return node.source.value;
108111
}
109112
else if (node.type === 'VariableDeclaration') {
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* SPDX-FileCopyrightText: © 2017 Liferay, Inc. <https://liferay.com>
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
const {
7+
getLeadingComments,
8+
getTrailingComments,
9+
isAbsolute,
10+
isRelative,
11+
withScope,
12+
} = require('../common/imports');
13+
14+
const DESCRIPTION = 'exports must be sorted';
15+
16+
/**
17+
* Given two sort keys `a` and `b`, return -1, 0 or 1 to indicate
18+
* their relative ordering.
19+
*/
20+
function compare(aKey, bKey) {
21+
const [aPrefix, aName, aTieBreaker] = aKey.split(':');
22+
const [bPrefix, bName, bTieBreaker] = bKey.split(':');
23+
24+
const cmp = (a, b) => {
25+
return a < b ? -1 : a > b ? 1 : 0;
26+
};
27+
28+
return (
29+
cmp(aPrefix, bPrefix) ||
30+
cmp(ranking(aName), ranking(bName)) ||
31+
cmp(aName, bName) ||
32+
cmp(aTieBreaker, bTieBreaker)
33+
);
34+
}
35+
36+
/**
37+
* Returns a ranking for `source`. Lower-numbered ranks are considered more
38+
* important and will be sorted first in the file.
39+
*
40+
* - 0: NodeJS built-ins and dependencies declared in "package.json" files.
41+
* - 1: Absolute paths.
42+
* - 2: Relative paths.
43+
*/
44+
function ranking(source) {
45+
return isRelative(source) ? 2 : isAbsolute(source) ? 1 : 0;
46+
}
47+
48+
module.exports = {
49+
create(context) {
50+
const exportNodes = [];
51+
52+
const {visitors} = withScope();
53+
54+
function getRangeForNode(node) {
55+
const commentsBefore = getLeadingComments(node, context);
56+
const commentsAfter = getTrailingComments(node, context);
57+
58+
const first = commentsBefore[0] || node;
59+
60+
const last = commentsAfter[commentsAfter.length - 1] || node;
61+
62+
return [first.range[0], last.range[1]];
63+
}
64+
65+
return {
66+
...visitors,
67+
ExportNamedDeclaration(node) {
68+
69+
/**
70+
* Only sort exports if they have a source. Skip exports like:
71+
* export function Foo() {}
72+
* export default function Bar() {}
73+
* export {Baz};
74+
*/
75+
if (node.source) {
76+
exportNodes.push(node);
77+
}
78+
},
79+
80+
['Program:exit'](_node) {
81+
const desired = [...exportNodes].sort((a, b) =>
82+
compare(a.source.value, b.source.value)
83+
);
84+
85+
// Try to make error messages (somewhat) minimal by only
86+
// reporting from the first to the last mismatch (ie.
87+
// not a full Myers diff algorithm).
88+
89+
let firstMismatch = -1;
90+
let lastMismatch = -1;
91+
92+
for (let i = 0; i < exportNodes.length; i++) {
93+
if (exportNodes[i] !== desired[i]) {
94+
firstMismatch = i;
95+
break;
96+
}
97+
}
98+
99+
for (let i = exportNodes.length - 1; i >= 0; i--) {
100+
if (exportNodes[i] !== desired[i]) {
101+
lastMismatch = i;
102+
break;
103+
}
104+
}
105+
106+
if (firstMismatch === -1) {
107+
return;
108+
}
109+
110+
const description = desired
111+
.slice(firstMismatch, lastMismatch + 1)
112+
.map((node) => {
113+
const source = JSON.stringify(node.source.value);
114+
115+
return source;
116+
})
117+
.join(' << ');
118+
119+
const message =
120+
'exports must be sorted by module name ' +
121+
`(expected: ${description})`;
122+
123+
context.report({
124+
fix: (fixer) => {
125+
const fixings = [];
126+
127+
const code = context.getSourceCode();
128+
129+
const sources = new Map();
130+
131+
// Pass 1: Extract copy of text.
132+
133+
for (let i = firstMismatch; i <= lastMismatch; i++) {
134+
const node = exportNodes[i];
135+
const range = getRangeForNode(node);
136+
const text = code.getText().slice(...range);
137+
138+
sources.set(exportNodes[i], {text});
139+
}
140+
141+
// Pass 2: Write text into expected positions.
142+
143+
for (let i = firstMismatch; i <= lastMismatch; i++) {
144+
fixings.push(
145+
fixer.replaceTextRange(
146+
getRangeForNode(exportNodes[i]),
147+
sources.get(desired[i]).text
148+
)
149+
);
150+
}
151+
152+
return fixings;
153+
},
154+
message,
155+
node: exportNodes[firstMismatch],
156+
});
157+
},
158+
};
159+
},
160+
161+
meta: {
162+
docs: {
163+
category: 'Best Practices',
164+
description: DESCRIPTION,
165+
recommended: false,
166+
},
167+
fixable: 'code',
168+
schema: [],
169+
type: 'problem',
170+
},
171+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* SPDX-FileCopyrightText: © 2017 Liferay, Inc. <https://liferay.com>
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
const MultiTester = require('../../../../../scripts/MultiTester');
7+
const rule = require('../../../lib/rules/sort-exports');
8+
9+
const parserOptions = {
10+
parserOptions: {
11+
ecmaVersion: 6,
12+
sourceType: 'module',
13+
},
14+
};
15+
16+
const ruleTester = new MultiTester(parserOptions);
17+
18+
ruleTester.run('sort-exports', rule, {
19+
invalid: [
20+
{
21+
22+
// Basic example.
23+
24+
code: `
25+
export {Foo} from './Foo';
26+
export {Bar} from './Bar';
27+
`,
28+
errors: [
29+
{
30+
message:
31+
'exports must be sorted by module name ' +
32+
'(expected: "./Bar" << "./Foo")',
33+
type: 'ExportNamedDeclaration',
34+
},
35+
],
36+
output: `
37+
export {Bar} from './Bar';
38+
export {Foo} from './Foo';
39+
`,
40+
},
41+
],
42+
43+
valid: [
44+
{
45+
46+
// Well-sorted exports.
47+
48+
code: `
49+
export {Bar} from './Bar';
50+
export {Foo} from './Foo';
51+
`,
52+
},
53+
],
54+
});

projects/js-toolkit/packages/js-toolkit-core/src/index.ts

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,53 +3,51 @@
33
* SPDX-License-Identifier: LGPL-3.0-or-later
44
*/
55

6-
// Operations on files
6+
// TODO: remove the next section before babel 3 release
7+
// Bundler plugin utilities
78

8-
export {default as FilePath} from './file/FilePath';
9-
export {default as Manifest} from './file/handler/Manifest';
9+
export {default as PkgDesc} from './bundler/PkgDesc';
10+
export {default as escapeStringRegExp} from './escapeStringRegExp';
1011

1112
// Utilities to deal with node packages and modules
1213

1314
export * from './node/modules';
1415
export * from './node/namespace';
1516

16-
// TODO: remove the next section before babel 3 release
17-
// Bundler plugin utilities
17+
// Operations on files
1818

19-
export {default as PkgDesc} from './bundler/PkgDesc';
19+
export {default as FilePath} from './file/FilePath';
20+
21+
export {default as Manifest} from './file/handler/Manifest';
22+
23+
// Miscellaneous utilities
24+
25+
export {negate as negateGlobs, prefix as prefixGlobs} from './globs';
26+
export {LogLevel as B3LogLevel} from './project/bundler3/Misc';
27+
export {ProjectType as B3ProjectType} from './project/bundler3/Probe';
2028

2129
// Bundler 3 Project descriptor class and types
2230

2331
export {
2432
default as B3Project,
2533
Imports as B3Imports,
2634
} from './project/bundler3/Project';
27-
export {ProjectType as B3ProjectType} from './project/bundler3/Probe';
28-
export {LogLevel as B3LogLevel} from './project/bundler3/Misc';
2935
export {default as B3VersionInfo} from './project/bundler3/VersionInfo';
3036

31-
// Liferay CLI Project descriptor class and types
37+
// Format library
38+
39+
export * as format from './format';
3240

33-
export {default as Project} from './project/liferayCli/Project';
3441
export {
3542
Bundler2BuildOptions,
3643
CustomElementBuildOptions,
3744
MinifiableBuildOptions,
3845
WebpackBuildOptions,
3946
} from './project/liferayCli/Build';
4047

41-
// Format library
42-
43-
export * as format from './format';
44-
45-
// Template rendering
46-
47-
export {default as TemplateRenderer} from './template/Renderer';
48-
49-
// Miscellaneous utilities
48+
// Liferay CLI Project descriptor class and types
5049

51-
export {negate as negateGlobs, prefix as prefixGlobs} from './globs';
52-
export {default as escapeStringRegExp} from './escapeStringRegExp';
50+
export {default as Project} from './project/liferayCli/Project';
5351
export {runNodeModulesBin, runPkgJsonScript} from './run';
5452

5553
// JSON file structure definitions (schemas)
@@ -93,6 +91,10 @@ export type {
9391

9492
export type {default as RemoteAppManifestJson} from './schema/RemoteAppManifestJson';
9593

94+
// Template rendering
95+
96+
export {default as TemplateRenderer} from './template/Renderer';
97+
9698
// JavaScript source code transformation
9799

98100
export type {

0 commit comments

Comments
 (0)