@baseplate-dev/sync
Version:
Library for syncing Baseplate descriptions
292 lines • 12.7 kB
JavaScript
import path from 'node:path';
import { ConflictDetectedError, FormatterError } from '../errors.js';
import { buildCompositeMergeAlgorithm, diff3MergeAlgorithm, gitMergeDriverAlgorithmGenerator, jsonMergeAlgorithm, simpleDiffAlgorithm, } from '../string-merge-algorithms/index.js';
/**
* Normalize a buffer or string to a buffer
*
* @param val - Buffer or string
* @returns Buffer
*/
function normalizeBufferString(val) {
if (typeof val === 'string') {
return Buffer.from(val, 'utf8');
}
return val;
}
/**
* Check if two buffers or strings are equal
*
* @param a - Buffer or string
* @param b - Buffer or string
* @returns Whether the two buffers or strings are equal
*/
function areBufferStringsEqual(a, b) {
if (typeof a === 'string' && typeof b === 'string')
return a === b;
const bufferA = normalizeBufferString(a);
const bufferB = normalizeBufferString(b);
return bufferA.equals(bufferB);
}
/**
* Format the contents of a file
*
* @param relativePath - Relative path of the file
* @param data - File data
* @param context - Context
* @param context.outputDirectory - Output directory
* @param context.formatters - Formatters to use
* @param context.logger - Logger to use
* @returns Formatted contents of the file
*/
export async function formatOutputFileContents(relativePath, data, { outputDirectory, formatters, logger, }) {
const { options, contents } = data;
if (options?.skipFormatting)
return contents;
if (Buffer.isBuffer(contents)) {
throw new TypeError(`Contents of file for ${relativePath} cannot be formatted since it is a Buffer`);
}
const formattersForFile = formatters.filter((f) => !!f.fileExtensions?.some((ext) => path.extname(relativePath) === ext) ||
!!f.fileNames?.some((name) => path.basename(relativePath) === name));
if (formattersForFile.length > 1) {
throw new Error(`Multiple formatters found for file ${relativePath}: ${formattersForFile.map((f) => f.name).join(', ')}`);
}
const formatter = formattersForFile.at(0);
if (!formatter)
return contents;
try {
const filePath = path.join(outputDirectory, relativePath);
return await formatter.format(contents, filePath, logger);
}
catch (error) {
throw new FormatterError(error, contents, relativePath);
}
}
/**
* Merge buffer contents. It always creates a conflict since we don't have
* a good way to merge buffer contents.
*
* @param input - Merge contents input
* @returns Merge contents result
*/
async function mergeBufferContents({ data, relativePath, previousRelativePath, previousWorkingBuffer, context, }) {
// Check for a conflict version of the working file
const conflictVersion = await context.previousWorkingCodebase?.fileExists(`${relativePath}.conflict`);
if (conflictVersion) {
throw new ConflictDetectedError(relativePath);
}
return {
relativePath,
previousRelativePath,
mergedContents: previousWorkingBuffer,
generatedContents: normalizeBufferString(data.contents),
generatedConflictRelativePath: `${relativePath}.conflict`,
hasConflict: true,
};
}
/**
* Merge string contents
*
* @param input - Merge contents input
* @returns Merge contents result
*/
async function mergeStringContents({ relativePath, data, previousRelativePath, previousGeneratedBuffer, previousWorkingBuffer, context, }) {
const { options = {}, contents } = data;
if (Buffer.isBuffer(contents)) {
throw new TypeError(`Contents of file for ${relativePath} must be provided as a string to be merged`);
}
const previousWorkingText = previousWorkingBuffer.toString('utf8');
const currentGeneratedText = contents;
const previousGeneratedText = previousGeneratedBuffer?.toString('utf8');
// Detect conflicts in the working file
if (/^<<<<<<</m.test(previousWorkingText) &&
/^>>>>>>>/m.test(previousWorkingText)) {
throw new ConflictDetectedError(relativePath);
}
const mergeAlgorithm = buildCompositeMergeAlgorithm([
...(options.mergeAlgorithms ?? []),
...(relativePath.endsWith('.json') ? [jsonMergeAlgorithm] : []),
...(context.mergeDriver
? [gitMergeDriverAlgorithmGenerator(context.mergeDriver)]
: []),
diff3MergeAlgorithm,
]);
// if there's a previous generated file, we do a 3-way merge otherwise we do a simple diff
const mergeResult = previousGeneratedText
? await mergeAlgorithm({
previousWorkingText,
currentGeneratedText,
previousGeneratedText,
filePath: relativePath,
})
: simpleDiffAlgorithm({
previousWorkingText,
currentGeneratedText,
});
if (mergeResult) {
// do not format if there is a conflict
const formattedMergeResult = mergeResult.hasConflict
? mergeResult.mergedText
: await formatOutputFileContents(relativePath, { ...data, contents: mergeResult.mergedText }, context);
return {
relativePath,
previousRelativePath,
mergedContents: normalizeBufferString(formattedMergeResult),
generatedContents: normalizeBufferString(contents),
hasConflict: mergeResult.hasConflict,
};
}
throw new Error(`Unable to merge ${relativePath} with ${path.join(context.outputDirectory, previousRelativePath)}`);
}
/**
* Find the relative path of the file in the previous generated codebase
*
* @param data - File data
* @param relativePath - Relative path of the file
* @param context - Generator output file writer context
* @returns The relative path of the file in the previous generated codebase or undefined if it does not exist
*/
async function findPreviousRelativePath(data, relativePath, context) {
const { previousGeneratedPayload, previousWorkingCodebase } = context;
// If the file exists in the previous working codebase, we use it as our
// previous relative path. If the file was renamed to an existing file, the
// engine will attempt to merge the existing file with the new generated code
// and the previous generated file will be deleted with the standard logic.
const previousWorkingFileExists = await previousWorkingCodebase?.fileExists(relativePath);
if (previousWorkingFileExists)
return relativePath;
// If there's no previous generated payload, there is no previous relative path
if (!previousGeneratedPayload)
return undefined;
// Find the previous generated file ID that matches the current file ID
const previousGeneratedFileIds = [
data.id,
...(data.options?.alternateFullIds ?? []),
].filter((id) => previousGeneratedPayload.fileIdToRelativePathMap.has(id));
if (previousGeneratedFileIds.length > 1) {
throw new Error(`File ${relativePath} has multiple matching previous generated file IDs: ${previousGeneratedFileIds.join(', ')}. ` +
`Please remove the unused matching previous generated file IDs from the codebase map.`);
}
const previousRelativePath = previousGeneratedFileIds[0] &&
previousGeneratedPayload.fileIdToRelativePathMap.get(previousGeneratedFileIds[0]);
if (!previousRelativePath)
return undefined;
// only return the previous relative path if it exists in the previous working codebase
const previousRelativePathExists = await previousWorkingCodebase?.fileExists(previousRelativePath);
return previousRelativePathExists ? previousRelativePath : undefined;
}
/**
* Prepare a file for writing
*
* @param input - Prepare generator file input
* @returns Prepare generator file result
*/
export async function prepareGeneratorFile({ relativePath, data, context, }) {
const { options } = data;
const { previousWorkingCodebase, previousGeneratedPayload } = context;
if (options?.skipWriting) {
return {
relativePath,
mergedContents: undefined,
generatedContents: normalizeBufferString(data.contents),
previousRelativePath: undefined,
};
}
// Find previous relative path
const previousRelativePath = await findPreviousRelativePath(data, relativePath, context);
const formattedContents = await formatOutputFileContents(relativePath, data, context);
// if the file should never overwrite and there is a previous relative path,
// we use the working file version
if (options?.shouldNeverOverwrite && previousRelativePath) {
return {
relativePath,
mergedContents: undefined,
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
// If force overwrite is enabled, bypass all merge logic and use generated content directly
if (context.overwriteOptions?.enabled &&
!context.overwriteOptions.skipFile?.(relativePath)) {
const { applyDiff } = context.overwriteOptions;
const generatedContentsWithDiff = applyDiff
? await applyDiff(relativePath, formattedContents)
: normalizeBufferString(formattedContents);
if (generatedContentsWithDiff) {
const previousWorkingBuffer = await previousWorkingCodebase?.readFile(previousRelativePath ?? relativePath);
if (previousWorkingBuffer?.equals(normalizeBufferString(generatedContentsWithDiff))) {
return {
relativePath,
mergedContents: undefined,
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
return {
relativePath,
mergedContents: normalizeBufferString(generatedContentsWithDiff),
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
else if (generatedContentsWithDiff === undefined) {
// If the file was purposely deleted, we skip the generation
return {
relativePath,
mergedContents: undefined,
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
}
// If we haven't modified the generated version of the file,
// we use the previous working file version
const previousGeneratedBuffer = await previousGeneratedPayload?.fileReader.readFile(previousRelativePath ?? relativePath);
if (previousGeneratedBuffer &&
areBufferStringsEqual(previousGeneratedBuffer, formattedContents)) {
return {
relativePath,
mergedContents: undefined, // use the previous working file version
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
const previousWorkingBuffer = await previousWorkingCodebase?.readFile(previousRelativePath ?? relativePath);
const currentGeneratedBuffer = normalizeBufferString(formattedContents);
// If there is no previous working file, we use the generated file
if (!previousRelativePath || !previousWorkingBuffer) {
return {
relativePath,
mergedContents: currentGeneratedBuffer,
generatedContents: currentGeneratedBuffer,
// If there is a previous generated file, the file was deleted in the
// working codebase but modified in the generated codebase so should be flagged as a conflict
deletedInWorking: previousGeneratedBuffer !== undefined && !previousWorkingBuffer
? true
: undefined,
previousRelativePath,
};
}
// If the previous working file is identical to the generated file, we don't
// need to change anything
if (previousWorkingBuffer.equals(currentGeneratedBuffer)) {
return {
relativePath,
mergedContents: undefined, // use the previous working file version
generatedContents: normalizeBufferString(formattedContents),
previousRelativePath,
};
}
// Otherwise, we merge the contents
const mergeInput = {
relativePath,
data: { ...data, contents: formattedContents },
previousRelativePath,
previousGeneratedBuffer,
previousWorkingBuffer,
context,
};
return Buffer.isBuffer(formattedContents)
? mergeBufferContents(mergeInput)
: mergeStringContents(mergeInput);
}
//# sourceMappingURL=prepare-generator-file.js.map