commit 5fe468a947f134de490dcfef4f99a872b3234cb5 Author: Alexander Zhirov Date: Sun Aug 31 18:42:22 2025 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..740b38e --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d1c022f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.detectIndentation": false +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..36b7cd9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8869256 --- /dev/null +++ b/README.md @@ -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). diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..477c3fc --- /dev/null +++ b/dub.json @@ -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" + ] + } + } + +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..0b7884b --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,6 @@ +{ + "fileVersion": 1, + "versions": { + "xdiff": {"version":"e2396bc172eba813cdcd1a96c494e35d687f576a","repository":"git+https://git.zhirov.kz/dlang/xdiff.git"} + } +} diff --git a/source/libxdiff/init.d b/source/libxdiff/init.d new file mode 100644 index 0000000..ddfdca8 --- /dev/null +++ b/source/libxdiff/init.d @@ -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; +} diff --git a/source/libxdiff/mmblocks.d b/source/libxdiff/mmblocks.d new file mode 100644 index 0000000..0ed3d9e --- /dev/null +++ b/source/libxdiff/mmblocks.d @@ -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; + } +} diff --git a/source/libxdiff/mmfile.d b/source/libxdiff/mmfile.d new file mode 100644 index 0000000..f98693d --- /dev/null +++ b/source/libxdiff/mmfile.d @@ -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; + } +} diff --git a/source/libxdiff/unittests.d b/source/libxdiff/unittests.d new file mode 100644 index 0000000..590d4d0 --- /dev/null +++ b/source/libxdiff/unittests.d @@ -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); +}