Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ The interactive CLI can guide you through the following steps:
npx esperf
```

## Advanced Usage

The interactive CLI will show more steps:

- Set scan speed

```sh
npx esperf --advanced
```

## License

MIT
124 changes: 93 additions & 31 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ import {scanFiles} from './stages/scan-files.js';
import {fixFiles} from './stages/fix-files.js';
import {traverseFiles} from './stages/traverse-files.js';
import {scanDependencies} from './stages/scan-dependencies.js';
import {availableParallelism} from 'node:os';

let advanced = false;

for (let i = 0; i < process.argv.length; ++i) {
if (process.argv[i] === '--advanced') advanced = true;
}

function getWantedThreads(scanSpeed: string): number {
const threads = availableParallelism();
switch (scanSpeed) {
case 'slow':
return threads * 0.25;
case 'medium':
return threads * 0.5;
case 'fast':
return threads * 0.75;
case 'fastest':
return threads;
}
return 1;
}

const availableManifests: Record<string, modReplacements.ManifestModule> = {
native: modReplacements.nativeReplacements,
Expand Down Expand Up @@ -118,7 +140,41 @@ async function runModuleReplacements(): Promise<void> {
cl.confirm({
message: 'Automatically uninstall packages?',
initialValue: false
})
}),
scanSpeed: () =>
advanced
? cl.select({
message: 'Preferred scan speed',
options: [
{
value: 'fastest',
label: 'Fastest',
hint: 'uses all the threads, pushes cpu to 100%'
},
{
value: 'fast',
label: 'Fast',
hint: '75% of available threads'
},
{
value: 'medium',
label: 'Medium',
hint: '50% of available threads'
},
{
value: 'slow',
label: 'Slow',
hint: '25% of available threads'
},
{
value: 'slowest',
label: 'Slowest',
hint: 'disables parallelism, 1 thread'
}
],
initialValue: 'medium'
})
: Promise.resolve('medium')
},
{
onCancel: () => {
Expand Down Expand Up @@ -196,43 +252,49 @@ async function runModuleReplacements(): Promise<void> {

scanSpinner.message('Scanning files');

const files = await traverseFiles(options.filesDir);
try {
const files = await traverseFiles(options.filesDir);
const threads = getWantedThreads(options.scanSpeed);

const scanFilesResult = await scanFiles(
files,
manifestReplacements,
scanSpinner
);
const scanFilesResult = await scanFiles(
files,
manifestReplacements,
threads,
scanSpinner
);

if (scanFilesResult.length > 0) {
dependenciesFound = true;
}
if (scanFilesResult.length > 0) {
dependenciesFound = true;
}

if (dependenciesFound) {
scanSpinner.stop('Replaceable modules found.', 2);
} else {
scanSpinner.stop('No replaceable modules found.');
}
if (dependenciesFound) {
scanSpinner.stop('Replaceable modules found.', 2);
} else {
scanSpinner.stop('No replaceable modules found.');
}

if (
options.autoUninstall &&
(dependenciesToRemove.length > 0 || devDependenciesToRemove.length > 0)
) {
const npmSpinner = cl.spinner();
if (
options.autoUninstall &&
(dependenciesToRemove.length > 0 || devDependenciesToRemove.length > 0)
) {
const npmSpinner = cl.spinner();

npmSpinner.start('Removing npm dependencies');
npmSpinner.start('Removing npm dependencies');

if (dependenciesToRemove.length > 0) {
await x('npm', ['rm', '-S', ...dependenciesToRemove]);
}
if (devDependenciesToRemove.length > 0) {
await x('npm', ['rm', '-D', ...devDependenciesToRemove]);
}
if (dependenciesToRemove.length > 0) {
await x('npm', ['rm', '-S', ...dependenciesToRemove]);
}
if (devDependenciesToRemove.length > 0) {
await x('npm', ['rm', '-D', ...devDependenciesToRemove]);
}

npmSpinner.stop('npm dependencies removed');
}
npmSpinner.stop('npm dependencies removed');
}

if (options.fix) {
await fixFiles(scanFilesResult);
if (options.fix) {
await fixFiles(scanFilesResult);
}
} catch (error) {
scanSpinner.stop(error as string, 1);
}
}
145 changes: 44 additions & 101 deletions src/stages/scan-files.ts
Original file line number Diff line number Diff line change
@@ -1,112 +1,55 @@
import * as modReplacements from 'module-replacements';
import {ts as sg} from '@ast-grep/napi';
import {readFile} from 'node:fs/promises';
import dedent from 'dedent';
import pc from 'picocolors';
import * as cl from '@clack/prompts';
import {type FileReplacement} from '../shared-types.js';
import {suggestReplacement} from '../suggest-replacement.js';
import {availableParallelism} from 'node:os';
import {Worker} from 'node:worker_threads';
import path from 'path';
import {fileURLToPath} from 'url';

async function scanFile(
filePath: string,
contents: string,
lines: string[],
replacements: modReplacements.ModuleReplacement[]
): Promise<FileReplacement | null> {
const ast = sg.parse(contents);
const root = ast.root();
const matches: modReplacements.ModuleReplacement[] = [];
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const available = availableParallelism();

for (const replacement of replacements) {
const imports = root.findAll({
rule: {
any: [
{
pattern: {
context: `import $NAME from '${replacement.moduleName}'`,
strictness: 'relaxed'
}
},
{
pattern: {
context: `require('${replacement.moduleName}')`,
strictness: 'relaxed'
}
}
]
}
});

if (imports.length > 0) {
matches.push(replacement);
}

for (const node of imports) {
const range = node.range();
let snippet: string = '';

const prevLine = lines[range.start.line - 1];
const line = lines[range.start.line];
const nextLine = lines[range.start.line + 1];

if (prevLine) {
snippet += `${range.start.line} | ${prevLine}\n`;
}

snippet += `${range.start.line + 1} | ${pc.red(line)}\n`;

if (nextLine) {
snippet += `${range.start.line + 2} | ${nextLine}\n`;
}

suggestReplacement(replacement, {
type: 'file',
path: filePath,
line: range.start.line,
column: range.start.column,
snippet
});
}
}

if (matches.length === 0) {
return null;
}

return {
path: filePath,
contents,
replacements: matches
};
}

export async function scanFiles(
export function scanFiles(
files: string[],
replacements: modReplacements.ModuleReplacement[],
threads: number,
spinner: ReturnType<typeof cl.spinner>
): Promise<FileReplacement[]> {
const results: FileReplacement[] = [];

for (const file of files) {
try {
const contents = await readFile(file, 'utf8');
const lines = contents.split('\n');

spinner.message(`Scanning ${file}`);

const scanResult = await scanFile(file, contents, lines, replacements);

if (scanResult) {
results.push(scanResult);
}
} catch (err) {
cl.log.error(dedent`
Could not read file ${file}:

${String(err)}
`);
return new Promise((resolve, reject) => {
let i = 0;
let tasks = 0;
const filesLength = files.length;
const results: FileReplacement[] = [];

for (const file of files.splice(0, threads)) {
const worker = new Worker(`${__dirname}/workers/scan-file.js`);
// todo, what todo with the errors?
worker.on('error', (error) => reject(error.message));
worker.on('message', (message) => {
if (message?.type === 'result') {
results.push(message.value);
i += 1;
if (i === filesLength) {
resolve(results);
}
} else {
reject(message.value);
}
if (files.length > 0) {
if (available >= tasks) {
const file = files.shift();
spinner.message(`Scanning file: ${file}`);
worker.postMessage({file, replacements});
} else {
tasks -= 1;
}
} else {
worker.terminate();
}
});
spinner.message(`Scanning file: ${file}`);
worker.postMessage({file, replacements});
}
}

return results;
});
}
Loading