This commit is contained in:
Alexander Zhirov 2025-08-31 18:42:22 +03:00
commit 5fe468a947
Signed by: alexander
GPG key ID: C8D8BE544A27C511
10 changed files with 730 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
.dub
docs.json
__dummy.html
docs/
/libxdiff
libxdiff.so
libxdiff.dylib
libxdiff.dll
libxdiff.a
libxdiff.lib
libxdiff-test-*
*.exe
*.pdb
*.o
*.obj
*.lst

5
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"editor.insertSpaces": false,
"editor.tabSize": 4,
"editor.detectIndentation": false
}

23
LICENSE Normal file
View file

@ -0,0 +1,23 @@
Boost Software License - Version 1.0 - August 17th, 2003
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

39
README.md Normal file
View file

@ -0,0 +1,39 @@
# libxdiff
A D wrapper around the C **libxdiff**: thin bindings ([xdiff.d](https://git.zhirov.kz/dlang/xdiff)) + a small OO layer (`MMFile`, `MMBlocks`) for diff/patch/merge with clear ownership.
## Features
* **MMFile** — compact in-memory file (single buffer).
* **MMBlocks** — native libxdiff block chain with compaction.
* `computePatch`, `applyPatch` (returns accepted + rejected parts).
* `merge3Raw` (three-way merge) with `nothrow` callbacks.
* Deep copies where needed, explicit move of ownership, no double-free traps.
## Installation
Add to `dub.json`:
```json
{
"dependencies": {
"libxdiff": "~>0.1.0"
}
}
```
Or to `dub.sdl`:
```
dependency "libxdiff" version="~>0.1.0"
```
Run `dub build`.
## Usage
Import the module: `import libxdiff;`
## License
Boost Software License 1.0. See [LICENSE](LICENSE).

28
dub.json Normal file
View file

@ -0,0 +1,28 @@
{
"authors": [
"Alexander Zhirov"
],
"copyright": "Copyright © 2025, Alexander Zhirov",
"description": "An OO wrapper over xdiff.d (thin libxdiff bindings) with safe ownership and a simple diff/patch/merge API.",
"license": "BSL-1.0",
"name": "libxdiff",
"targetType": "library",
"dependencies": {
"xdiff": {
"repository": "git+https://git.zhirov.kz/dlang/xdiff.git",
"version": "e2396bc172eba813cdcd1a96c494e35d687f576a"
}
},
"buildTypes": {
"unittest": {
"dflags": [
"-unittest",
"-g"
],
"libs": [
"xdiff"
]
}
}
}

6
dub.selections.json Normal file
View file

@ -0,0 +1,6 @@
{
"fileVersion": 1,
"versions": {
"xdiff": {"version":"e2396bc172eba813cdcd1a96c494e35d687f576a","repository":"git+https://git.zhirov.kz/dlang/xdiff.git"}
}
}

44
source/libxdiff/init.d Normal file
View file

@ -0,0 +1,44 @@
module libxdiff.init;
import core.stdc.stdlib : malloc, free, realloc;
import std.exception : enforce;
import xdiff;
extern (C) private void* wrap_malloc(void*, uint size)
{
return malloc(size);
}
extern (C) private void wrap_free(void*, void* p)
{
free(p);
}
extern (C) private void* wrap_realloc(void*, void* p, uint size)
{
return realloc(p, size);
}
void ensureInit()
{
static bool done = false;
if (done)
return;
memallocator_t m;
m.priv = null;
m.malloc = &wrap_malloc;
m.free = &wrap_free;
m.realloc = &wrap_realloc;
enforce(xdl_set_allocator(&m) == 0, "xdl_set_allocator failed");
done = true;
}
mmfile_t initMmfile(size_t len)
{
ensureInit();
mmfile_t mf;
enforce(xdl_init_mmfile(&mf, cast(long) len, XDL_MMF_ATOMIC) == 0, "xdl_init_mmfile failed");
return mf;
}

117
source/libxdiff/mmblocks.d Normal file
View file

@ -0,0 +1,117 @@
module libxdiff.mmblocks;
import std.exception : enforce;
import libxdiff.init : ensureInit, initMmfile;
import xdiff;
final class MMBlocks
{
private:
mmfile_t _inner;
bool _owned = true;
enum DEFAULT_BSIZE = 8 * 1024;
static void deepCopyAll(mmfile_t* src, MMBlocks dst)
{
enforce(xdl_seek_mmfile(src, 0) == 0, "seek(0) failed");
long sz = 0;
auto p = xdl_mmfile_first(src, &sz);
while (p !is null && sz > 0)
{
auto wrote = xdl_write_mmfile(dst.mmfilePtr(), p, sz);
enforce(wrote == sz, "write failed during deep copy");
p = xdl_mmfile_next(src, &sz);
}
}
public:
this()
{
_inner = initMmfile(DEFAULT_BSIZE);
}
static MMBlocks fromBytes(const(ubyte)[] data)
{
auto b = new MMBlocks;
if (data.length > 0)
{
auto wrote = xdl_write_mmfile(&b._inner, data.ptr, cast(long) data.length);
enforce(wrote == cast(long) data.length, "xdl_write_mmfile wrote less than requested");
}
return b;
}
~this()
{
if (_owned && _inner.head !is null)
xdl_free_mmfile(&_inner);
_inner.head = null;
_inner.tail = null;
_inner.rcur = null;
_inner.wcur = null;
_inner.fsize = 0;
_inner.bsize = 0;
}
mmfile_t* mmfilePtr() @trusted
{
return &_inner;
}
size_t size()
{
return cast(size_t) xdl_mmfile_size(&_inner);
}
bool isCompact() const
{
return xdl_mmfile_iscompact(cast(mmfile_t*)&_inner) != 0;
}
int writeBuf(const(ubyte)[] buf)
{
auto wrote = xdl_write_mmfile(&_inner, buf.ptr, cast(long) buf.length);
return (wrote == cast(long) buf.length) ? 0 : -1;
}
MMBlocks clone() const
{
auto dst = new MMBlocks;
deepCopyAll(cast(mmfile_t*)&_inner, dst);
return dst;
}
void toCompact()
{
if (isCompact())
return;
auto dst = new MMBlocks;
deepCopyAll(&_inner, dst);
if (_owned && _inner.head !is null)
xdl_free_mmfile(&_inner);
_inner = *dst.mmfilePtr();
dst._owned = false;
dst._inner.head = null;
dst._inner.tail = null;
dst._inner.rcur = null;
dst._inner.wcur = null;
dst._inner.fsize = 0;
dst._inner.bsize = 0;
}
void disarm()
{
_owned = false;
_inner.head = null;
_inner.tail = null;
_inner.rcur = null;
_inner.wcur = null;
_inner.fsize = 0;
_inner.bsize = 0;
}
}

199
source/libxdiff/mmfile.d Normal file
View file

@ -0,0 +1,199 @@
module libxdiff.mmfile;
import std.exception : enforce;
import libxdiff.init : ensureInit, initMmfile;
import libxdiff.mmblocks : MMBlocks;
import xdiff;
final class MMFile
{
private:
mmfile_t _inner;
bool _owned = true;
public:
this()
{
_inner = initMmfile(0);
}
static MMFile fromBytes(const(ubyte)[] data)
{
auto f = new MMFile;
if (data.length > 0)
{
auto wrote = xdl_write_mmfile(&f._inner, data.ptr, cast(long) data.length);
enforce(wrote == cast(long) data.length, "xdl_write_mmfile wrote less than requested");
}
return f;
}
~this()
{
if (_owned && _inner.head !is null)
xdl_free_mmfile(&_inner);
_inner.head = null;
_inner.tail = null;
_inner.rcur = null;
_inner.wcur = null;
_inner.fsize = 0;
_inner.bsize = 0;
}
size_t size()
{
return cast(size_t) xdl_mmfile_size(&_inner);
}
bool isCompact() const
{
return xdl_mmfile_iscompact(cast(mmfile_t*)&_inner) != 0;
}
const(ubyte)[] asSlice() const
{
enforce(isCompact(), "MMFile must be compact for asSlice");
auto h = _inner.head;
if (h is null || h.size <= 0)
return (cast(ubyte*) null)[0 .. 0];
return (cast(const(ubyte)*) h.ptr)[0 .. cast(size_t) h.size];
}
ubyte[] asSliceMut()
{
enforce(isCompact(), "MMFile must be compact for asSliceMut");
auto h = _inner.head;
if (h is null || h.size <= 0)
return (cast(ubyte*) null)[0 .. 0];
return (cast(ubyte*) h.ptr)[0 .. cast(size_t) h.size];
}
alias MMPatch = MMBlocks;
MMPatch computePatch(ref MMFile other)
{
auto patch = new MMBlocks();
static extern (C) int emitToPatch(void* priv, mmbuffer_t* bufs, int num)
{
auto target = cast(MMBlocks) priv;
foreach (i; 0 .. num)
{
auto b = bufs + i;
auto wrote = xdl_writem_mmfile(target.mmfilePtr(), b, 1);
if (wrote != (*b).size)
return -1;
}
return 0;
}
xpparam_t xpp;
xpp.flags = 0;
xdemitconf_t xec;
xec.ctxlen = 3;
xdemitcb_t ecb;
ecb.priv = cast(void*) patch;
ecb.outf = &emitToPatch;
auto rc = xdl_diff(&_inner, &other._inner, &xpp, &xec, &ecb);
enforce(rc == 0, "xdl_diff failed");
return patch;
}
struct PatchResult
{
bool success;
MMFile patched;
MMFile rejected;
}
PatchResult applyPatch(ref MMPatch patch)
{
patch.toCompact();
auto acc = new MMBlocks();
auto rej = new MMBlocks();
static extern (C) int emitTo(void* priv, mmbuffer_t* bufs, int num)
{
auto target = cast(MMBlocks) priv;
long expect = 0;
foreach (i; 0 .. num)
expect += (bufs + i).size;
auto wrote = xdl_writem_mmfile(target.mmfilePtr(), bufs, num);
return (wrote == expect) ? 0 : -1;
}
xdemitcb_t accCb;
accCb.priv = cast(void*) acc;
accCb.outf = &emitTo;
xdemitcb_t rejCb;
rejCb.priv = cast(void*) rej;
rejCb.outf = &emitTo;
auto rc = xdl_patch(&_inner, patch.mmfilePtr(), XDL_PATCH_NORMAL, &accCb, &rejCb);
enforce(rc == 0 || rc == 1, "xdl_patch failed");
auto accFile = MMFile.fromBlocksMoved(acc);
auto rejFile = MMFile.fromBlocksMoved(rej);
bool ok = (rejFile.size() == 0);
return PatchResult(ok, accFile, rejFile);
}
void merge3Raw(
ref MMFile f1,
ref MMFile f2,
scope int delegate(const(ubyte)[]) nothrow acceptCb,
scope int delegate(const(ubyte)[]) nothrow rejectCb)
{
static extern (C) int emitAccept(void* priv, mmbuffer_t* bufs, int num)
{
auto d = cast(int delegate(const(ubyte)[]) nothrow*) priv;
foreach (i; 0 .. num)
{
auto b = bufs + i;
auto slice = (cast(const(ubyte)*)((*b).ptr))[0 .. cast(size_t)((*b).size)];
auto r = (*d)(slice);
if (r != 0)
return r;
}
return 0;
}
static extern (C) int emitReject(void* priv, mmbuffer_t* bufs, int num)
{
auto d = cast(int delegate(const(ubyte)[]) nothrow*) priv;
foreach (i; 0 .. num)
{
auto b = bufs + i;
auto slice = (cast(const(ubyte)*)((*b).ptr))[0 .. cast(size_t)((*b).size)];
auto r = (*d)(slice);
if (r != 0)
return r;
}
return 0;
}
auto a = acceptCb, r = rejectCb;
xdemitcb_t accCb;
accCb.priv = cast(void*)&a;
accCb.outf = &emitAccept;
xdemitcb_t rejCb;
rejCb.priv = cast(void*)&r;
rejCb.outf = &emitReject;
auto rc = xdl_merge3(&_inner, &f1._inner, &f2._inner, &accCb, &rejCb);
enforce(rc == 0, "xdl_merge3 failed");
}
static MMFile fromBlocksMoved(MMBlocks b)
{
b.toCompact();
auto f = new MMFile;
f._inner = *b.mmfilePtr();
b.disarm();
return f;
}
}

253
source/libxdiff/unittests.d Normal file
View file

@ -0,0 +1,253 @@
module libxdiff.unittests;
import std.algorithm : equal;
import std.array : appender;
import std.exception : assertThrown;
import std.range : iota, cycle, take;
import libxdiff.mmfile : MMFile;
import libxdiff.mmblocks : MMBlocks;
ubyte[] genCycled(size_t n)
{
auto acc = appender!(ubyte[])();
foreach (v; iota(0, 240).cycle.take(n))
acc.put(cast(ubyte) v);
return acc.data;
}
ubyte[] blocksToBytes(const MMBlocks b)
{
auto tmp = b.clone();
auto mf = MMFile.fromBlocksMoved(tmp);
return (cast(const(ubyte)[]) mf.asSlice()).dup;
}
bool sameBytes(const MMFile a, const MMFile b)
{
return a.asSlice().equal(b.asSlice());
}
unittest
{
auto f = new MMFile;
assert(f.size() == 0);
assert(f.isCompact());
}
unittest
{
auto data = cast(ubyte[]) "hello world";
auto f = MMFile.fromBytes(data);
assert(f.size() == data.length);
assert(f.isCompact());
assert(f.asSlice().equal(data));
}
unittest
{
auto data = genCycled(15_000);
auto f = MMFile.fromBytes(data);
assert(f.size() == data.length);
assert(f.isCompact());
assert(f.asSlice().equal(data));
}
unittest
{
auto data = genCycled(15_000);
auto f = MMFile.fromBytes(data);
auto f2 = MMFile.fromBytes(f.asSlice().dup);
assert(sameBytes(f, f2));
}
unittest
{
auto data = genCycled(15_000);
auto b = MMBlocks.fromBytes(data);
auto b2 = b.clone();
auto bytes = blocksToBytes(b);
auto bytes2 = blocksToBytes(b2);
assert(bytes.equal(bytes2));
}
unittest
{
auto data = genCycled(15_000);
auto f = MMFile.fromBytes(data);
auto f2 = MMFile.fromBytes(data.dup);
assert(sameBytes(f, f2));
}
unittest
{
assert((new MMFile).asSlice().length == 0);
auto data = genCycled(15_000);
auto f = MMFile.fromBytes(data);
assert(f.asSlice().equal(data));
}
unittest
{
auto empty = new MMFile;
assert(empty.asSlice().length == 0);
auto data = genCycled(15_000);
auto f = MMFile.fromBytes(data);
assert(f.asSlice()[0] == data[0]);
auto mut = f.asSliceMut();
mut[0] = cast(ubyte)(mut[0] + 1);
assert(f.asSlice()[0] == data[0] + 1);
}
unittest
{
auto data = cast(ubyte[]) "hello world\n";
auto data2 = cast(ubyte[]) "hello world!\n";
auto f = MMFile.fromBytes(data);
auto f2 = MMFile.fromBytes(data2);
auto patch = f.computePatch(f2);
auto r = f.applyPatch(patch);
assert(r.success);
assert(r.patched.asSlice().equal(data2));
assert(r.rejected.asSlice().length == 0);
}
unittest
{
auto f = MMFile.fromBytes(cast(ubyte[]) "hello world\n");
auto f2 = MMFile.fromBytes(cast(ubyte[]) "hello world!\n");
auto p1 = f.computePatch(f2);
auto r1 = f.applyPatch(p1);
assert(r1.success);
assert(r1.patched.asSlice().equal(f2.asSlice()));
auto m = f2.asSliceMut();
m[0] = cast(ubyte) 'j';
auto p2 = f.computePatch(f2);
auto r2 = f.applyPatch(p2);
assert(r2.success);
assert(r2.patched.asSlice().equal(f2.asSlice()));
}
unittest
{
auto base = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world\n");
auto a = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world changed\n");
auto b = MMFile.fromBytes(cast(ubyte[]) "header_changed\nline2\nline3\nline4\nhello world\n");
auto lines = appender!(string[])();
auto rej = appender!(string[])();
int acceptCb(const(ubyte)[] s) nothrow
{
lines.put(cast(string) s.idup);
return 0;
}
int rejectCb(const(ubyte)[] s) nothrow
{
rej.put(cast(string) s.idup);
return 0;
}
base.merge3Raw(a, b, &acceptCb, &rejectCb);
auto expected = [
"header_changed\n",
"line2\n",
"line3\n",
"line4\n",
"hello world changed\n",
];
assert(lines.data.equal(expected));
assert(rej.data.length == 0);
}
unittest
{
auto base = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world\n");
auto a = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world changed\n");
auto b = MMFile.fromBytes(
cast(ubyte[]) "header\nline2\nline3\nline4\nhello world also changed\n");
auto lines = appender!(string[])();
auto rej = appender!(string[])();
int acceptCb(const(ubyte)[] s) nothrow
{
lines.put(cast(string) s.idup);
return 0;
}
int rejectCb(const(ubyte)[] s) nothrow
{
rej.put(cast(string) s.idup);
return 0;
}
base.merge3Raw(a, b, &acceptCb, &rejectCb);
assert(lines.data.length > 0);
assert(rej.data.length > 0);
}
unittest
{
auto base = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world\n");
auto a = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world changed\n");
auto b = MMFile.fromBytes(cast(ubyte[]) "header_changed\nline2\nline3\nline4\nhello world\n");
auto lines = appender!(string[])();
int acceptCb(const(ubyte)[] s) nothrow
{
static size_t cnt = 0;
++cnt;
if (cnt > 3)
return -1;
lines.put(cast(string) s.idup);
return 0;
}
int rejectCb(const(ubyte)[] s) nothrow
{
return 0;
}
assertThrown!Exception(base.merge3Raw(a, b, &acceptCb, &rejectCb));
}
unittest
{
auto base = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world\n");
auto want = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world changed\n");
auto patch = base.computePatch(want);
auto res = base.applyPatch(patch);
assert(res.success);
assert(sameBytes(res.patched, want));
assert(res.rejected.asSlice().length == 0);
}
unittest
{
auto base = MMFile.fromBytes(cast(ubyte[]) "header\nline2\nline3\nline4\nhello world\n");
auto want = MMFile.fromBytes(
cast(ubyte[]) "header changed\nline2\nline3\nline4\nhello world changed\n");
auto patch = base.computePatch(want);
auto m = base.asSliceMut();
m[0] = cast(ubyte) 'b';
auto res = base.applyPatch(patch);
assert(!res.success);
assert(res.rejected.asSlice().length > 0);
}