295 lines
9.6 KiB
TypeScript
295 lines
9.6 KiB
TypeScript
// Define the patch structure
|
|
export interface YamlPatch {
|
|
op: "set-key" | "delete-key";
|
|
path: string; // Still assuming simple, top-level key names
|
|
value?: any; // Required for set-key, not used for delete-key
|
|
}
|
|
|
|
// Helper function specifically for serializing scalar types
|
|
function serializeToYamlScalar(
|
|
value: string | number | boolean | null,
|
|
): string {
|
|
if (typeof value === "string") {
|
|
// Always quote empty strings and strings with special characters
|
|
if (
|
|
value === "" || // Empty string
|
|
value.match(/[:{#}[],&*!|>'"%@`]/) || // Special YAML characters (added % and ")
|
|
/^\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value) || // Looks like a number
|
|
value.includes(":") || // Contains colons
|
|
["true", "false", "null", "yes", "no", "on", "off"].includes(
|
|
value.toLowerCase(),
|
|
)
|
|
) {
|
|
return JSON.stringify(value);
|
|
}
|
|
// Simple strings without special chars/meaning don't need quotes
|
|
return value;
|
|
} else if (
|
|
typeof value === "number" ||
|
|
typeof value === "boolean" ||
|
|
value === null
|
|
) {
|
|
return String(value);
|
|
}
|
|
// Default for unsupported scalar types (e.g., undefined)
|
|
return "null";
|
|
}
|
|
|
|
// Updated helper function to serialize various JS types to YAML string representations
|
|
// Added baseIndentation parameter for handling nested lists correctly (though we only use it for top-level lists here)
|
|
function serializeToYamlValue(
|
|
value: any,
|
|
baseIndentation: string = "",
|
|
): string {
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
return "[]"; // Use flow style for empty arrays for simplicity
|
|
}
|
|
// Determine indentation for list items (base + 2 spaces)
|
|
const itemIndentation = `${baseIndentation} `;
|
|
// Format each item recursively, preceded by '- ' marker
|
|
return (
|
|
"\n" +
|
|
value
|
|
.map(
|
|
(item) =>
|
|
`${itemIndentation}- ${serializeToYamlValue(item, itemIndentation)}`,
|
|
)
|
|
.join("\n")
|
|
);
|
|
// Note: serializeToYamlValue is used recursively here to handle nested arrays/objects if needed in future
|
|
// However, the current `applyMinimalSetKeyPatches` only handles top-level keys.
|
|
} else if (typeof value === "object" && value !== null) {
|
|
// Basic object serialization (not requested, but good to consider)
|
|
// This is highly simplified and doesn't handle nesting well without more context
|
|
const itemIndentation = `${baseIndentation} `;
|
|
const entries = Object.entries(value);
|
|
if (entries.length === 0) return "{}"; // Flow style empty objects
|
|
return (
|
|
"\n" +
|
|
entries
|
|
.map(
|
|
([key, val]) =>
|
|
`${itemIndentation}${key}: ${serializeToYamlValue(
|
|
val,
|
|
itemIndentation,
|
|
)}`,
|
|
)
|
|
.join("\n")
|
|
);
|
|
} else {
|
|
// Handle scalars using the dedicated function
|
|
return serializeToYamlScalar(value);
|
|
}
|
|
}
|
|
|
|
export function applyPatches(yamlString: string, patches: YamlPatch[]): string {
|
|
let currentYaml = yamlString;
|
|
|
|
for (const patch of patches) {
|
|
if (patch.op === "delete-key") {
|
|
// Handle delete operation
|
|
const key = patch.path;
|
|
const lines = currentYaml.split("\n");
|
|
let keyLineIndex = -1;
|
|
let startDeleteIndex = -1;
|
|
let endDeleteIndex = -1;
|
|
|
|
// Find the key line
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmedLine = line.trim();
|
|
if (trimmedLine.startsWith(`${key}:`)) {
|
|
keyLineIndex = i;
|
|
startDeleteIndex = i;
|
|
endDeleteIndex = i;
|
|
|
|
// Look backwards for preceding comments (delete them too)
|
|
for (let j = i - 1; j >= 0; j--) {
|
|
const prevLine = lines[j].trim();
|
|
if (prevLine.startsWith("#")) {
|
|
startDeleteIndex = j;
|
|
} else if (prevLine !== "") {
|
|
break;
|
|
} else {
|
|
// Empty line - include it in deletion if followed by comments
|
|
if (startDeleteIndex < i) {
|
|
startDeleteIndex = j;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Determine the indentation of the key
|
|
const keyIndent = lines[i].match(/^(\s*)/)?.[1] || "";
|
|
|
|
// Look forwards to find all content belonging to this key
|
|
// This includes list items and nested objects
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const nextLine = lines[j];
|
|
const trimmedNextLine = nextLine.trim();
|
|
|
|
// Empty line - continue looking
|
|
if (trimmedNextLine === "") {
|
|
continue;
|
|
}
|
|
|
|
// Comment at same or greater indentation - might be trailing comment
|
|
if (trimmedNextLine.startsWith("#")) {
|
|
const commentIndent = nextLine.match(/^(\s*)/)?.[1] || "";
|
|
if (commentIndent.length > keyIndent.length) {
|
|
endDeleteIndex = j;
|
|
continue;
|
|
} else {
|
|
// Comment at same or less indentation - not part of this key
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check indentation of next non-empty, non-comment line
|
|
const nextIndent = nextLine.match(/^(\s*)/)?.[1] || "";
|
|
if (nextIndent.length > keyIndent.length) {
|
|
// This line is indented more than the key, so it belongs to the key
|
|
endDeleteIndex = j;
|
|
} else {
|
|
// This line is at same or less indentation, so it's a new key
|
|
break;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (keyLineIndex !== -1) {
|
|
// Delete the lines from startDeleteIndex to endDeleteIndex (inclusive)
|
|
const beforeDelete = lines.slice(0, startDeleteIndex);
|
|
const afterDelete = lines.slice(endDeleteIndex + 1);
|
|
|
|
// Clean up excessive empty lines at the boundary
|
|
// Remove trailing empty lines from beforeDelete
|
|
while (
|
|
beforeDelete.length > 0 &&
|
|
beforeDelete[beforeDelete.length - 1].trim() === ""
|
|
) {
|
|
beforeDelete.pop();
|
|
}
|
|
|
|
// Remove leading empty lines from afterDelete (but keep one if there's content after)
|
|
let leadingEmptyCount = 0;
|
|
for (const line of afterDelete) {
|
|
if (line.trim() === "") {
|
|
leadingEmptyCount++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
const cleanedAfterDelete = afterDelete.slice(
|
|
Math.min(leadingEmptyCount, 1),
|
|
);
|
|
|
|
currentYaml = [...beforeDelete, ...cleanedAfterDelete].join("\n");
|
|
if (currentYaml && !currentYaml.endsWith("\n")) {
|
|
currentYaml += "\n";
|
|
}
|
|
}
|
|
// If key not found, do nothing
|
|
continue;
|
|
}
|
|
|
|
if (patch.op !== "set-key") continue;
|
|
|
|
const key = patch.path;
|
|
|
|
// Split the YAML into lines for easier processing
|
|
const lines = currentYaml.split("\n");
|
|
let keyLineIndex = -1;
|
|
let commentBlock = "";
|
|
let trailingComments = "";
|
|
let inlineComment = "";
|
|
|
|
// Find the key line and collect comments
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmedLine = line.trim();
|
|
if (trimmedLine.startsWith(`${key}:`)) {
|
|
keyLineIndex = i;
|
|
// Extract inline comment if present
|
|
const commentMatch = line.match(/#.*$/);
|
|
if (commentMatch) {
|
|
inlineComment = commentMatch[0];
|
|
}
|
|
// Look backwards for comments
|
|
for (let j = i - 1; j >= 0; j--) {
|
|
const prevLine = lines[j].trim();
|
|
if (prevLine.startsWith("#")) {
|
|
commentBlock = `${lines[j]}\n${commentBlock}`;
|
|
} else if (prevLine !== "") {
|
|
break;
|
|
}
|
|
}
|
|
// Look forwards for comments
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const nextLine = lines[j].trim();
|
|
if (nextLine.startsWith("#")) {
|
|
trailingComments += `${lines[j]}\n`;
|
|
} else if (nextLine !== "") {
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Serialize the new value
|
|
const serializedNewValue = serializeToYamlValue(patch.value);
|
|
|
|
// Create the replacement line
|
|
let replacementLine: string;
|
|
if (serializedNewValue.startsWith("\n")) {
|
|
// For lists/objects, the key line ends with just ':'
|
|
replacementLine = `${key}:${inlineComment}${serializedNewValue}`;
|
|
} else {
|
|
// For scalars, format as key: value
|
|
replacementLine = `${key}: ${serializedNewValue}${
|
|
inlineComment ? ` ${inlineComment}` : ""
|
|
}`;
|
|
}
|
|
|
|
if (keyLineIndex !== -1) {
|
|
// Replace the existing line while preserving comments
|
|
const beforeKey = lines.slice(
|
|
0,
|
|
keyLineIndex - commentBlock.split("\n").filter(Boolean).length,
|
|
);
|
|
const afterKey = lines.slice(
|
|
keyLineIndex + 1 + trailingComments.split("\n").filter(Boolean).length,
|
|
);
|
|
|
|
// Build the new content
|
|
const newContent = [
|
|
...beforeKey,
|
|
...commentBlock.split("\n").filter(Boolean),
|
|
replacementLine,
|
|
...trailingComments.split("\n").filter(Boolean),
|
|
...afterKey,
|
|
];
|
|
|
|
// Join lines and ensure proper newlines
|
|
currentYaml = `${newContent.join("\n").replace(/\n*$/, "\n")}\n`;
|
|
} else {
|
|
// Key not found: Add the new key-value pair to the end
|
|
const newLineBlock = replacementLine;
|
|
if (currentYaml.trim() === "") {
|
|
currentYaml = `${newLineBlock}\n`;
|
|
} else {
|
|
currentYaml = `${currentYaml.replace(/\n*$/, "\n") + newLineBlock}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure the result ends with a newline
|
|
return currentYaml.replace(/\n*$/, "\n");
|
|
}
|