Skip to content

Commit

Permalink
Merge pull request #1233 from bryceosterhaus/LPD-39648
Browse files Browse the repository at this point in the history
feat(eslint-plugin): add rule to sort named exports from a source
  • Loading branch information
bryceosterhaus authored Oct 18, 2024
2 parents 740f555 + 9a4a58d commit de3c289
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 22 deletions.
1 change: 1 addition & 0 deletions projects/eslint-plugin/configs/general.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const config = {
'@liferay/padded-test-blocks': 'error',
'@liferay/prefer-length-check': 'error',
'@liferay/ref-name-suffix': 'error',
'@liferay/sort-exports': 'error',
'@liferay/sort-import-destructures': 'error',
'@liferay/sort-imports': 'error',
'@liferay/use-state-naming-pattern': 'error',
Expand Down
1 change: 1 addition & 0 deletions projects/eslint-plugin/rules/general/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ module.exports = {
'prefer-length-check': require('./lib/rules/prefer-length-check'),
'ref-name-suffix': require('./lib/rules/ref-name-suffix'),
'sort-class-names': require('./lib/rules/sort-class-names'),
'sort-exports': require('./lib/rules/sort-exports'),
'sort-import-destructures': require('./lib/rules/sort-import-destructures'),
'sort-imports': require('./lib/rules/sort-imports'),
'trim-class-names': require('./lib/rules/trim-class-names'),
Expand Down
5 changes: 4 additions & 1 deletion projects/eslint-plugin/rules/general/lib/common/imports.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ function getRequireStatement(node) {
}

function getSource(node) {
if (node.type === 'ImportDeclaration') {
if (node.type === 'ExportNamedDeclaration') {
return node.source?.value;
}
else if (node.type === 'ImportDeclaration') {
return node.source.value;
}
else if (node.type === 'VariableDeclaration') {
Expand Down
171 changes: 171 additions & 0 deletions projects/eslint-plugin/rules/general/lib/rules/sort-exports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* SPDX-FileCopyrightText: © 2017 Liferay, Inc. <https://liferay.com>
* SPDX-License-Identifier: MIT
*/

const {
getLeadingComments,
getTrailingComments,
isAbsolute,
isRelative,
withScope,
} = require('../common/imports');

const DESCRIPTION = 'exports must be sorted';

/**
* Given two sort keys `a` and `b`, return -1, 0 or 1 to indicate
* their relative ordering.
*/
function compare(aKey, bKey) {
const [aPrefix, aName, aTieBreaker] = aKey.split(':');
const [bPrefix, bName, bTieBreaker] = bKey.split(':');

const cmp = (a, b) => {
return a < b ? -1 : a > b ? 1 : 0;
};

return (
cmp(aPrefix, bPrefix) ||
cmp(ranking(aName), ranking(bName)) ||
cmp(aName, bName) ||
cmp(aTieBreaker, bTieBreaker)
);
}

/**
* Returns a ranking for `source`. Lower-numbered ranks are considered more
* important and will be sorted first in the file.
*
* - 0: NodeJS built-ins and dependencies declared in "package.json" files.
* - 1: Absolute paths.
* - 2: Relative paths.
*/
function ranking(source) {
return isRelative(source) ? 2 : isAbsolute(source) ? 1 : 0;
}

module.exports = {
create(context) {
const exportNodes = [];

const {visitors} = withScope();

function getRangeForNode(node) {
const commentsBefore = getLeadingComments(node, context);
const commentsAfter = getTrailingComments(node, context);

const first = commentsBefore[0] || node;

const last = commentsAfter[commentsAfter.length - 1] || node;

return [first.range[0], last.range[1]];
}

return {
...visitors,
ExportNamedDeclaration(node) {

/**
* Only sort exports if they have a source. Skip exports like:
* export function Foo() {}
* export default function Bar() {}
* export {Baz};
*/
if (node.source) {
exportNodes.push(node);
}
},

['Program:exit'](_node) {
const desired = [...exportNodes].sort((a, b) =>
compare(a.source.value, b.source.value)
);

// Try to make error messages (somewhat) minimal by only
// reporting from the first to the last mismatch (ie.
// not a full Myers diff algorithm).

let firstMismatch = -1;
let lastMismatch = -1;

for (let i = 0; i < exportNodes.length; i++) {
if (exportNodes[i] !== desired[i]) {
firstMismatch = i;
break;
}
}

for (let i = exportNodes.length - 1; i >= 0; i--) {
if (exportNodes[i] !== desired[i]) {
lastMismatch = i;
break;
}
}

if (firstMismatch === -1) {
return;
}

const description = desired
.slice(firstMismatch, lastMismatch + 1)
.map((node) => {
const source = JSON.stringify(node.source.value);

return source;
})
.join(' << ');

const message =
'exports must be sorted by module name ' +
`(expected: ${description})`;

context.report({
fix: (fixer) => {
const fixings = [];

const code = context.getSourceCode();

const sources = new Map();

// Pass 1: Extract copy of text.

for (let i = firstMismatch; i <= lastMismatch; i++) {
const node = exportNodes[i];
const range = getRangeForNode(node);
const text = code.getText().slice(...range);

sources.set(exportNodes[i], {text});
}

// Pass 2: Write text into expected positions.

for (let i = firstMismatch; i <= lastMismatch; i++) {
fixings.push(
fixer.replaceTextRange(
getRangeForNode(exportNodes[i]),
sources.get(desired[i]).text
)
);
}

return fixings;
},
message,
node: exportNodes[firstMismatch],
});
},
};
},

meta: {
docs: {
category: 'Best Practices',
description: DESCRIPTION,
recommended: false,
},
fixable: 'code',
schema: [],
type: 'problem',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* SPDX-FileCopyrightText: © 2017 Liferay, Inc. <https://liferay.com>
* SPDX-License-Identifier: MIT
*/

const MultiTester = require('../../../../../scripts/MultiTester');
const rule = require('../../../lib/rules/sort-exports');

const parserOptions = {
parserOptions: {
ecmaVersion: 6,
sourceType: 'module',
},
};

const ruleTester = new MultiTester(parserOptions);

ruleTester.run('sort-exports', rule, {
invalid: [
{

// Basic example.

code: `
export {Foo} from './Foo';
export {Bar} from './Bar';
`,
errors: [
{
message:
'exports must be sorted by module name ' +
'(expected: "./Bar" << "./Foo")',
type: 'ExportNamedDeclaration',
},
],
output: `
export {Bar} from './Bar';
export {Foo} from './Foo';
`,
},
],

valid: [
{

// Well-sorted exports.

code: `
export {Bar} from './Bar';
export {Foo} from './Foo';
`,
},
],
});
44 changes: 23 additions & 21 deletions projects/js-toolkit/packages/js-toolkit-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,51 @@
* SPDX-License-Identifier: LGPL-3.0-or-later
*/

// Operations on files
// TODO: remove the next section before babel 3 release
// Bundler plugin utilities

export {default as FilePath} from './file/FilePath';
export {default as Manifest} from './file/handler/Manifest';
export {default as PkgDesc} from './bundler/PkgDesc';
export {default as escapeStringRegExp} from './escapeStringRegExp';

// Utilities to deal with node packages and modules

export * from './node/modules';
export * from './node/namespace';

// TODO: remove the next section before babel 3 release
// Bundler plugin utilities
// Operations on files

export {default as PkgDesc} from './bundler/PkgDesc';
export {default as FilePath} from './file/FilePath';

export {default as Manifest} from './file/handler/Manifest';

// Miscellaneous utilities

export {negate as negateGlobs, prefix as prefixGlobs} from './globs';
export {LogLevel as B3LogLevel} from './project/bundler3/Misc';
export {ProjectType as B3ProjectType} from './project/bundler3/Probe';

// Bundler 3 Project descriptor class and types

export {
default as B3Project,
Imports as B3Imports,
} from './project/bundler3/Project';
export {ProjectType as B3ProjectType} from './project/bundler3/Probe';
export {LogLevel as B3LogLevel} from './project/bundler3/Misc';
export {default as B3VersionInfo} from './project/bundler3/VersionInfo';

// Liferay CLI Project descriptor class and types
// Format library

export * as format from './format';

export {default as Project} from './project/liferayCli/Project';
export {
Bundler2BuildOptions,
CustomElementBuildOptions,
MinifiableBuildOptions,
WebpackBuildOptions,
} from './project/liferayCli/Build';

// Format library

export * as format from './format';

// Template rendering

export {default as TemplateRenderer} from './template/Renderer';

// Miscellaneous utilities
// Liferay CLI Project descriptor class and types

export {negate as negateGlobs, prefix as prefixGlobs} from './globs';
export {default as escapeStringRegExp} from './escapeStringRegExp';
export {default as Project} from './project/liferayCli/Project';
export {runNodeModulesBin, runPkgJsonScript} from './run';

// JSON file structure definitions (schemas)
Expand Down Expand Up @@ -93,6 +91,10 @@ export type {

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

// Template rendering

export {default as TemplateRenderer} from './template/Renderer';

// JavaScript source code transformation

export type {
Expand Down

0 comments on commit de3c289

Please sign in to comment.