@adecore/merge 0.0.1 → 0.17.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,110 @@
1
+ # Functional Source License, Version 1.1, MIT Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-MIT
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 - present Bas Milius
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the MIT license that is effective on the second anniversary of the date we make
91
+ the Software available. On or after that date, you may use the Software under
92
+ the MIT license, in which case the following will apply:
93
+
94
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
95
+ this software and associated documentation files (the "Software"), to deal in
96
+ the Software without restriction, including without limitation the rights to
97
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
98
+ of the Software, and to permit persons to whom the Software is furnished to do
99
+ so, subject to the following conditions:
100
+
101
+ The above copyright notice and this permission notice shall be included in all
102
+ copies or substantial portions of the Software.
103
+
104
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
105
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
106
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
107
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
108
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
109
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
110
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @adecore/merge
2
+
3
+ Pure line diffs, three-way merge blocks and conflict resolution. It uses no filesystem, Git process, editor or browser API.
4
+
5
+ ```sh
6
+ bun add @adecore/merge
7
+ ```
8
+
9
+ Licensed under FSL-1.1-MIT, see [LICENSE](./LICENSE).
10
+
11
+ ```ts
12
+ import { draftOf, joinLines, shapeOf, splitBlocks, splitLines } from '@adecore/merge';
13
+
14
+ const base = 'first\r\nold\r\n';
15
+ const ours = 'first\r\nours\r\n';
16
+ const theirs = 'first\r\ntheirs\r\n';
17
+ const blocks = splitBlocks(splitLines(base), splitLines(ours), splitLines(theirs));
18
+ const answers = new Map<number, readonly string[]>();
19
+ for (const [index, block] of blocks.entries()) {
20
+ if (block.kind === 'conflict') {
21
+ answers.set(index, ['combined']);
22
+ }
23
+ }
24
+ const text = joinLines(draftOf(blocks, answers).lines, shapeOf(ours));
25
+ ```
26
+
27
+ The fixed answer above represents a caller's completed decision. Unanswered draft conflicts use ours without markers; saving requires a separate unresolved check. Preserve text shape and verify input versions before writing. The host owns authorization, file access and stale-answer rejection.
28
+
29
+ Read the [overview](https://adecore.dev/merge/), [lines and diffs](https://adecore.dev/merge/lines) and [three-way merge](https://adecore.dev/merge/three-way).
30
+
31
+ From the repository root, run `bun run --cwd packages/merge test`, `typecheck` or `build`. The isolated `test:memory` command checks bounded diff memory workloads. A source-relative example is in [examples/resolve-conflict.ts](./examples/resolve-conflict.ts).
@@ -0,0 +1,9 @@
1
+ export type MergeBlockKind = 'stable' | 'ours' | 'theirs' | 'both' | 'conflict';
2
+ export interface MergeBlock {
3
+ kind: MergeBlockKind;
4
+ base: string[];
5
+ ours: string[];
6
+ theirs: string[];
7
+ }
8
+ export declare function splitBlocks(base: readonly string[], ours: readonly string[], theirs: readonly string[]): MergeBlock[];
9
+ export declare function openBlocks(blocks: readonly MergeBlock[]): number;
package/dist/blocks.js ADDED
@@ -0,0 +1,82 @@
1
+ import { diffLines } from './diff.js';
2
+ function same(left, right) {
3
+ return left.length === right.length && left.every((line, index) => line === right[index]);
4
+ }
5
+ function kindOf(base, ours, theirs) {
6
+ if (same(ours, theirs)) {
7
+ return same(base, ours) ? 'stable' : 'both';
8
+ }
9
+ if (same(base, ours)) {
10
+ return 'theirs';
11
+ }
12
+ if (same(base, theirs)) {
13
+ return 'ours';
14
+ }
15
+ return 'conflict';
16
+ }
17
+ /* How many lines a change adds over what it replaces, which is how far it moves everything after it. */
18
+ function delta(change) {
19
+ return change.otherEnd - change.otherStart - (change.baseEnd - change.baseStart);
20
+ }
21
+ /*
22
+ * The three versions of a file as the stretches they agree and disagree on, in reading order. A
23
+ * stretch is taken up by both sides at once only where their changes overlap in the base, or where
24
+ * both added something in exactly the same place; anything else is one side's alone and merges
25
+ * without asking. This is what every other function here counts, draws and resolves.
26
+ */
27
+ export function splitBlocks(base, ours, theirs) {
28
+ const ourChanges = diffLines(base, ours);
29
+ const theirChanges = diffLines(base, theirs);
30
+ const blocks = [];
31
+ let baseAt = 0;
32
+ let ourAt = 0;
33
+ let theirAt = 0;
34
+ let ourNext = 0;
35
+ let theirNext = 0;
36
+ const push = (baseEnd, ourEnd, theirEnd) => {
37
+ const stretch = { base: base.slice(baseAt, baseEnd), ours: ours.slice(ourAt, ourEnd), theirs: theirs.slice(theirAt, theirEnd) };
38
+ if (stretch.base.length > 0 || stretch.ours.length > 0 || stretch.theirs.length > 0) {
39
+ blocks.push({ kind: kindOf(stretch.base, stretch.ours, stretch.theirs), ...stretch });
40
+ }
41
+ baseAt = baseEnd;
42
+ ourAt = ourEnd;
43
+ theirAt = theirEnd;
44
+ };
45
+ while (ourNext < ourChanges.length || theirNext < theirChanges.length) {
46
+ const start = Math.min(ourChanges[ourNext]?.baseStart ?? Infinity, theirChanges[theirNext]?.baseStart ?? Infinity);
47
+ if (start > baseAt) {
48
+ const length = start - baseAt;
49
+ push(start, ourAt + length, theirAt + length);
50
+ }
51
+ let end = baseAt;
52
+ let ourGrowth = 0;
53
+ let theirGrowth = 0;
54
+ /* The stretch grows while a change of either side reaches into it: two changes that overlap
55
+ in the base are one thing a person has to look at, however far apart they started. */
56
+ for (let taken = true; taken;) {
57
+ taken = false;
58
+ const reaches = (change) => change !== undefined && change.baseStart <= end && (change.baseStart < end || end === baseAt);
59
+ while (reaches(ourChanges[ourNext])) {
60
+ const change = ourChanges[ourNext];
61
+ end = Math.max(end, change.baseEnd);
62
+ ourGrowth += delta(change);
63
+ ourNext += 1;
64
+ taken = true;
65
+ }
66
+ while (reaches(theirChanges[theirNext])) {
67
+ const change = theirChanges[theirNext];
68
+ end = Math.max(end, change.baseEnd);
69
+ theirGrowth += delta(change);
70
+ theirNext += 1;
71
+ taken = true;
72
+ }
73
+ }
74
+ push(end, ourAt + (end - baseAt) + ourGrowth, theirAt + (end - baseAt) + theirGrowth);
75
+ }
76
+ push(base.length, ours.length, theirs.length);
77
+ return blocks;
78
+ }
79
+ /* The stretches a person still has to decide on. */
80
+ export function openBlocks(blocks) {
81
+ return blocks.filter((block) => block.kind === 'conflict').length;
82
+ }
package/dist/diff.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface Change {
2
+ baseStart: number;
3
+ baseEnd: number;
4
+ otherStart: number;
5
+ otherEnd: number;
6
+ }
7
+ export declare function diffLines(base: readonly string[], other: readonly string[]): Change[];
package/dist/diff.js ADDED
@@ -0,0 +1,129 @@
1
+ /*
2
+ * How far apart two files may be before the exact walk costs more than the answer is worth. Past
3
+ * this the two are answered as one stretch that differs whole, which reads as a single conflict
4
+ * instead of a list nobody would work through anyway.
5
+ */
6
+ const MAX_DISTANCE = 4000;
7
+ /* The walk keeps every STRIDE-th state and walks the rounds between two again on the way back: a
8
+ state per round came to over a hundred megabytes at MAX_DISTANCE. */
9
+ const STRIDE = 64;
10
+ /* One round of the walk: every diagonal of this distance taken as far as its lines match. True once the far corner is reached. */
11
+ function extend(left, right, reach, offset, distance) {
12
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
13
+ const down = diagonal === -distance || (diagonal !== distance && reach[offset + diagonal - 1] < reach[offset + diagonal + 1]);
14
+ let x = down ? reach[offset + diagonal + 1] : reach[offset + diagonal - 1] + 1;
15
+ let y = x - diagonal;
16
+ while (x < left.length && y < right.length && left[x] === right[y]) {
17
+ x += 1;
18
+ y += 1;
19
+ }
20
+ reach[offset + diagonal] = x;
21
+ if (x >= left.length && y >= right.length) {
22
+ return true;
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+ /* The path back from the far corner, one edit per step, in the order the file reads. */
28
+ function backtrack(left, right, saved, distance, offset) {
29
+ const moves = [];
30
+ let x = left.length;
31
+ let y = right.length;
32
+ const states = Array.from({ length: Math.min(distance, STRIDE) + 1 }, () => new Int32Array(2 * offset + 1));
33
+ for (let step = distance; step > 0;) {
34
+ // The states the rounds from the saved one below this step started in, walked again.
35
+ const first = Math.floor((step - 1) / STRIDE) * STRIDE;
36
+ states[0].set(saved[first / STRIDE]);
37
+ for (let round = first; round < step; round += 1) {
38
+ const next = states[round - first + 1];
39
+ next.set(states[round - first]);
40
+ extend(left, right, next, offset, round);
41
+ }
42
+ for (; step > first; step -= 1) {
43
+ const state = states[step - first];
44
+ const diagonal = x - y;
45
+ const down = diagonal === -step || (diagonal !== step && state[offset + diagonal - 1] < state[offset + diagonal + 1]);
46
+ const previous = down ? diagonal + 1 : diagonal - 1;
47
+ const prevX = state[offset + previous];
48
+ const prevY = prevX - previous;
49
+ moves.push({ prevX, prevY, x, y });
50
+ x = prevX;
51
+ y = prevY;
52
+ }
53
+ }
54
+ return moves.reverse();
55
+ }
56
+ /*
57
+ * Myers' greedy walk: for every edit distance it keeps the furthest reach on each diagonal, and every
58
+ * STRIDE-th state a round started in, which is what the path is read back from once the far corner
59
+ * is reached. Null once the distance passes what is worth walking.
60
+ */
61
+ function walk(left, right) {
62
+ const limit = Math.min(left.length + right.length, MAX_DISTANCE);
63
+ const offset = limit;
64
+ const reach = new Int32Array(2 * limit + 1);
65
+ const saved = [];
66
+ for (let distance = 0; distance <= limit; distance += 1) {
67
+ if (distance % STRIDE === 0) {
68
+ saved.push(reach.slice());
69
+ }
70
+ if (extend(left, right, reach, offset, distance)) {
71
+ return backtrack(left, right, saved, distance, offset);
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ /* The stretches the moves cover, with edits that follow each other straight away folded into one. */
77
+ function changesOf(moves) {
78
+ const changes = [];
79
+ let current = null;
80
+ for (const move of moves) {
81
+ // A delete moves along the base side and so raises the diagonal; an insert lowers it.
82
+ const deleted = move.x - move.y > move.prevX - move.prevY;
83
+ const editX = deleted ? move.prevX + 1 : move.prevX;
84
+ const editY = deleted ? move.prevY : move.prevY + 1;
85
+ if (current !== null && current.baseEnd === move.prevX && current.otherEnd === move.prevY) {
86
+ current.baseEnd = editX;
87
+ current.otherEnd = editY;
88
+ }
89
+ else {
90
+ if (current !== null) {
91
+ changes.push(current);
92
+ }
93
+ current = { baseStart: move.prevX, baseEnd: editX, otherStart: move.prevY, otherEnd: editY };
94
+ }
95
+ // What follows the edit is a run of equal lines, which ends the stretch.
96
+ if (move.x !== editX || move.y !== editY) {
97
+ changes.push(current);
98
+ current = null;
99
+ }
100
+ }
101
+ if (current !== null) {
102
+ changes.push(current);
103
+ }
104
+ return changes;
105
+ }
106
+ /* Where `other` differs from `base`, in line stretches, in the order they appear. */
107
+ export function diffLines(base, other) {
108
+ let prefix = 0;
109
+ while (prefix < base.length && prefix < other.length && base[prefix] === other[prefix]) {
110
+ prefix += 1;
111
+ }
112
+ let suffix = 0;
113
+ while (suffix < base.length - prefix && suffix < other.length - prefix && base[base.length - 1 - suffix] === other[other.length - 1 - suffix]) {
114
+ suffix += 1;
115
+ }
116
+ const left = base.slice(prefix, base.length - suffix);
117
+ const right = other.slice(prefix, other.length - suffix);
118
+ if (left.length === 0 && right.length === 0) {
119
+ return [];
120
+ }
121
+ const moves = left.length === 0 || right.length === 0 ? null : walk(left, right);
122
+ const changes = moves === null ? [{ baseStart: 0, baseEnd: left.length, otherStart: 0, otherEnd: right.length }] : changesOf(moves);
123
+ return changes.map((change) => ({
124
+ baseStart: change.baseStart + prefix,
125
+ baseEnd: change.baseEnd + prefix,
126
+ otherStart: change.otherStart + prefix,
127
+ otherEnd: change.otherEnd + prefix
128
+ }));
129
+ }
@@ -0,0 +1,4 @@
1
+ export { diffLines, type Change } from './diff.ts';
2
+ export { openBlocks, splitBlocks, type MergeBlock, type MergeBlockKind } from './blocks.ts';
3
+ export { autoLines, bothLines, draftOf, fingerprint, sideLines, wandLines, type MergeDraft, type MergeSide, type MergeSpan } from './resolve.ts';
4
+ export { joinLines, shapeOf, splitLines, type TextShape } from './text.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { diffLines } from './diff.js';
2
+ export { openBlocks, splitBlocks } from './blocks.js';
3
+ export { autoLines, bothLines, draftOf, fingerprint, sideLines, wandLines } from './resolve.js';
4
+ export { joinLines, shapeOf, splitLines } from './text.js';
@@ -0,0 +1,18 @@
1
+ import type { MergeBlock } from './blocks.ts';
2
+ export type MergeSide = 'ours' | 'theirs';
3
+ export declare function sideLines(block: MergeBlock, side: MergeSide): string[];
4
+ export declare function bothLines(block: MergeBlock, first: MergeSide): string[];
5
+ export declare function autoLines(block: MergeBlock): string[] | null;
6
+ export declare function wandLines(block: MergeBlock): string[] | null;
7
+ export declare function fingerprint(block: MergeBlock): string;
8
+ export interface MergeSpan {
9
+ block: number;
10
+ kind: MergeBlock['kind'];
11
+ from: number;
12
+ to: number;
13
+ }
14
+ export interface MergeDraft {
15
+ lines: string[];
16
+ spans: MergeSpan[];
17
+ }
18
+ export declare function draftOf(blocks: readonly MergeBlock[], answered?: ReadonlyMap<number, readonly string[]>): MergeDraft;
@@ -0,0 +1,105 @@
1
+ export function sideLines(block, side) {
2
+ return side === 'ours' ? [...block.ours] : [...block.theirs];
3
+ }
4
+ /* Both sides, one after the other, for the conflict where the two additions both belong. */
5
+ export function bothLines(block, first) {
6
+ return first === 'ours' ? [...block.ours, ...block.theirs] : [...block.theirs, ...block.ours];
7
+ }
8
+ /* What a block is worth without anyone choosing: the side that changed, or the change both made. */
9
+ export function autoLines(block) {
10
+ switch (block.kind) {
11
+ case 'stable':
12
+ case 'ours':
13
+ case 'both':
14
+ return [...block.ours];
15
+ case 'theirs':
16
+ return [...block.theirs];
17
+ case 'conflict':
18
+ return null;
19
+ }
20
+ }
21
+ function squashed(lines) {
22
+ return lines
23
+ .map((line) => {
24
+ const body = line.trim();
25
+ // Indentation is meaning in Python, YAML or a Makefile, so only a blank line loses its own.
26
+ const indent = body === '' ? '' : line.slice(0, line.length - line.trimStart().length);
27
+ return `${indent}${body.replace(/\s+/g, ' ')}`;
28
+ })
29
+ .join('\n');
30
+ }
31
+ /* Where `inner` starts in `outer` as one run of lines, or -1. */
32
+ function runIn(outer, inner) {
33
+ for (let start = 0; start + inner.length <= outer.length; start += 1) {
34
+ if (inner.every((line, index) => outer[start + index] === line)) {
35
+ return start;
36
+ }
37
+ }
38
+ return -1;
39
+ }
40
+ /*
41
+ * Whether `outer` holds every line of `inner` and adds nothing `inner` took out: a base line among
42
+ * what `outer` adds is one `inner` deleted, and taking `outer` would put it back.
43
+ */
44
+ function covers(base, outer, inner) {
45
+ const start = runIn(outer, inner);
46
+ if (inner.length === 0 || start < 0) {
47
+ return false;
48
+ }
49
+ const added = [...outer.slice(0, start), ...outer.slice(start + inner.length)];
50
+ return added.every((line) => !base.includes(line) || inner.includes(line));
51
+ }
52
+ /*
53
+ * What the wand makes of a conflict, or null for one that needs a person after all. It closes the
54
+ * two nobody would think twice about: the sides that differ only in whitespace inside or after a
55
+ * line, and the side that already holds every line of the other without putting back what the other
56
+ * deleted. Anything else is a choice, and a wand that guesses at those is a wand nobody trusts twice.
57
+ */
58
+ export function wandLines(block) {
59
+ if (block.kind !== 'conflict') {
60
+ return autoLines(block);
61
+ }
62
+ if (squashed(block.ours) === squashed(block.theirs)) {
63
+ return [...block.ours];
64
+ }
65
+ if (covers(block.base, block.ours, block.theirs)) {
66
+ return [...block.ours];
67
+ }
68
+ if (covers(block.base, block.theirs, block.ours)) {
69
+ return [...block.theirs];
70
+ }
71
+ return null;
72
+ }
73
+ /*
74
+ * A short name for the two sides of one block, so an answer written for it can be checked against
75
+ * the block it lands on. Not a secret and not a digest anyone leans on: it only has to differ when
76
+ * the stretch does, and it has to read the same on a daemon and in a browser, which rules out the
77
+ * one hash a browser cannot take synchronously.
78
+ */
79
+ export function fingerprint(block) {
80
+ let hash = 0x811c9dc5;
81
+ for (const line of [...block.ours, '\u0000', ...block.theirs]) {
82
+ for (let index = 0; index < line.length; index += 1) {
83
+ hash = Math.imul(hash ^ line.charCodeAt(index), 0x01000193);
84
+ }
85
+ hash = Math.imul(hash ^ 0x0a, 0x01000193);
86
+ }
87
+ return (hash >>> 0).toString(16).padStart(8, '0');
88
+ }
89
+ /*
90
+ * The merged file to start from: everything that merges by itself is merged, and every conflict
91
+ * holds our side until someone says otherwise. Never a conflict marker: what a person edits here is
92
+ * a file that would compile, and the other side is a column away rather than a line below. Blocks
93
+ * that have already been answered are passed in, which is how a proposal lands in a file nobody has
94
+ * open.
95
+ */
96
+ export function draftOf(blocks, answered = new Map()) {
97
+ const lines = [];
98
+ const spans = [];
99
+ for (const [index, block] of blocks.entries()) {
100
+ const from = lines.length;
101
+ lines.push(...(answered.get(index) ?? autoLines(block) ?? block.ours));
102
+ spans.push({ block: index, kind: block.kind, from, to: lines.length });
103
+ }
104
+ return { lines, spans };
105
+ }
package/dist/text.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export interface TextShape {
2
+ eol: '\n' | '\r\n';
3
+ finalNewline: boolean;
4
+ }
5
+ export declare function shapeOf(text: string): TextShape;
6
+ export declare function splitLines(text: string): string[];
7
+ export declare function joinLines(lines: readonly string[], shape: TextShape): string;
package/dist/text.js ADDED
@@ -0,0 +1,22 @@
1
+ /* The line ending a file uses, by the first one in it, and whether it ends on one. */
2
+ export function shapeOf(text) {
3
+ const line = text.indexOf('\n');
4
+ return {
5
+ eol: line > 0 && text[line - 1] === '\r' ? '\r\n' : '\n',
6
+ finalNewline: text === '' || text.endsWith('\n')
7
+ };
8
+ }
9
+ /* The lines of a file, without the empty one a trailing newline would leave behind. */
10
+ export function splitLines(text) {
11
+ if (text === '') {
12
+ return [];
13
+ }
14
+ const lines = text.split(/\r\n|\n/);
15
+ if (lines[lines.length - 1] === '') {
16
+ lines.pop();
17
+ }
18
+ return lines;
19
+ }
20
+ export function joinLines(lines, shape) {
21
+ return lines.length === 0 ? '' : lines.join(shape.eol) + (shape.finalNewline ? shape.eol : '');
22
+ }
package/package.json CHANGED
@@ -1,11 +1,42 @@
1
1
  {
2
2
  "name": "@adecore/merge",
3
- "version": "0.0.1",
4
- "description": "Placeholder for the first release.",
3
+ "version": "0.17.0-beta.2",
4
+ "description": "Pure line diffs, three-way merge blocks and conflict resolution.",
5
5
  "license": "FSL-1.1-MIT",
6
+ "author": "Bas Milius",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/basmilius/adecore.git",
9
10
  "directory": "packages/merge"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/basmilius/adecore/issues"
14
+ },
15
+ "homepage": "https://adecore.dev/merge/",
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/fixtures"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "source": "./src/index.ts",
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "bun scripts/build.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "bun test --conditions=source",
35
+ "test:memory": "bun --config=scripts/memory.bunfig.toml test src/diff-memory.integration.test.ts"
36
+ },
37
+ "devDependencies": {
38
+ "@types/bun": "^1.4.2",
39
+ "@types/node": "^24.13.6",
40
+ "typescript": "^7.0.2"
10
41
  }
11
42
  }
package/src/blocks.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { diffLines, type Change } from './diff.ts';
2
+
3
+ /*
4
+ * What happened to one stretch of a file between the version the two sides started from and what
5
+ * each of them made of it. Only `conflict` needs a person: the rest is one side's change, or the
6
+ * same change made twice.
7
+ */
8
+ export type MergeBlockKind = 'stable' | 'ours' | 'theirs' | 'both' | 'conflict';
9
+
10
+ export interface MergeBlock {
11
+ kind: MergeBlockKind;
12
+ base: string[];
13
+ ours: string[];
14
+ theirs: string[];
15
+ }
16
+
17
+ function same(left: readonly string[], right: readonly string[]): boolean {
18
+ return left.length === right.length && left.every((line, index) => line === right[index]);
19
+ }
20
+
21
+ function kindOf(base: readonly string[], ours: readonly string[], theirs: readonly string[]): MergeBlockKind {
22
+ if (same(ours, theirs)) {
23
+ return same(base, ours) ? 'stable' : 'both';
24
+ }
25
+ if (same(base, ours)) {
26
+ return 'theirs';
27
+ }
28
+ if (same(base, theirs)) {
29
+ return 'ours';
30
+ }
31
+ return 'conflict';
32
+ }
33
+
34
+ /* How many lines a change adds over what it replaces, which is how far it moves everything after it. */
35
+ function delta(change: Change): number {
36
+ return change.otherEnd - change.otherStart - (change.baseEnd - change.baseStart);
37
+ }
38
+
39
+ /*
40
+ * The three versions of a file as the stretches they agree and disagree on, in reading order. A
41
+ * stretch is taken up by both sides at once only where their changes overlap in the base, or where
42
+ * both added something in exactly the same place; anything else is one side's alone and merges
43
+ * without asking. This is what every other function here counts, draws and resolves.
44
+ */
45
+ export function splitBlocks(base: readonly string[], ours: readonly string[], theirs: readonly string[]): MergeBlock[] {
46
+ const ourChanges = diffLines(base, ours);
47
+ const theirChanges = diffLines(base, theirs);
48
+ const blocks: MergeBlock[] = [];
49
+ let baseAt = 0;
50
+ let ourAt = 0;
51
+ let theirAt = 0;
52
+ let ourNext = 0;
53
+ let theirNext = 0;
54
+
55
+ const push = (baseEnd: number, ourEnd: number, theirEnd: number): void => {
56
+ const stretch = { base: base.slice(baseAt, baseEnd), ours: ours.slice(ourAt, ourEnd), theirs: theirs.slice(theirAt, theirEnd) };
57
+ if (stretch.base.length > 0 || stretch.ours.length > 0 || stretch.theirs.length > 0) {
58
+ blocks.push({ kind: kindOf(stretch.base, stretch.ours, stretch.theirs), ...stretch });
59
+ }
60
+ baseAt = baseEnd;
61
+ ourAt = ourEnd;
62
+ theirAt = theirEnd;
63
+ };
64
+
65
+ while (ourNext < ourChanges.length || theirNext < theirChanges.length) {
66
+ const start = Math.min(ourChanges[ourNext]?.baseStart ?? Infinity, theirChanges[theirNext]?.baseStart ?? Infinity);
67
+ if (start > baseAt) {
68
+ const length = start - baseAt;
69
+ push(start, ourAt + length, theirAt + length);
70
+ }
71
+ let end = baseAt;
72
+ let ourGrowth = 0;
73
+ let theirGrowth = 0;
74
+ /* The stretch grows while a change of either side reaches into it: two changes that overlap
75
+ in the base are one thing a person has to look at, however far apart they started. */
76
+ for (let taken = true; taken;) {
77
+ taken = false;
78
+ const reaches = (change: Change | undefined): boolean =>
79
+ change !== undefined && change.baseStart <= end && (change.baseStart < end || end === baseAt);
80
+ while (reaches(ourChanges[ourNext])) {
81
+ const change = ourChanges[ourNext]!;
82
+ end = Math.max(end, change.baseEnd);
83
+ ourGrowth += delta(change);
84
+ ourNext += 1;
85
+ taken = true;
86
+ }
87
+ while (reaches(theirChanges[theirNext])) {
88
+ const change = theirChanges[theirNext]!;
89
+ end = Math.max(end, change.baseEnd);
90
+ theirGrowth += delta(change);
91
+ theirNext += 1;
92
+ taken = true;
93
+ }
94
+ }
95
+ push(end, ourAt + (end - baseAt) + ourGrowth, theirAt + (end - baseAt) + theirGrowth);
96
+ }
97
+ push(base.length, ours.length, theirs.length);
98
+ return blocks;
99
+ }
100
+
101
+ /* The stretches a person still has to decide on. */
102
+ export function openBlocks(blocks: readonly MergeBlock[]): number {
103
+ return blocks.filter((block) => block.kind === 'conflict').length;
104
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,153 @@
1
+ /*
2
+ * One stretch where two versions of a file differ: the lines it covers on each side, `end` exclusive.
3
+ * An insert covers no line on the base side, a delete none on the other.
4
+ */
5
+ export interface Change {
6
+ baseStart: number;
7
+ baseEnd: number;
8
+ otherStart: number;
9
+ otherEnd: number;
10
+ }
11
+
12
+ /*
13
+ * How far apart two files may be before the exact walk costs more than the answer is worth. Past
14
+ * this the two are answered as one stretch that differs whole, which reads as a single conflict
15
+ * instead of a list nobody would work through anyway.
16
+ */
17
+ const MAX_DISTANCE = 4000;
18
+
19
+ /* The walk keeps every STRIDE-th state and walks the rounds between two again on the way back: a
20
+ state per round came to over a hundred megabytes at MAX_DISTANCE. */
21
+ const STRIDE = 64;
22
+
23
+ /* One edit and the run of equal lines behind it, as the walk found them. */
24
+ interface Move {
25
+ prevX: number;
26
+ prevY: number;
27
+ x: number;
28
+ y: number;
29
+ }
30
+
31
+ /* One round of the walk: every diagonal of this distance taken as far as its lines match. True once the far corner is reached. */
32
+ function extend(left: readonly string[], right: readonly string[], reach: Int32Array, offset: number, distance: number): boolean {
33
+ for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
34
+ const down = diagonal === -distance || (diagonal !== distance && reach[offset + diagonal - 1]! < reach[offset + diagonal + 1]!);
35
+ let x = down ? reach[offset + diagonal + 1]! : reach[offset + diagonal - 1]! + 1;
36
+ let y = x - diagonal;
37
+ while (x < left.length && y < right.length && left[x] === right[y]) {
38
+ x += 1;
39
+ y += 1;
40
+ }
41
+ reach[offset + diagonal] = x;
42
+ if (x >= left.length && y >= right.length) {
43
+ return true;
44
+ }
45
+ }
46
+ return false;
47
+ }
48
+
49
+ /* The path back from the far corner, one edit per step, in the order the file reads. */
50
+ function backtrack(left: readonly string[], right: readonly string[], saved: readonly Int32Array[], distance: number, offset: number): Move[] {
51
+ const moves: Move[] = [];
52
+ let x = left.length;
53
+ let y = right.length;
54
+ const states = Array.from({ length: Math.min(distance, STRIDE) + 1 }, () => new Int32Array(2 * offset + 1));
55
+ for (let step = distance; step > 0;) {
56
+ // The states the rounds from the saved one below this step started in, walked again.
57
+ const first = Math.floor((step - 1) / STRIDE) * STRIDE;
58
+ states[0]!.set(saved[first / STRIDE]!);
59
+ for (let round = first; round < step; round += 1) {
60
+ const next = states[round - first + 1]!;
61
+ next.set(states[round - first]!);
62
+ extend(left, right, next, offset, round);
63
+ }
64
+ for (; step > first; step -= 1) {
65
+ const state = states[step - first]!;
66
+ const diagonal = x - y;
67
+ const down = diagonal === -step || (diagonal !== step && state[offset + diagonal - 1]! < state[offset + diagonal + 1]!);
68
+ const previous = down ? diagonal + 1 : diagonal - 1;
69
+ const prevX = state[offset + previous]!;
70
+ const prevY = prevX - previous;
71
+ moves.push({ prevX, prevY, x, y });
72
+ x = prevX;
73
+ y = prevY;
74
+ }
75
+ }
76
+ return moves.reverse();
77
+ }
78
+
79
+ /*
80
+ * Myers' greedy walk: for every edit distance it keeps the furthest reach on each diagonal, and every
81
+ * STRIDE-th state a round started in, which is what the path is read back from once the far corner
82
+ * is reached. Null once the distance passes what is worth walking.
83
+ */
84
+ function walk(left: readonly string[], right: readonly string[]): Move[] | null {
85
+ const limit = Math.min(left.length + right.length, MAX_DISTANCE);
86
+ const offset = limit;
87
+ const reach = new Int32Array(2 * limit + 1);
88
+ const saved: Int32Array[] = [];
89
+ for (let distance = 0; distance <= limit; distance += 1) {
90
+ if (distance % STRIDE === 0) {
91
+ saved.push(reach.slice());
92
+ }
93
+ if (extend(left, right, reach, offset, distance)) {
94
+ return backtrack(left, right, saved, distance, offset);
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /* The stretches the moves cover, with edits that follow each other straight away folded into one. */
101
+ function changesOf(moves: readonly Move[]): Change[] {
102
+ const changes: Change[] = [];
103
+ let current: Change | null = null;
104
+ for (const move of moves) {
105
+ // A delete moves along the base side and so raises the diagonal; an insert lowers it.
106
+ const deleted = move.x - move.y > move.prevX - move.prevY;
107
+ const editX = deleted ? move.prevX + 1 : move.prevX;
108
+ const editY = deleted ? move.prevY : move.prevY + 1;
109
+ if (current !== null && current.baseEnd === move.prevX && current.otherEnd === move.prevY) {
110
+ current.baseEnd = editX;
111
+ current.otherEnd = editY;
112
+ } else {
113
+ if (current !== null) {
114
+ changes.push(current);
115
+ }
116
+ current = { baseStart: move.prevX, baseEnd: editX, otherStart: move.prevY, otherEnd: editY };
117
+ }
118
+ // What follows the edit is a run of equal lines, which ends the stretch.
119
+ if (move.x !== editX || move.y !== editY) {
120
+ changes.push(current);
121
+ current = null;
122
+ }
123
+ }
124
+ if (current !== null) {
125
+ changes.push(current);
126
+ }
127
+ return changes;
128
+ }
129
+
130
+ /* Where `other` differs from `base`, in line stretches, in the order they appear. */
131
+ export function diffLines(base: readonly string[], other: readonly string[]): Change[] {
132
+ let prefix = 0;
133
+ while (prefix < base.length && prefix < other.length && base[prefix] === other[prefix]) {
134
+ prefix += 1;
135
+ }
136
+ let suffix = 0;
137
+ while (suffix < base.length - prefix && suffix < other.length - prefix && base[base.length - 1 - suffix] === other[other.length - 1 - suffix]) {
138
+ suffix += 1;
139
+ }
140
+ const left = base.slice(prefix, base.length - suffix);
141
+ const right = other.slice(prefix, other.length - suffix);
142
+ if (left.length === 0 && right.length === 0) {
143
+ return [];
144
+ }
145
+ const moves = left.length === 0 || right.length === 0 ? null : walk(left, right);
146
+ const changes = moves === null ? [{ baseStart: 0, baseEnd: left.length, otherStart: 0, otherEnd: right.length }] : changesOf(moves);
147
+ return changes.map((change) => ({
148
+ baseStart: change.baseStart + prefix,
149
+ baseEnd: change.baseEnd + prefix,
150
+ otherStart: change.otherStart + prefix,
151
+ otherEnd: change.otherEnd + prefix
152
+ }));
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { diffLines, type Change } from './diff.ts';
2
+ export { openBlocks, splitBlocks, type MergeBlock, type MergeBlockKind } from './blocks.ts';
3
+ export { autoLines, bothLines, draftOf, fingerprint, sideLines, wandLines, type MergeDraft, type MergeSide, type MergeSpan } from './resolve.ts';
4
+ export { joinLines, shapeOf, splitLines, type TextShape } from './text.ts';
package/src/resolve.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { MergeBlock } from './blocks.ts';
2
+
3
+ export type MergeSide = 'ours' | 'theirs';
4
+
5
+ export function sideLines(block: MergeBlock, side: MergeSide): string[] {
6
+ return side === 'ours' ? [...block.ours] : [...block.theirs];
7
+ }
8
+
9
+ /* Both sides, one after the other, for the conflict where the two additions both belong. */
10
+ export function bothLines(block: MergeBlock, first: MergeSide): string[] {
11
+ return first === 'ours' ? [...block.ours, ...block.theirs] : [...block.theirs, ...block.ours];
12
+ }
13
+
14
+ /* What a block is worth without anyone choosing: the side that changed, or the change both made. */
15
+ export function autoLines(block: MergeBlock): string[] | null {
16
+ switch (block.kind) {
17
+ case 'stable':
18
+ case 'ours':
19
+ case 'both':
20
+ return [...block.ours];
21
+ case 'theirs':
22
+ return [...block.theirs];
23
+ case 'conflict':
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function squashed(lines: readonly string[]): string {
29
+ return lines
30
+ .map((line) => {
31
+ const body = line.trim();
32
+ // Indentation is meaning in Python, YAML or a Makefile, so only a blank line loses its own.
33
+ const indent = body === '' ? '' : line.slice(0, line.length - line.trimStart().length);
34
+ return `${indent}${body.replace(/\s+/g, ' ')}`;
35
+ })
36
+ .join('\n');
37
+ }
38
+
39
+ /* Where `inner` starts in `outer` as one run of lines, or -1. */
40
+ function runIn(outer: readonly string[], inner: readonly string[]): number {
41
+ for (let start = 0; start + inner.length <= outer.length; start += 1) {
42
+ if (inner.every((line, index) => outer[start + index] === line)) {
43
+ return start;
44
+ }
45
+ }
46
+ return -1;
47
+ }
48
+
49
+ /*
50
+ * Whether `outer` holds every line of `inner` and adds nothing `inner` took out: a base line among
51
+ * what `outer` adds is one `inner` deleted, and taking `outer` would put it back.
52
+ */
53
+ function covers(base: readonly string[], outer: readonly string[], inner: readonly string[]): boolean {
54
+ const start = runIn(outer, inner);
55
+ if (inner.length === 0 || start < 0) {
56
+ return false;
57
+ }
58
+ const added = [...outer.slice(0, start), ...outer.slice(start + inner.length)];
59
+ return added.every((line) => !base.includes(line) || inner.includes(line));
60
+ }
61
+
62
+ /*
63
+ * What the wand makes of a conflict, or null for one that needs a person after all. It closes the
64
+ * two nobody would think twice about: the sides that differ only in whitespace inside or after a
65
+ * line, and the side that already holds every line of the other without putting back what the other
66
+ * deleted. Anything else is a choice, and a wand that guesses at those is a wand nobody trusts twice.
67
+ */
68
+ export function wandLines(block: MergeBlock): string[] | null {
69
+ if (block.kind !== 'conflict') {
70
+ return autoLines(block);
71
+ }
72
+ if (squashed(block.ours) === squashed(block.theirs)) {
73
+ return [...block.ours];
74
+ }
75
+ if (covers(block.base, block.ours, block.theirs)) {
76
+ return [...block.ours];
77
+ }
78
+ if (covers(block.base, block.theirs, block.ours)) {
79
+ return [...block.theirs];
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /*
85
+ * A short name for the two sides of one block, so an answer written for it can be checked against
86
+ * the block it lands on. Not a secret and not a digest anyone leans on: it only has to differ when
87
+ * the stretch does, and it has to read the same on a daemon and in a browser, which rules out the
88
+ * one hash a browser cannot take synchronously.
89
+ */
90
+ export function fingerprint(block: MergeBlock): string {
91
+ let hash = 0x811c9dc5;
92
+ for (const line of [...block.ours, '\u0000', ...block.theirs]) {
93
+ for (let index = 0; index < line.length; index += 1) {
94
+ hash = Math.imul(hash ^ line.charCodeAt(index), 0x01000193);
95
+ }
96
+ hash = Math.imul(hash ^ 0x0a, 0x01000193);
97
+ }
98
+ return (hash >>> 0).toString(16).padStart(8, '0');
99
+ }
100
+
101
+ /* Where one block landed in the draft, `to` exclusive, so a block is a range a person can act on. */
102
+ export interface MergeSpan {
103
+ block: number;
104
+ kind: MergeBlock['kind'];
105
+ from: number;
106
+ to: number;
107
+ }
108
+
109
+ export interface MergeDraft {
110
+ lines: string[];
111
+ spans: MergeSpan[];
112
+ }
113
+
114
+ /*
115
+ * The merged file to start from: everything that merges by itself is merged, and every conflict
116
+ * holds our side until someone says otherwise. Never a conflict marker: what a person edits here is
117
+ * a file that would compile, and the other side is a column away rather than a line below. Blocks
118
+ * that have already been answered are passed in, which is how a proposal lands in a file nobody has
119
+ * open.
120
+ */
121
+ export function draftOf(blocks: readonly MergeBlock[], answered: ReadonlyMap<number, readonly string[]> = new Map()): MergeDraft {
122
+ const lines: string[] = [];
123
+ const spans: MergeSpan[] = [];
124
+ for (const [index, block] of blocks.entries()) {
125
+ const from = lines.length;
126
+ lines.push(...(answered.get(index) ?? autoLines(block) ?? block.ours));
127
+ spans.push({ block: index, kind: block.kind, from, to: lines.length });
128
+ }
129
+ return { lines, spans };
130
+ }
package/src/text.ts ADDED
@@ -0,0 +1,33 @@
1
+ /*
2
+ * The shape of the file every line is put back into: what ends a line here, and whether the last one
3
+ * is ended at all. A resolution that loses either rewrites the whole file on the next diff.
4
+ */
5
+ export interface TextShape {
6
+ eol: '\n' | '\r\n';
7
+ finalNewline: boolean;
8
+ }
9
+
10
+ /* The line ending a file uses, by the first one in it, and whether it ends on one. */
11
+ export function shapeOf(text: string): TextShape {
12
+ const line = text.indexOf('\n');
13
+ return {
14
+ eol: line > 0 && text[line - 1] === '\r' ? '\r\n' : '\n',
15
+ finalNewline: text === '' || text.endsWith('\n')
16
+ };
17
+ }
18
+
19
+ /* The lines of a file, without the empty one a trailing newline would leave behind. */
20
+ export function splitLines(text: string): string[] {
21
+ if (text === '') {
22
+ return [];
23
+ }
24
+ const lines = text.split(/\r\n|\n/);
25
+ if (lines[lines.length - 1] === '') {
26
+ lines.pop();
27
+ }
28
+ return lines;
29
+ }
30
+
31
+ export function joinLines(lines: readonly string[], shape: TextShape): string {
32
+ return lines.length === 0 ? '' : lines.join(shape.eol) + (shape.finalNewline ? shape.eol : '');
33
+ }