170 lines
6.1 KiB
TypeScript
170 lines
6.1 KiB
TypeScript
/**
|
|
* YAML source-preserving patch helper.
|
|
*
|
|
* Problem: `yaml.stringify(obj)` completely re-serializes a document, destroying
|
|
* the source formatting (block literal vs folded, inline vs block arrays, blank
|
|
* lines, comments, key order). This changes `instruction: |` to `instruction: >`,
|
|
* which actually alters runtime behavior because folded style collapses newlines.
|
|
*
|
|
* Solution: parseDocument(originalText) -> deep-diff doc.toJS() vs newBody ->
|
|
* apply only differing paths via Document#setIn / Document#deleteIn. Untouched
|
|
* regions preserve their original source exactly.
|
|
*
|
|
* Newly-added subtrees go through Document#createNode with the existing
|
|
* lineWidth: 120 convention.
|
|
*/
|
|
|
|
import { parseDocument, stringify, type Document } from 'yaml';
|
|
import { logger } from '../logger.js';
|
|
|
|
const LINE_WIDTH = 120;
|
|
|
|
type Path = (string | number)[];
|
|
|
|
/**
|
|
* Recursively walk `prev` and `next` (both plain JS values), collecting paths
|
|
* where the two differ. For differing paths we emit either a set (with the new
|
|
* value) or a delete.
|
|
*
|
|
* Rules:
|
|
* - For objects (plain dicts) we compare by key. Keys removed in `next` become
|
|
* deletes; keys added in `next` become sets; common keys recurse.
|
|
* - For arrays of equal length we recurse element-by-element by index. This
|
|
* lets us do minimal edits inside one movement without re-serializing the
|
|
* entire `movements:` sequence (which would flatten its inline arrays and
|
|
* block-literal instructions).
|
|
* - For arrays of differing length we replace the whole array at that path.
|
|
* Element-wise alignment across inserts/deletes is ambiguous without a
|
|
* stable identity field, so we bail to wholesale replacement and accept
|
|
* the one-time formatting loss for the mutated sequence.
|
|
* - For primitives (string/number/bool/null) we compare via strict equality.
|
|
*/
|
|
export type DiffOp =
|
|
| { kind: 'set'; path: Path; value: unknown }
|
|
| { kind: 'delete'; path: Path };
|
|
|
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
if (a === b) return true;
|
|
if (typeof a !== typeof b) return false;
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
if (a.length !== b.length) return false;
|
|
for (let i = 0; i < a.length; i++) {
|
|
if (!deepEqual(a[i], b[i])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
if (isPlainObject(a) && isPlainObject(b)) {
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
if (keysA.length !== keysB.length) return false;
|
|
for (const k of keysA) {
|
|
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
|
if (!deepEqual(a[k], b[k])) return false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function diff(prev: unknown, next: unknown, path: Path = []): DiffOp[] {
|
|
if (deepEqual(prev, next)) return [];
|
|
|
|
// If the shapes differ (object<->array<->scalar) replace wholesale.
|
|
const prevIsObj = isPlainObject(prev);
|
|
const nextIsObj = isPlainObject(next);
|
|
const prevIsArr = Array.isArray(prev);
|
|
const nextIsArr = Array.isArray(next);
|
|
|
|
if (prevIsObj && nextIsObj) {
|
|
const ops: DiffOp[] = [];
|
|
const prevKeys = new Set(Object.keys(prev as Record<string, unknown>));
|
|
const nextKeys = new Set(Object.keys(next as Record<string, unknown>));
|
|
// deletions
|
|
for (const k of prevKeys) {
|
|
if (!nextKeys.has(k)) {
|
|
ops.push({ kind: 'delete', path: [...path, k] });
|
|
}
|
|
}
|
|
// additions + recursive diffs
|
|
for (const k of nextKeys) {
|
|
const nextVal = (next as Record<string, unknown>)[k];
|
|
if (!prevKeys.has(k)) {
|
|
ops.push({ kind: 'set', path: [...path, k], value: nextVal });
|
|
} else {
|
|
const prevVal = (prev as Record<string, unknown>)[k];
|
|
ops.push(...diff(prevVal, nextVal, [...path, k]));
|
|
}
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
if (prevIsArr && nextIsArr) {
|
|
const prevArr = prev as unknown[];
|
|
const nextArr = next as unknown[];
|
|
if (prevArr.length !== nextArr.length) {
|
|
// Length change: bail to wholesale replacement. We can't reliably align
|
|
// items across insertions/deletions without a stable identity field.
|
|
return [{ kind: 'set', path, value: next }];
|
|
}
|
|
// Equal length: recurse element-wise so untouched items keep formatting.
|
|
const ops: DiffOp[] = [];
|
|
for (let i = 0; i < prevArr.length; i++) {
|
|
ops.push(...diff(prevArr[i], nextArr[i], [...path, i]));
|
|
}
|
|
return ops;
|
|
}
|
|
|
|
// Shape change or primitive mismatch: replace.
|
|
return [{ kind: 'set', path, value: next }];
|
|
}
|
|
|
|
/**
|
|
* Apply a list of diff ops to a Document in place. New subtrees are wrapped via
|
|
* doc.createNode so they follow our stringify options (lineWidth etc.).
|
|
*/
|
|
export function applyOps(doc: Document, ops: DiffOp[]): void {
|
|
for (const op of ops) {
|
|
if (op.kind === 'delete') {
|
|
doc.deleteIn(op.path);
|
|
} else {
|
|
// createNode respects schema + options; we don't pass extra options
|
|
// because per-field block style for untouched content comes from the
|
|
// original Document and we only call createNode for NEW subtrees.
|
|
const node = doc.createNode(op.value);
|
|
doc.setIn(op.path, node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-serialize `body` onto the formatting of `originalText`, preserving block
|
|
* styles / inline arrays / blank lines / comments for untouched regions.
|
|
*
|
|
* If the original text fails to parse cleanly (errors array non-empty) we fall
|
|
* back to a plain stringify and log a warning.
|
|
*/
|
|
export function patchYaml(originalText: string, body: unknown): string {
|
|
let doc: Document;
|
|
try {
|
|
doc = parseDocument(originalText);
|
|
} catch (e) {
|
|
logger.warn(`[yaml-patch] parseDocument threw, falling back to stringify err=${e}`);
|
|
return stringify(body, { lineWidth: LINE_WIDTH });
|
|
}
|
|
if (doc.errors && doc.errors.length > 0) {
|
|
logger.warn(
|
|
`[yaml-patch] original document has parse errors, falling back to stringify count=${doc.errors.length}`,
|
|
);
|
|
return stringify(body, { lineWidth: LINE_WIDTH });
|
|
}
|
|
|
|
const prev = doc.toJS();
|
|
const ops = diff(prev, body);
|
|
applyOps(doc, ops);
|
|
return doc.toString({ lineWidth: LINE_WIDTH });
|
|
}
|