Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 58 additions & 71 deletions packages/adf/src/patcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
*
* Immutable: returns a new document; the original is never mutated.
* Throws AdfPatchError with context on any invalid operation.
*
* Operation dispatch uses a keyed handler map instead of a switch so
* adding a new op type requires only one map entry and one handler.
*/

import type { AdfContent, AdfDocument, AdfSection, PatchOperation } from './types';
import type { AdfContent, AdfDocument, AdfMapEntry, AdfSection, PatchOperation } from './types';
import { AdfPatchError } from './errors';

// ============================================================================
// Dispatch
// ============================================================================

export function applyPatches(doc: AdfDocument, ops: PatchOperation[]): AdfDocument {
// Deep clone for immutability
let result: AdfDocument = JSON.parse(JSON.stringify(doc));
Expand All @@ -19,25 +26,25 @@ export function applyPatches(doc: AdfDocument, ops: PatchOperation[]): AdfDocume
return result;
}

const handlers: Record<PatchOperation['op'], (doc: AdfDocument, op: never) => AdfDocument> = {
ADD_BULLET: (doc, op: { section: string; value: string }) => addBullet(doc, op.section, op.value),
REPLACE_BULLET: (doc, op: { section: string; index: number; value: string }) => replaceBullet(doc, op.section, op.index, op.value),
REMOVE_BULLET: (doc, op: { section: string; index: number }) => removeBullet(doc, op.section, op.index),
ADD_SECTION: (doc, op: { key: string; decoration?: string | null; content: AdfContent; weight?: 'load-bearing' | 'advisory' }) => addSection(doc, op.key, op.decoration ?? null, op.content, op.weight),
REPLACE_SECTION: (doc, op: { key: string; content: AdfContent }) => replaceSection(doc, op.key, op.content),
REMOVE_SECTION: (doc, op: { key: string }) => removeSection(doc, op.key),
UPDATE_METRIC: (doc, op: { section: string; key: string; value: number }) => updateMetric(doc, op.section, op.key, op.value),
};

function applyOne(doc: AdfDocument, op: PatchOperation): AdfDocument {
switch (op.op) {
case 'ADD_BULLET':
return addBullet(doc, op.section, op.value);
case 'REPLACE_BULLET':
return replaceBullet(doc, op.section, op.index, op.value);
case 'REMOVE_BULLET':
return removeBullet(doc, op.section, op.index);
case 'ADD_SECTION':
return addSection(doc, op.key, op.decoration ?? null, op.content, op.weight);
case 'REPLACE_SECTION':
return replaceSection(doc, op.key, op.content);
case 'REMOVE_SECTION':
return removeSection(doc, op.key);
case 'UPDATE_METRIC':
return updateMetric(doc, op.section, op.key, op.value);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (handlers[op.op] as any)(doc, op);
}

// ============================================================================
// Shared Helpers
// ============================================================================

function findSection(doc: AdfDocument, key: string, opName: string): AdfSection {
const section = doc.sections.find(s => s.key === key);
if (!section) {
Expand All @@ -46,24 +53,40 @@ function findSection(doc: AdfDocument, key: string, opName: string): AdfSection
return section;
}

function checkBounds(length: number, index: number, opName: string, sectionKey: string, label: string): void {
if (index < 0 || index >= length) {
throw new AdfPatchError(
`Index ${index} out of bounds (section "${sectionKey}" has ${length} ${label})`,
opName,
sectionKey,
index
);
}
}

function parseColonEntry(value: string): AdfMapEntry {
const colonIndex = value.indexOf(':');
if (colonIndex > 0) {
return {
key: value.slice(0, colonIndex).trim(),
value: value.slice(colonIndex + 1).trim(),
};
}
return { key: value.trim(), value: '' };
}

// ============================================================================
// Operation Handlers
// ============================================================================

function addBullet(doc: AdfDocument, sectionKey: string, value: string): AdfDocument {
const section = findSection(doc, sectionKey, 'ADD_BULLET');

if (section.content.type === 'list') {
section.content.items.push(value);
} else if (section.content.type === 'map') {
// Parse "KEY: value" or treat as key with empty value
const colonIndex = value.indexOf(':');
if (colonIndex > 0) {
section.content.entries.push({
key: value.slice(0, colonIndex).trim(),
value: value.slice(colonIndex + 1).trim(),
});
} else {
section.content.entries.push({ key: value.trim(), value: '' });
}
section.content.entries.push(parseColonEntry(value));
} else if (section.content.type === 'text') {
// Convert text section to list, preserving existing prose as first item
const existing = section.content.value.trim();
const items = existing ? [existing, value] : [value];
(section as { content: AdfContent }).content = { type: 'list', items };
Expand All @@ -82,33 +105,11 @@ function replaceBullet(doc: AdfDocument, sectionKey: string, index: number, valu
const section = findSection(doc, sectionKey, 'REPLACE_BULLET');

if (section.content.type === 'list') {
if (index < 0 || index >= section.content.items.length) {
throw new AdfPatchError(
`Index ${index} out of bounds (section "${sectionKey}" has ${section.content.items.length} items)`,
'REPLACE_BULLET',
sectionKey,
index
);
}
checkBounds(section.content.items.length, index, 'REPLACE_BULLET', sectionKey, 'items');
section.content.items[index] = value;
} else if (section.content.type === 'map') {
if (index < 0 || index >= section.content.entries.length) {
throw new AdfPatchError(
`Index ${index} out of bounds (section "${sectionKey}" has ${section.content.entries.length} entries)`,
'REPLACE_BULLET',
sectionKey,
index
);
}
const colonIndex = value.indexOf(':');
if (colonIndex > 0) {
section.content.entries[index] = {
key: value.slice(0, colonIndex).trim(),
value: value.slice(colonIndex + 1).trim(),
};
} else {
section.content.entries[index] = { key: value.trim(), value: '' };
}
checkBounds(section.content.entries.length, index, 'REPLACE_BULLET', sectionKey, 'entries');
section.content.entries[index] = parseColonEntry(value);
} else {
throw new AdfPatchError(
`Cannot REPLACE_BULLET in ${section.content.type} section "${sectionKey}". Section must be list or map.`,
Expand All @@ -124,24 +125,10 @@ function removeBullet(doc: AdfDocument, sectionKey: string, index: number): AdfD
const section = findSection(doc, sectionKey, 'REMOVE_BULLET');

if (section.content.type === 'list') {
if (index < 0 || index >= section.content.items.length) {
throw new AdfPatchError(
`Index ${index} out of bounds (section "${sectionKey}" has ${section.content.items.length} items)`,
'REMOVE_BULLET',
sectionKey,
index
);
}
checkBounds(section.content.items.length, index, 'REMOVE_BULLET', sectionKey, 'items');
section.content.items.splice(index, 1);
} else if (section.content.type === 'map') {
if (index < 0 || index >= section.content.entries.length) {
throw new AdfPatchError(
`Index ${index} out of bounds (section "${sectionKey}" has ${section.content.entries.length} entries)`,
'REMOVE_BULLET',
sectionKey,
index
);
}
checkBounds(section.content.entries.length, index, 'REMOVE_BULLET', sectionKey, 'entries');
section.content.entries.splice(index, 1);
} else {
throw new AdfPatchError(
Expand All @@ -158,7 +145,7 @@ function addSection(
doc: AdfDocument,
key: string,
decoration: string | null,
content: import('./types').AdfContent,
content: AdfContent,
weight?: 'load-bearing' | 'advisory'
): AdfDocument {
const existing = doc.sections.find(s => s.key === key);
Expand All @@ -177,7 +164,7 @@ function addSection(
function replaceSection(
doc: AdfDocument,
key: string,
content: import('./types').AdfContent
content: AdfContent
): AdfDocument {
const section = findSection(doc, key, 'REPLACE_SECTION');
section.content = content;
Expand Down