ember-template-lint
Version:
Linter for Ember or Handlebars templates.
369 lines (317 loc) • 10.4 kB
JavaScript
/* eslint require-atomic-updates:"off" */
// @ts-check
// Use V8's code cache to speed up instantiation time:
import 'v8-compile-cache'; // eslint-disable-line import/no-unassigned-import
import {
compactTodoStorageFile,
getTodoStorageFilePath,
getTodoConfig,
validateConfig,
} from '@lint-todo/utils';
import getStdin from 'get-stdin';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
import { Worker } from 'node:worker_threads';
import os from 'node:os';
import { parseArgv, getFilesToLint } from '../lib/helpers/cli.js';
import printResults from '../lib/helpers/print-results.js';
import processResults from '../lib/helpers/process-results.js';
import Linter from '../lib/linter.js';
import { getProjectConfig } from '../lib/get-config.js';
import { processWithPool } from '../lib/-private/process-with-pool.js';
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
const STDIN = '/dev/stdin';
const MIN_FILES_TO_USE_WORKERS = 100;
const NOOP_CONSOLE = {
log: () => {},
warn: () => {},
error: () => {},
};
function removeExt(filePath) {
return filePath.slice(0, -path.extname(filePath).length);
}
async function buildLinterOptions(workingDir, filePath, filename = '', stdin) {
if (stdin) {
let filePath = filename;
let moduleId = removeExt(filePath);
let source = stdin;
return { source, filePath, moduleId };
} else {
let moduleId = removeExt(filePath);
let resolvedFilePath = path.resolve(workingDir, filePath);
let source = await readFile(resolvedFilePath, { encoding: 'utf8' });
return { source, filePath, moduleId };
}
}
function getTodoConfigFromCommandLineOptions(options) {
let todoConfig = {};
if (Number.isInteger(options.todoDaysToWarn)) {
todoConfig.warn = options.todoDaysToWarn || undefined;
}
if (Number.isInteger(options.todoDaysToError)) {
todoConfig.error = options.todoDaysToError || undefined;
}
return todoConfig;
}
function _isOverridingConfig(options) {
let defaultArgs = parseArgv(['fake-file-to-get-default-options.hbs']);
return Boolean(
options.config !== defaultArgs.config ||
options.rule !== defaultArgs.rule ||
options.inlineConfig !== defaultArgs.inlineConfig ||
options.configPath !== defaultArgs.configPath
);
}
function _todoStorageDirExists(baseDir) {
try {
return fs.lstatSync(getTodoStorageFilePath(baseDir)).isDirectory();
} catch (error) {
if (error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function run() {
let options = parseArgv(process.argv.slice(2));
let positional = options._;
let config;
let isOverridingConfig = _isOverridingConfig(options);
let shouldWriteToStdout = !(options.quiet || ['sarif', 'json'].includes(options.format));
let _console = shouldWriteToStdout ? console : NOOP_CONSOLE;
if (options.config) {
try {
config = JSON.parse(options.config);
} catch {
console.error('Could not parse specified `--config` as JSON');
process.exitCode = 1;
return;
}
}
if (options['no-config-path'] !== undefined) {
options.configPath = false;
}
let todoConfigResult = validateConfig(options.workingDirectory);
if (!todoConfigResult.isValid) {
console.error(todoConfigResult.message);
process.exitCode = 1;
return;
}
if (_todoStorageDirExists(options.workingDirectory)) {
console.error(
'Found `.lint-todo` directory. Please run `npx @lint-todo/migrator .` to convert to the new todo file format'
);
process.exitCode = 1;
return;
}
if (options.compactTodo) {
let { compacted } = compactTodoStorageFile(options.workingDirectory);
_console.log(`Removed ${compacted} todos in .lint-todo storage file`);
process.exitCode = 0;
return;
}
let linter;
let todoInfo = {
added: 0,
removed: 0,
todoConfig: getTodoConfig(
options.workingDirectory,
'ember-template-lint',
getTodoConfigFromCommandLineOptions(options)
),
};
try {
linter = new Linter({
workingDir: options.workingDirectory,
configPath: options.configPath,
config,
rule: options.rule,
allowInlineConfig: !options.noInlineConfig,
reportUnusedDisableDirectives: options.reportUnusedDisableDirectives,
console: _console,
});
await linter.getConfig();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
return;
}
if ((options.todoDaysToWarn || options.todoDaysToError) && !options.updateTodo) {
console.error(
'Using `--todo-days-to-warn` or `--todo-days-to-error` is only valid when the `--update-todo` option is being used.'
);
process.exitCode = 1;
return;
}
let filePaths;
try {
let config = await getProjectConfig(options.workingDirectory, options);
filePaths = getFilesToLint(
options.workingDirectory,
positional,
options.ignorePattern,
options.errorOnUnmatchedPattern !== false,
config,
_console
);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
return;
}
let resultsAccumulator = [];
let debug = false;
if (debug) {
console.time('Linting');
}
const filePathsArray = [...filePaths];
const isReadingStdin = filePaths.has(STDIN);
const maybeStdin = isReadingStdin ? await getStdin() : undefined;
// Handle --print-config option early, as it should exit immediately
if (options.printConfig) {
if (filePathsArray.length > 1) {
console.error('The --print-config option must be used with exactly one file name.');
process.exitCode = 1;
return;
}
const relativeFilePath = filePathsArray[0];
let linterOptions = await buildLinterOptions(
options.workingDirectory,
relativeFilePath,
options.filename,
maybeStdin
);
let fileConfig = await linter.getConfigForFile(linterOptions);
_console.log(JSON.stringify(fileConfig, null, 2));
process.exitCode = 0;
return;
}
// Process a single file and return its results
async function processFile(relativeFilePath) {
let linterOptions = await buildLinterOptions(
options.workingDirectory,
relativeFilePath,
options.filename,
maybeStdin
);
let fileResults;
if (options.fix) {
let { isFixed, output, messages } = await linter.verifyAndFix(linterOptions);
if (isFixed) {
await writeFile(linterOptions.filePath, output, { encoding: 'utf-8' });
}
fileResults = messages;
} else {
fileResults = await linter.verify(linterOptions);
}
// Return both the file path and its results to maintain ordering information
return {
filePath: relativeFilePath,
linterOptions,
results: fileResults,
};
}
// Process files with worker threads or existing pool
let allResults;
if (!isReadingStdin && filePathsArray.length >= MIN_FILES_TO_USE_WORKERS) {
// Use worker threads for parallel processing
const cpuCount = os.cpus().length;
const workerCount = Math.max(1, cpuCount - 1);
let amountOfFilesPerWorker = Math.ceil(filePathsArray.length / workerCount);
if (amountOfFilesPerWorker < MIN_FILES_TO_USE_WORKERS) {
// creating a worker is quite expensive, so, we need an minimal amount of files per worker
// to get real performance benefits
amountOfFilesPerWorker = MIN_FILES_TO_USE_WORKERS;
}
if (debug) {
console.log(`Using ${workerCount} workers to lint files`);
console.log(
`Processing ${filePathsArray.length} files in batches of ${amountOfFilesPerWorker}`
);
}
// Split files into batches
const batches = [];
for (let i = 0; i < filePathsArray.length; i += amountOfFilesPerWorker) {
batches.push(filePathsArray.slice(i, i + amountOfFilesPerWorker));
}
const results = batches.map((batch) => {
return runWorker({
filePaths: batch,
options: structuredClone(options),
});
});
const awaitedResult = await Promise.all(results);
allResults = awaitedResult.flat();
} else {
// Use existing pool for stdin or smaller file sets
allResults = await processWithPool(filePathsArray, 10, processFile);
}
if (options.updateTodo) {
for (const result of allResults) {
let { addedCount, removedCount } = linter.updateTodo(
result.linterOptions,
result.results,
todoInfo.todoConfig,
isOverridingConfig
);
todoInfo.added += addedCount;
todoInfo.removed += removedCount;
}
}
if (!isReadingStdin && linter.configureTodos()) {
for (const result of allResults) {
result.results = linter.processTodos(
result.linterOptions,
result.results,
todoInfo.todoConfig,
options.fix || options.cleanTodo,
isOverridingConfig
);
}
}
// Flatten all results into the accumulator
for (const { results } of allResults) {
resultsAccumulator.push(...results);
}
// Sort the resultsAccumulator to match the original file order in filePathsArray
resultsAccumulator.sort((a, b) => {
// Find the original index in filePathsArray for each result
const indexA = filePathsArray.indexOf(a.filePath);
const indexB = filePathsArray.indexOf(b.filePath);
// Sort based on original position
return indexA - indexB;
});
let results = processResults(resultsAccumulator);
if (
results.errorCount > 0 ||
(!options.quiet && options.maxWarnings && results.warningCount > options.maxWarnings)
) {
process.exitCode = 1;
}
await printResults(results, { options, todoInfo, config: linter.config });
if (debug) {
console.timeEnd('Linting');
}
}
run();
function runWorker(workerData) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('../lib/-private/lint-worker.js', import.meta.url), {
workerData: {
filePaths: workerData.filePaths,
options: workerData.options,
},
});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
}