From a0807b052fbfd9eb4ae24e346754277f3bc729cf Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Sun, 30 Mar 2025 13:03:32 +0200 Subject: [PATCH] Add YAML patching tests and implement serialization logic - Introduced comprehensive tests for applying minimal set key patches in YAML, covering various scenarios including scalar operations, adding new keys, list operations, and handling of empty values. - Implemented serialization functions to convert JavaScript types to YAML format, ensuring proper handling of scalars, arrays, and objects. - Enhanced the `applyMinimalSetKeyPatches` function to support patching of top-level keys while preserving comments and formatting. --- lib/yaml.test.ts | 170 +++++++++++++++++++++++++++++++++++++++++++++ lib/yaml.ts | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 lib/yaml.test.ts create mode 100644 lib/yaml.ts diff --git a/lib/yaml.test.ts b/lib/yaml.test.ts new file mode 100644 index 00000000..2a4b26ff --- /dev/null +++ b/lib/yaml.test.ts @@ -0,0 +1,170 @@ +import { assertEquals, assertExists } from "@std/assert"; +import { applyMinimalSetKeyPatches, SetKeyPatch } from "./yaml.ts"; + +Deno.test("YAML patching - basic scalar operations", () => { + const initialYaml = ` +name: my-package +version: 1.0.0 +description: A sample package +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "version", value: "1.1.0" }, + { op: "set-key", path: "description", value: "Updated description" }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes("version: 1.1.0"), true); + assertEquals(result.includes("description: Updated description"), true); + assertEquals(result.includes("name: my-package"), true); +}); + +Deno.test("YAML patching - adding new keys", () => { + const initialYaml = ` +name: my-package +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "version", value: "1.0.0" }, + { op: "set-key", path: "private", value: true }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes("version: 1.0.0"), true); + assertEquals(result.includes("private: true"), true); +}); + +Deno.test("YAML patching - list operations", () => { + const initialYaml = ` +name: my-package +tags: + - node + - typescript +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "tags", value: ["deno", "typescript", "yaml"] }, + { op: "set-key", path: "dependencies", value: ["std", "testing"] }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes("tags:"), true); + assertEquals(result.includes("- deno"), true); + assertEquals(result.includes("- typescript"), true); + assertEquals(result.includes("- yaml"), true); + assertEquals(result.includes("dependencies:"), true); + assertEquals(result.includes("- std"), true); + assertEquals(result.includes("- testing"), true); +}); + +Deno.test("YAML patching - empty values and special characters", () => { + const initialYaml = ` +name: my-package +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "empty", value: "" }, + { op: "set-key", path: "special", value: "value:with:colons" }, + { op: "set-key", path: "null", value: null }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes('empty: ""'), true); + assertEquals(result.includes('special: "value:with:colons"'), true); + assertEquals(result.includes("null: null"), true); +}); + +Deno.test("YAML patching - empty input", () => { + const initialYaml = ""; + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "name", value: "new-package" }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.trim(), "name: new-package"); +}); + +Deno.test("YAML patching - nested object values", () => { + const initialYaml = ` +name: my-package +`; + + const patches: SetKeyPatch[] = [ + { + op: "set-key", + path: "config", + value: { + port: 3000, + host: "localhost", + }, + }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes("config:"), true); + assertEquals(result.includes(" port: 3000"), true); + assertEquals(result.includes(" host: localhost"), true); +}); + +Deno.test("YAML patching - empty lists and objects", () => { + const initialYaml = ` +name: my-package +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "emptyList", value: [] }, + { op: "set-key", path: "emptyObject", value: {} }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + assertExists(result); + assertEquals(result.includes("emptyList: []"), true); + assertEquals(result.includes("emptyObject: {}"), true); +}); + +Deno.test("YAML patching - preserves comments", () => { + const initialYaml = ` +# Main package configuration +name: my-package # The package name +version: 1.0.0 # Current version +description: A sample package + +# Additional metadata +author: John Doe # Package maintainer +license: MIT # License type +`; + + const patches: SetKeyPatch[] = [ + { op: "set-key", path: "version", value: "2.0.0" }, + { op: "set-key", path: "description", value: "An updated package" }, + { op: "set-key", path: "newProp", value: "new value" }, + ]; + + const result = applyMinimalSetKeyPatches(initialYaml, patches); + + // Verify all comments are preserved + assertEquals(result.includes("# Main package configuration"), true); + assertEquals(result.includes("# The package name"), true); + assertEquals(result.includes("# Current version"), true); + assertEquals(result.includes("# Additional metadata"), true); + assertEquals(result.includes("# Package maintainer"), true); + assertEquals(result.includes("# License type"), true); + + // Verify the content is also correct + assertEquals(result.includes("version: 2.0.0"), true); + assertEquals(result.includes("description: An updated package"), true); + assertEquals(result.includes("newProp: new value"), true); +}); diff --git a/lib/yaml.ts b/lib/yaml.ts new file mode 100644 index 00000000..2aece25e --- /dev/null +++ b/lib/yaml.ts @@ -0,0 +1,174 @@ +// Define the patch structure +export interface SetKeyPatch { + op: "set-key"; + path: string; // Still assuming simple, top-level key names + value: any; // Allow any value, including arrays +} + +// 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 applyMinimalSetKeyPatches( + yamlString: string, + patches: SetKeyPatch[], +): string { + let currentYaml = yamlString; + + for (const patch of patches) { + if (patch.op !== "set-key") continue; + + // Still simplifying: Only handle top-level keys + if (patch.path.includes(".") || patch.path.includes("[")) { + console.warn( + `Skipping patch for nested or invalid key path (not supported): ${patch.path}`, + ); + continue; + } + + const key = patch.path; + + // Regex to find the key at the beginning of a line, optionally indented, + // capturing the value and any trailing comment separately + const regex = new RegExp( + `^(\\s*)(${key}):\\s*([^#\\n]*?)\\s*(#.*)?\\s*$`, + "m", + ); + const match = currentYaml.match(regex); + + if (match) { + // Key found: Replace the existing line/block + const [fullMatch, indentation, keyMatch, oldValue, inlineComment = ""] = + match; + const originalLine = fullMatch; + + // Serialize the new value. Pass the key's indentation for list/object formatting. + const serializedNewValue = serializeToYamlValue(patch.value, indentation); + + let replacementString: string; + // Check if the new value requires multi-line formatting (list/object starting with newline) + if (serializedNewValue.startsWith("\n")) { + // For lists/objects, the key line ends with just ':' plus any comment + replacementString = + `${indentation}${key}:${inlineComment}${serializedNewValue}`; + } else { + // For scalars, format as key: value plus any comment + // Ensure proper spacing around the inline comment + replacementString = `${indentation}${key}: ${serializedNewValue}${ + inlineComment ? " " + inlineComment : "" + }`; + } + + // Find any standalone comments before this key and preserve them + const lines = currentYaml.split("\n"); + const keyLineIndex = lines.findIndex((line) => + line.trim() === originalLine.trim() + ); + let commentBlock = ""; + + // Look backwards from the key line to collect any standalone comments + for (let i = keyLineIndex - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.startsWith("#")) { + commentBlock = lines[i] + "\n" + commentBlock; + } else if (line !== "") { + break; + } + } + + // Replace the original line while preserving comments + if (commentBlock) { + currentYaml = currentYaml.replace( + commentBlock + originalLine, + commentBlock + replacementString, + ); + } else { + currentYaml = currentYaml.replace(originalLine, replacementString); + } + } else { + // Key not found: Add the new key-value pair to the end + const indentation = ""; // No indentation for new top-level keys + const serializedNewValue = serializeToYamlValue(patch.value, indentation); + + let newLineBlock: string; + // Check if the new value requires multi-line formatting + if (serializedNewValue.startsWith("\n")) { + newLineBlock = `${key}:${serializedNewValue}`; // Key, colon, then newline and indented items/properties + } else { + newLineBlock = `${key}: ${serializedNewValue}`; // Key, colon, space, scalar value + } + + // Append the new line/block, ensuring proper spacing + if (currentYaml.trim() === "") { + currentYaml = newLineBlock; // If yaml was empty, just set it + } else { + // Add a newline before appending if needed + currentYaml = currentYaml.replace(/\n*$/, "\n"); // Ensure single trailing newline + currentYaml += newLineBlock + "\n"; // Append and add trailing newline + } + } + } + + return currentYaml; +}