diff --git a/package.json b/package.json index 210117c..3042486 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "lib", "!lib/test" ], + "bin": { + "module-replacements": "./lib/bin.js", + "mr": "./lib/bin.js" + }, "scripts": { "clean:build": "premove lib", "clean:test": "premove coverage", diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..b2028a3 --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import {run} from './main.js'; + +run(); diff --git a/src/main.ts b/src/main.ts index 88a325f..ee7367b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,13 @@ import * as cl from '@clack/prompts'; import * as modReplacements from 'module-replacements'; -import {exit, cwd} from 'node:process'; +import {exit, cwd as getCwd} from 'node:process'; import {findPackage} from 'fd-package-json'; import dedent from 'dedent'; -import pc from 'picocolors'; -import {fdir} from 'fdir'; -import {getDocsUrl, getMdnUrl} from './replacement-urls.js'; -import {ts as sg} from '@ast-grep/napi'; -import {readFile, writeFile} from 'node:fs/promises'; -import {extname} from 'node:path'; -import {codemods, type Codemod} from 'module-replacements-codemods'; import {x} from 'tinyexec'; +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'; const availableManifests: Record = { native: modReplacements.nativeReplacements, @@ -18,499 +15,200 @@ const availableManifests: Record = { preferred: modReplacements.preferredReplacements }; -const packageManifest = await findPackage(cwd()); - -interface PackageSource { - type: 'package'; - source: 'dependencies' | 'devDependencies'; -} -interface FileSource { - type: 'file'; - path: string; - line: number; - column: number; - snippet: string; -} -type Source = PackageSource | FileSource; - -function renderSource(source: Source): string { - switch (source.type) { - case 'package': - return dedent` - ${pc.bold('package.json')} - `; - case 'file': - return dedent` - ${pc.bold(`${source.path} (${source.line}:${source.column})`)} - - ${source.snippet} - `; - } -} - -function suggestDocumentedReplacement( - replacement: modReplacements.DocumentedModuleReplacement, - source: Source -): void { - cl.log.warn(dedent` - ${pc.bold(replacement.moduleName)} - ${renderSource(source)} - - Module ${pc.cyan(replacement.moduleName)} could be replaced with a more performant alternative. - - You can find an alternative in the following documentation: - ${pc.underline(getDocsUrl(replacement.docPath))} - `); -} - -function suggestNativeReplacement( - replacement: modReplacements.NativeModuleReplacement, - source: Source -): void { - cl.log.warn(dedent` - ${pc.bold(replacement.moduleName)} - ${renderSource(source)} - - Module ${pc.cyan(replacement.moduleName)} could be replaced with the following native functionality: - - ${pc.underline(getMdnUrl(replacement.mdnPath))} - `); -} - -function suggestNoneReplacement( - replacement: modReplacements.NoModuleReplacement, - source: Source -): void { - cl.log.warn(dedent` - ${pc.bold(replacement.moduleName)} - ${renderSource(source)} - - Module ${pc.cyan(replacement.moduleName)} could be removed or replaced with a more performant alternative. - `); -} - -function suggestSimpleReplacement( - replacement: modReplacements.SimpleModuleReplacement, - source: Source -): void { - cl.log.warn(dedent` - ${pc.bold(replacement.moduleName)} - ${renderSource(source)} - - Module ${pc.cyan(replacement.moduleName)} could be replaced inline/native equivalent logic. - - ${replacement.replacement} - `); -} - -function suggestReplacement( - replacement: modReplacements.ModuleReplacement, - source: Source -): void { - switch (replacement.type) { - case 'documented': - return suggestDocumentedReplacement(replacement, source); - case 'native': - return suggestNativeReplacement(replacement, source); - case 'none': - return suggestNoneReplacement(replacement, source); - case 'simple': - return suggestSimpleReplacement(replacement, source); +function isDependenciesLike(obj: unknown): obj is Record { + if (typeof obj !== 'object' || obj === null) { + return false; } -} - -interface DependencyResult { - match: modReplacements.ModuleReplacement; - source: PackageSource['source']; -} - -function traverseDependencies( - dependencies: Record, - replacements: modReplacements.ModuleReplacement[], - source: PackageSource['source'] -): DependencyResult[] { - const results: DependencyResult[] = []; - - for (const key in dependencies) { - for (const replacement of replacements) { - if (key === replacement.moduleName) { - results.push({ - match: replacement, - source - }); - suggestReplacement(replacement, {type: 'package', source}); - } + for (const key in obj) { + if (typeof (obj as Record)[key] !== 'string') { + return false; } } - - return results; -} - -interface ScanFileResult { - path: string; - contents: string; - matches: modReplacements.ModuleReplacement[]; + return true; } -async function fixFile( - scanResult: ScanFileResult, - cache: Record -): Promise { - let newContent = scanResult.contents; - - for (const replacement of scanResult.matches) { - const factory = codemods[replacement.moduleName]; - - if (!factory) { - continue; - } - - const cachedInstance = cache[replacement.moduleName]; - let codemod; - if (!cachedInstance) { - codemod = factory({}); - cache[replacement.moduleName] = codemod; - } else { - codemod = cachedInstance; - } +export async function run(): Promise { + const cwd = getCwd(); + const packageManifest = await findPackage(cwd); - try { - const transformResult = await codemod.transform({ - file: { - filename: scanResult.path, - source: newContent - } - }); - - cl.log.success(dedent` - Applying codemod ${pc.cyan(replacement.moduleName)} to ${scanResult.path} - `); - - newContent = transformResult; - } catch (err) { - cl.log.error(dedent` - ${pc.bold('Error:')} the ${pc.cyan(replacement.moduleName)} codemod unexpectedly threw an exception: - - ${err} - `); - } - } + cl.intro('mr-cli'); - if (scanResult.contents !== newContent) { - await writeFile(scanResult.path, newContent, 'utf8'); + if (packageManifest === null) { + cl.log.error(dedent` + Could not find package.json. Please ensure that you run this command in a project which has one setup. + `); + cl.cancel(); + exit(0); } -} -async function fixFiles(scanResults: ScanFileResult[]): Promise { - const codemodCache: Record = {}; + cl.log.message(dedent` + We will search your project for modules which can be replaced by faster or lighter alternatives. - for (const result of scanResults) { - await fixFile(result, codemodCache); - } -} + Before we do that, we need to determine what level of strictness you want to apply. + `); -async function scanFile( - filePath: string, - contents: string, - lines: string[], - replacements: modReplacements.ModuleReplacement[] -): Promise { - const ast = sg.parse(contents); - const root = ast.root(); - const result: ScanFileResult = { - path: filePath, - contents, - matches: [] - }; - - 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' + const options = await cl.group( + { + manifests: () => + cl.multiselect({ + message: 'Choose which module lists to apply', + initialValues: ['native', 'micro-utilities', 'preferred'], + required: true, + options: [ + { + label: 'native', + value: 'native', + hint: 'modules with native replacements' + }, + { + label: 'micro utilities', + hint: 'more opinionated list of modules which can be replaced with native code', + value: 'micro-utilities' + }, + { + label: 'preferred', + hint: 'opinionated list of faster and leaner packages', + value: 'preferred' } - } - ] + ] + }), + includeDevDependencies: () => + cl.confirm({ + message: 'Include devDependencies?', + initialValue: false + }), + filesDir: () => + cl.text({ + message: `Which directory would you like to scan for files? (default: ${cwd})`, + defaultValue: cwd + }), + fix: () => + cl.confirm({ + message: 'Automatically apply codemods?', + initialValue: false + }), + autoUninstall: () => + cl.confirm({ + message: 'Automatically uninstall packages?', + initialValue: false + }) + }, + { + onCancel: () => { + cl.cancel('Operation was cancelled.'); + exit(0); } - }); - - if (imports.length > 0) { - result.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]; + const packageDependencies = packageManifest.dependencies; + const packageDevDependencies = packageManifest.devDependencies; - if (prevLine) { - snippet += `${range.start.line} | ${prevLine}\n`; - } + const manifestReplacements: modReplacements.ModuleReplacement[] = []; - snippet += `${range.start.line + 1} | ${pc.red(line)}\n`; + for (const manifestName of options.manifests) { + const manifestModule = availableManifests[manifestName]; - if (nextLine) { - snippet += `${range.start.line + 2} | ${nextLine}\n`; + if (manifestModule) { + for (const replacement of manifestModule.moduleReplacements) { + manifestReplacements.push(replacement); } - - suggestReplacement(replacement, { - type: 'file', - path: filePath, - line: range.start.line, - column: range.start.column, - snippet - }); } } - return result; -} - -const knownFileExtensions = new Set(['.tsx', '.ts', '.js', '.jsx']); + const packageScanSpinner = cl.spinner(); -async function scanFiles( - files: string[], - replacements: modReplacements.ModuleReplacement[], - spinner: ReturnType -): Promise { - const results: ScanFileResult[] = []; + packageScanSpinner.start('Scanning `package.json` dependencies'); - for (const file of files) { - try { - const contents = await readFile(file, 'utf8'); - const lines = contents.split('\n'); + const dependenciesToRemove: string[] = []; + const devDependenciesToRemove: string[] = []; + let packageJsonFailed = false; - spinner.message(`Scanning ${file}`); + if (isDependenciesLike(packageDependencies)) { + const traverseResults = scanDependencies( + packageDependencies, + manifestReplacements, + 'dependencies' + ); - results.push(await scanFile(file, contents, lines, replacements)); - } catch (err) { - cl.log.error(dedent` - Could not read file ${file}: - - ${String(err)} - `); + if (options.autoUninstall) { + for (const result of traverseResults) { + dependenciesToRemove.push(result.replacement.moduleName); + } } - } - - return results; -} -async function traverseFiles(dirPath: string): Promise { - const fileScanner = new fdir(); - - fileScanner - .withFullPaths() - .exclude((dirName) => dirName === 'node_modules') - .filter((filePath, isDir) => { - return !isDir && knownFileExtensions.has(extname(filePath)); - }); - - return await fileScanner.crawl(dirPath).withPromise(); -} - -function isDependenciesLike(obj: unknown): obj is Record { - if (typeof obj !== 'object' || obj === null) { - return false; + packageJsonFailed = true; } - for (const key in obj) { - if (typeof (obj as Record)[key] !== 'string') { - return false; - } - } - return true; -} -cl.intro('mr-cli'); + if ( + options.includeDevDependencies && + isDependenciesLike(packageDevDependencies) + ) { + const traverseResults = scanDependencies( + packageDevDependencies, + manifestReplacements, + 'devDependencies' + ); -if (packageManifest === null) { - cl.log.error(dedent` - Could not find package.json. Please ensure that you run this command in a project which has one setup. - `); - cl.cancel(); - exit(0); -} - -/** - * TODO: - * - * - Lint `package.json` for module replacements - * - Allow user to choose which manifests to apply (via strictness?) - * - Offer to autofix ones with codemods - */ - -cl.log.message(dedent` - We will search your project for modules which can be replaced by faster or lighter alternatives. - - Before we do that, we need to determine what level of strictness you want to apply. -`); - -const options = await cl.group( - { - manifests: () => - cl.multiselect({ - message: 'Choose which module lists to apply', - initialValues: ['native', 'micro-utilities', 'preferred'], - required: true, - options: [ - { - label: 'native', - value: 'native', - hint: 'modules with native replacements' - }, - { - label: 'micro utilities', - hint: 'more opinionated list of modules which can be replaced with native code', - value: 'micro-utilities' - }, - { - label: 'preferred', - hint: 'opinionated list of faster and leaner packages', - value: 'preferred' - } - ] - }), - includeDevDependencies: () => - cl.confirm({ - message: 'Include devDependencies?', - initialValue: false - }), - filesDir: () => - cl.text({ - message: `Which directory would you like to scan for files? (default: ${cwd()})`, - defaultValue: cwd() - }), - fix: () => - cl.confirm({ - message: 'Automatically apply codemods?', - initialValue: false - }), - autoUninstall: () => - cl.confirm({ - message: 'Automatically uninstall packages?', - initialValue: false - }) - }, - { - onCancel: () => { - cl.cancel('Operation was cancelled.'); - exit(0); + if (options.autoUninstall) { + for (const result of traverseResults) { + devDependenciesToRemove.push(result.replacement.moduleName); + } } + + packageJsonFailed = true; } -); -const packageDependencies = packageManifest.dependencies; -const packageDevDependencies = packageManifest.devDependencies; + if (packageJsonFailed) { + packageScanSpinner.stop( + '`package.json` dependencies scanned successfully.' + ); + } else { + packageScanSpinner.stop( + '`package.json` contained replaceable dependencies.', + 2 + ); + } -const manifestReplacements: modReplacements.ModuleReplacement[] = []; + if ( + options.autoUninstall && + (dependenciesToRemove.length > 0 || devDependenciesToRemove.length > 0) + ) { + const npmSpinner = cl.spinner(); -for (const manifestName of options.manifests) { - const manifestModule = availableManifests[manifestName]; + npmSpinner.start('Removing npm dependencies'); - if (manifestModule) { - for (const replacement of manifestModule.moduleReplacements) { - manifestReplacements.push(replacement); + if (dependenciesToRemove.length > 0) { + await x('npm', ['rm', '-S', ...dependenciesToRemove]); + } + if (devDependenciesToRemove.length > 0) { + await x('npm', ['rm', '-D', ...devDependenciesToRemove]); } - } -} - -const packageScanSpinner = cl.spinner(); - -packageScanSpinner.start('Scanning `package.json` dependencies'); -const dependenciesToRemove: string[] = []; -const devDependenciesToRemove: string[] = []; -let packageJsonFailed = false; + npmSpinner.stop('npm dependencies removed'); + } -if (isDependenciesLike(packageDependencies)) { - const traverseResults = traverseDependencies( - packageDependencies, - manifestReplacements, - 'dependencies' - ); + const fileScanSpinner = cl.spinner(); - if (options.autoUninstall) { - for (const result of traverseResults) { - dependenciesToRemove.push(result.match.moduleName); - } - } + fileScanSpinner.start('Scanning files'); - packageJsonFailed = true; -} + const files = await traverseFiles(options.filesDir); -if ( - options.includeDevDependencies && - isDependenciesLike(packageDevDependencies) -) { - const traverseResults = traverseDependencies( - packageDevDependencies, + const scanFilesResult = await scanFiles( + files, manifestReplacements, - 'devDependencies' + fileScanSpinner ); - if (options.autoUninstall) { - for (const result of traverseResults) { - devDependenciesToRemove.push(result.match.moduleName); - } + if (scanFilesResult.length > 0) { + fileScanSpinner.stop('Detected files with replaceable modules!', 2); + } else { + fileScanSpinner.stop('All files scanned'); } - packageJsonFailed = true; -} - -if (packageJsonFailed) { - packageScanSpinner.stop('`package.json` dependencies scanned successfully.'); -} else { - packageScanSpinner.stop( - '`package.json` contained replaceable dependencies.', - 2 - ); -} - -if ( - options.autoUninstall && - (dependenciesToRemove.length > 0 || devDependenciesToRemove.length > 0) -) { - const npmSpinner = cl.spinner(); - - npmSpinner.start('Removing npm dependencies'); - - if (dependenciesToRemove.length > 0) { - await x('npm', ['rm', '-S', ...dependenciesToRemove]); + if (options.fix) { + await fixFiles(scanFilesResult); } - if (devDependenciesToRemove.length > 0) { - await x('npm', ['rm', '-D', ...devDependenciesToRemove]); - } - - npmSpinner.stop('npm dependencies removed'); -} -const fileScanSpinner = cl.spinner(); - -fileScanSpinner.start('Scanning files'); - -const files = await traverseFiles(options.filesDir); - -const scanFilesResult = await scanFiles( - files, - manifestReplacements, - fileScanSpinner -); - -if (scanFilesResult.length > 0) { - fileScanSpinner.stop('Detected files with replaceable modules!', 2); -} else { - fileScanSpinner.stop('All files scanned'); + cl.outro('All checks complete!'); } - -if (options.fix) { - await fixFiles(scanFilesResult); -} - -cl.outro('All checks complete!'); diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..a640a6c --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,28 @@ +import * as modReplacements from 'module-replacements'; + +export interface FileReplacement { + path: string; + contents: string; + replacements: modReplacements.ModuleReplacement[]; +} + +export interface DependencyReplacement { + replacement: modReplacements.ModuleReplacement; + source: 'dependencies' | 'devDependencies'; + name: string; +} + +export interface PackageSource { + type: 'package'; + source: 'dependencies' | 'devDependencies'; +} + +export interface FileSource { + type: 'file'; + path: string; + line: number; + column: number; + snippet: string; +} + +export type Source = PackageSource | FileSource; diff --git a/src/stages/fix-files.ts b/src/stages/fix-files.ts new file mode 100644 index 0000000..2632a7c --- /dev/null +++ b/src/stages/fix-files.ts @@ -0,0 +1,63 @@ +import pc from 'picocolors'; +import {writeFile} from 'node:fs/promises'; +import {codemods, type Codemod} from 'module-replacements-codemods'; +import dedent from 'dedent'; +import * as cl from '@clack/prompts'; +import {type FileReplacement} from '../shared-types.js'; + +async function fixFile( + scanResult: FileReplacement, + cache: Record +): Promise { + let newContent = scanResult.contents; + + for (const replacement of scanResult.replacements) { + const factory = codemods[replacement.moduleName]; + + if (!factory) { + continue; + } + + const cachedInstance = cache[replacement.moduleName]; + let codemod; + if (!cachedInstance) { + codemod = factory({}); + cache[replacement.moduleName] = codemod; + } else { + codemod = cachedInstance; + } + + try { + const transformResult = await codemod.transform({ + file: { + filename: scanResult.path, + source: newContent + } + }); + + cl.log.success(dedent` + Applying codemod ${pc.cyan(replacement.moduleName)} to ${scanResult.path} + `); + + newContent = transformResult; + } catch (err) { + cl.log.error(dedent` + ${pc.bold('Error:')} the ${pc.cyan(replacement.moduleName)} codemod unexpectedly threw an exception: + + ${err} + `); + } + } + + if (scanResult.contents !== newContent) { + await writeFile(scanResult.path, newContent, 'utf8'); + } +} + +export async function fixFiles(scanResults: FileReplacement[]): Promise { + const codemodCache: Record = {}; + + for (const result of scanResults) { + await fixFile(result, codemodCache); + } +} diff --git a/src/stages/scan-dependencies.ts b/src/stages/scan-dependencies.ts new file mode 100644 index 0000000..04e47a9 --- /dev/null +++ b/src/stages/scan-dependencies.ts @@ -0,0 +1,26 @@ +import * as modReplacements from 'module-replacements'; +import {type DependencyReplacement} from '../shared-types.js'; +import {suggestReplacement} from '../suggest-replacement.js'; + +export function scanDependencies( + dependencies: Record, + replacements: modReplacements.ModuleReplacement[], + source: 'dependencies' | 'devDependencies' +): DependencyReplacement[] { + const results: DependencyReplacement[] = []; + + for (const key in dependencies) { + for (const replacement of replacements) { + if (key === replacement.moduleName) { + results.push({ + replacement, + name: replacement.moduleName, + source + }); + suggestReplacement(replacement, {type: 'package', source}); + } + } + } + + return results; +} diff --git a/src/stages/scan-files.ts b/src/stages/scan-files.ts new file mode 100644 index 0000000..8ba9a66 --- /dev/null +++ b/src/stages/scan-files.ts @@ -0,0 +1,104 @@ +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'; + +async function scanFile( + filePath: string, + contents: string, + lines: string[], + replacements: modReplacements.ModuleReplacement[] +): Promise { + const ast = sg.parse(contents); + const root = ast.root(); + const result: FileReplacement = { + path: filePath, + contents, + replacements: [] + }; + + 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) { + result.replacements.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 + }); + } + } + + return result; +} + +export async function scanFiles( + files: string[], + replacements: modReplacements.ModuleReplacement[], + spinner: ReturnType +): Promise { + const results: FileReplacement[] = []; + + for (const file of files) { + try { + const contents = await readFile(file, 'utf8'); + const lines = contents.split('\n'); + + spinner.message(`Scanning ${file}`); + + results.push(await scanFile(file, contents, lines, replacements)); + } catch (err) { + cl.log.error(dedent` + Could not read file ${file}: + + ${String(err)} + `); + } + } + + return results; +} diff --git a/src/stages/traverse-files.ts b/src/stages/traverse-files.ts new file mode 100644 index 0000000..025543f --- /dev/null +++ b/src/stages/traverse-files.ts @@ -0,0 +1,17 @@ +import {fdir} from 'fdir'; +import {extname} from 'node:path'; + +const knownFileExtensions = new Set(['.tsx', '.ts', '.js', '.jsx']); + +export async function traverseFiles(dirPath: string): Promise { + const fileScanner = new fdir(); + + fileScanner + .withFullPaths() + .exclude((dirName) => dirName === 'node_modules') + .filter((filePath, isDir) => { + return !isDir && knownFileExtensions.has(extname(filePath)); + }); + + return await fileScanner.crawl(dirPath).withPromise(); +} diff --git a/src/suggest-replacement.ts b/src/suggest-replacement.ts new file mode 100644 index 0000000..f046201 --- /dev/null +++ b/src/suggest-replacement.ts @@ -0,0 +1,88 @@ +import * as modReplacements from 'module-replacements'; +import pc from 'picocolors'; +import dedent from 'dedent'; +import * as cl from '@clack/prompts'; +import {getDocsUrl, getMdnUrl} from './replacement-urls.js'; +import {type Source} from './shared-types.js'; + +function renderSource(source: Source): string { + switch (source.type) { + case 'package': + return dedent` + ${pc.bold('package.json')} + `; + case 'file': + return dedent` + ${pc.bold(`${source.path} (${source.line}:${source.column})`)} + + ${source.snippet} + `; + } +} + +function suggestDocumentedReplacement( + replacement: modReplacements.DocumentedModuleReplacement, + source: Source +): void { + cl.log.warn(dedent` + ${pc.bold(replacement.moduleName)} - ${renderSource(source)} + + Module ${pc.cyan(replacement.moduleName)} could be replaced with a more performant alternative. + + You can find an alternative in the following documentation: + ${pc.underline(getDocsUrl(replacement.docPath))} + `); +} + +function suggestNativeReplacement( + replacement: modReplacements.NativeModuleReplacement, + source: Source +): void { + cl.log.warn(dedent` + ${pc.bold(replacement.moduleName)} - ${renderSource(source)} + + Module ${pc.cyan(replacement.moduleName)} could be replaced with the following native functionality: + + ${pc.underline(getMdnUrl(replacement.mdnPath))} + `); +} + +function suggestNoneReplacement( + replacement: modReplacements.NoModuleReplacement, + source: Source +): void { + cl.log.warn(dedent` + ${pc.bold(replacement.moduleName)} - ${renderSource(source)} + + Module ${pc.cyan(replacement.moduleName)} could be removed or replaced with a more performant alternative. + `); +} + +function suggestSimpleReplacement( + replacement: modReplacements.SimpleModuleReplacement, + source: Source +): void { + cl.log.warn(dedent` + ${pc.bold(replacement.moduleName)} - ${renderSource(source)} + + Module ${pc.cyan(replacement.moduleName)} could be replaced inline/native equivalent logic. + + ${replacement.replacement} + `); +} + +export function suggestReplacement( + replacement: modReplacements.ModuleReplacement, + source: Source +): void { + switch (replacement.type) { + case 'documented': + return suggestDocumentedReplacement(replacement, source); + case 'native': + return suggestNativeReplacement(replacement, source); + case 'none': + return suggestNoneReplacement(replacement, source); + case 'simple': + return suggestSimpleReplacement(replacement, source); + } +}