isaacscript
Version:
A command line tool for managing Isaac mods written in TypeScript.
280 lines • 11.7 kB
JavaScript
import { Command } from "@commander-js/extra-typings";
import chalk from "chalk";
import { ReadonlySet, getEnumValues, trimPrefix } from "complete-common";
import { PackageManager, deleteFileOrDirectory, fatalError, getPackageManagerLockFileNames, isDirectory, isFile, readFile, writeFile, } from "complete-node";
import klawSync from "klaw-sync";
import path from "node:path";
import { ACTION_YML, ACTION_YML_TEMPLATE_PATH, CWD, TEMPLATES_DYNAMIC_DIR, TEMPLATES_STATIC_DIR, } from "../../constants.js";
import { execShell } from "../../exec.js";
const URL_PREFIX = "https://raw.githubusercontent.com/IsaacScript/isaacscript/main/packages/isaacscript-cli/file-templates";
const MARKER_CUSTOMIZATION_START = "@template-customization-start";
const MARKER_CUSTOMIZATION_END = "@template-customization-end";
const MARKER_IGNORE_BLOCK_START = "@template-ignore-block-start";
const MARKER_IGNORE_BLOCK_END = "@template-ignore-block-end";
const MARKER_IGNORE_NEXT_LINE = "@template-ignore-next-line";
const PACKAGE_MANAGER_STRINGS = [
"PACKAGE_MANAGER_NAME",
"PACKAGE_MANAGER_INSTALL_COMMAND",
"PACKAGE_MANAGER_LOCK_FILE_NAME",
...getEnumValues(PackageManager),
...getPackageManagerLockFileNames(),
];
export const checkCommand = new Command()
.command("check")
.description("Check the template files of the current IsaacScript mod to see if they are up to date.")
.allowExcessArguments(false) // By default, Commander.js will allow extra positional arguments.
.helpOption("-h, --help", "Display the list of options for this command.")
.option("--ignore <ignoreList>", "Comma separated list of file names to ignore.")
.option("-v, --verbose", "Enable verbose output.", false)
.action((options) => {
check(options);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const checkOptions = checkCommand.opts();
function check(options) {
const { verbose } = options;
let oneOrMoreErrors = false;
const ignore = options.ignore ?? "";
const ignoreFileNames = ignore.split(",");
const ignoreFileNamesSet = new ReadonlySet(ignoreFileNames);
// First, check the static files that are shared between TypeScript projects and IsaacScript mods.
if (checkTemplateDirectory(TEMPLATES_STATIC_DIR, ignoreFileNamesSet, verbose)) {
oneOrMoreErrors = true;
}
// Second, check dynamic files that require specific logic.
if (checkIndividualFiles(ignoreFileNamesSet, verbose)) {
oneOrMoreErrors = true;
}
if (oneOrMoreErrors) {
fatalError("The check command failed.");
}
}
function checkTemplateDirectory(templateDirectory, ignoreFileNamesSet, verbose) {
let oneOrMoreErrors = false;
for (const klawItem of klawSync(templateDirectory)) {
const templateFilePath = klawItem.path;
if (isDirectory(templateFilePath)) {
continue;
}
const originalFileName = path.basename(templateFilePath);
if (originalFileName === "main.ts") {
continue;
}
const relativeTemplateFilePath = path.relative(templateDirectory, templateFilePath);
const templateFileName = path.basename(relativeTemplateFilePath);
let projectFilePath = path.join(CWD, relativeTemplateFilePath);
switch (templateFileName) {
case "_cspell.config.jsonc": {
projectFilePath = path.resolve(projectFilePath, "..", "cspell.config.jsonc");
break;
}
case "_gitattributes": {
projectFilePath = path.resolve(projectFilePath, "..", ".gitattributes");
break;
}
default: {
break;
}
}
const projectFileName = path.basename(projectFilePath);
if (ignoreFileNamesSet.has(projectFileName)) {
continue;
}
if (!compareTextFiles(projectFilePath, templateFilePath, verbose)) {
oneOrMoreErrors = true;
}
}
return oneOrMoreErrors;
}
function checkIndividualFiles(ignoreFileNamesSet, verbose) {
let oneOrMoreErrors = false;
if (!ignoreFileNamesSet.has(ACTION_YML)) {
const templateFilePath = ACTION_YML_TEMPLATE_PATH;
const relativeTemplateFilePath = path.relative(TEMPLATES_DYNAMIC_DIR, templateFilePath);
const projectFilePath = path.join(CWD, relativeTemplateFilePath);
if (!compareTextFiles(projectFilePath, templateFilePath, verbose)) {
oneOrMoreErrors = true;
}
}
return oneOrMoreErrors;
}
/** @returns Whether the project file is valid in reference to the template file. */
function compareTextFiles(projectFilePath, templateFilePath, verbose) {
if (!isFile(projectFilePath)) {
console.log(`Failed to find the following file: ${projectFilePath}`);
printTemplateLocation(templateFilePath);
return false;
}
const projectFileObject = getTruncatedFileText(projectFilePath, new Set(), new Set());
const templateFileObject = getTruncatedFileText(templateFilePath, projectFileObject.ignoreLines, projectFileObject.linesBeforeIgnore);
if (projectFileObject.text === templateFileObject.text) {
return true;
}
console.log(`The contents of the following file do not match: ${chalk.red(projectFilePath)}`);
printTemplateLocation(templateFilePath);
if (verbose) {
const originalTemplateFile = readFile(templateFilePath);
const originalProjectFile = readFile(projectFilePath);
console.log("--- Original template file: ---\n");
console.log(originalTemplateFile);
console.log();
console.log("--- Original project file: ---\n");
console.log(originalProjectFile);
console.log();
console.log("--- Parsed template file: ---\n");
console.log(templateFileObject.text);
console.log();
console.log("--- Parsed project file: ---\n");
console.log(projectFileObject.text);
console.log();
}
const tempProjectFilePath = path.join(CWD, "tempProjectFile.txt");
const tempTemplateFilePath = path.join(CWD, "tempTemplateFile.txt");
writeFile(tempProjectFilePath, projectFileObject.text);
writeFile(tempTemplateFilePath, templateFileObject.text);
const { stdout } = execShell("diff", [tempProjectFilePath, tempTemplateFilePath, "--ignore-blank-lines"], verbose, true);
console.log(`${stdout}\n`);
deleteFileOrDirectory(tempProjectFilePath);
deleteFileOrDirectory(tempTemplateFilePath);
return false;
}
function getTruncatedFileText(filePath, ignoreLines, linesBeforeIgnore) {
const fileName = path.basename(filePath);
const fileContents = readFile(filePath);
return getTruncatedText(fileName, fileContents, ignoreLines, linesBeforeIgnore);
}
/**
* @param fileName Used to perform some specific rules based on the template file name.
* @param text The text to parse.
* @param ignoreLines A set of lines to remove from the text.
* @param linesBeforeIgnore A set of lines that will trigger the subsequent line to be ignored.
* @returns The text of the file with all text removed between any flagged markers (and other
* specific hard-coded exclusions), as well as an array of lines that had a
* "ignore-next-line" marker below them.
*/
export function getTruncatedText(fileName, text, ignoreLines, linesBeforeIgnore) {
const lines = text.split("\n");
const newLines = [];
const newIgnoreLines = new Set();
const newLinesBeforeIgnore = new Set();
let isSkipping = false;
let isIgnoring = false;
let shouldIgnoreNextLine = false;
let previousLine = "";
for (const line of lines) {
if (line.trim() === "") {
continue;
}
if (ignoreLines.has(line.trim())) {
continue;
}
if (shouldIgnoreNextLine) {
shouldIgnoreNextLine = false;
continue;
}
if (linesBeforeIgnore.has(line)) {
shouldIgnoreNextLine = true;
}
// -------------
// Marker checks
// -------------
if (line.includes(MARKER_CUSTOMIZATION_START)) {
isSkipping = true;
continue;
}
if (line.includes(MARKER_CUSTOMIZATION_END)) {
isSkipping = false;
continue;
}
if (line.includes(MARKER_IGNORE_BLOCK_START)) {
isIgnoring = true;
continue;
}
if (line.includes(MARKER_IGNORE_BLOCK_END)) {
isIgnoring = false;
continue;
}
if (line.includes(MARKER_IGNORE_NEXT_LINE)) {
shouldIgnoreNextLine = true;
// We mark the previous line so that we know the next line to skip in the template.
if (previousLine.trim() === "") {
fatalError(`You cannot have a "${MARKER_IGNORE_NEXT_LINE}" marker before a blank line in the "${fileName}" file.`);
}
newLinesBeforeIgnore.add(previousLine);
continue;
}
if (isIgnoring) {
const baseLine = trimPrefix(line.trim(), "// ");
newIgnoreLines.add(baseLine);
continue;
}
// --------------------
// Specific file checks
// --------------------
// We should ignore imports in JavaScript or TypeScript files.
if (fileName.endsWith(".js") || fileName.endsWith(".ts")) {
if (line === "import {") {
isSkipping = true;
continue;
}
if (line.startsWith("} from ")) {
isSkipping = false;
continue;
}
if (line.startsWith("import ")) {
continue;
}
}
// End-users can have different ignored words.
if (fileName === "cspell.config.jsonc"
|| fileName === "_cspell.config.jsonc") {
if (line.match(/"words": \[.*]/) !== null) {
continue;
}
if (line.includes('"words": [')) {
isSkipping = true;
continue;
}
if ((line.endsWith("]") || line.endsWith("],")) && isSkipping) {
isSkipping = false;
continue;
}
}
if (fileName === "ci.yml" || fileName === "action.yml") {
// End-users can have different package managers.
if (hasPackageManagerString(line)) {
continue;
}
// Ignore comments, since end-users are expected to delete the explanations.
if (line.match(/^\s*#/) !== null) {
continue;
}
}
// ------------
// Final checks
// ------------
if (!isSkipping) {
newLines.push(line);
previousLine = line;
}
}
const newText = newLines.join("\n");
return {
text: newText,
ignoreLines: newIgnoreLines,
linesBeforeIgnore: newLinesBeforeIgnore,
};
}
function printTemplateLocation(templateFilePath) {
const unixPath = templateFilePath.split(path.sep).join(path.posix.sep);
const match = unixPath.match(/.+\/file-templates\/(?<urlSuffix>.+)/);
if (match === null || match.groups === undefined) {
fatalError(`Failed to parse the template file path: ${templateFilePath}`);
}
const { urlSuffix } = match.groups;
console.log(`You can find the template at: ${chalk.green(`${URL_PREFIX}/${urlSuffix}`)}\n`);
}
function hasPackageManagerString(line) {
return PACKAGE_MANAGER_STRINGS.some((string) => line.includes(string));
}
//# sourceMappingURL=check.js.map