diff --git a/examples/tour.luau b/examples/tour.luau index 6a58cc6..1e592ad 100644 --- a/examples/tour.luau +++ b/examples/tour.luau @@ -1,35 +1,36 @@ -local fs = require("@lune/fs") -local zip = require("../lib") - -local file = fs.readFile("test.zip") -local reader = zip.load(buffer.fromstring(file)) - -print("Directory structure:") -reader:walk(function(entry, depth) - local prefix = string.rep(" ", depth) - local suffix = if not entry.isDirectory then string.format(" (%d bytes)", entry.size) else "" - print(prefix .. entry.name .. suffix) -end) - -print("\nContents of `/`:") -local assets = reader:listDirectory("/") -for _, entry in assets do - print(entry.name, if entry.isDirectory then "DIR" else entry.size) - if not entry.isDirectory then - local extracted = reader:extract(entry, { isString = true }) - print("Content:", extracted) - end -end - --- Get archive statistics -local stats = reader:getStats() -print(string.format([[ - -Archive stats: -Files: %d -Directories: %d -Total size: %d bytes]], - stats.fileCount, - stats.dirCount, - stats.totalSize -)) +local fs = require("@lune/fs") +local zip = require("../lib") + +local file = fs.readFile("test.zip") +local reader = zip.load(buffer.fromstring(file)) + +print("Directory structure:") +reader:walk(function(entry, depth) + local prefix = string.rep(" ", depth) + local suffix = if not entry.isDirectory then string.format(" (%d bytes)", entry.size) else "" + print(prefix .. entry.name .. suffix) +end) + +print("\nContents of `/`:") +local assets = reader:listDirectory("/") +for _, entry in assets do + print(entry.name, if entry.isDirectory then "DIR" else entry.size) + if not entry.isDirectory then + local extracted = reader:extract(entry, { isString = true }) + print("Content:", extracted) + end +end + +-- Get archive statistics +local stats = reader:getStats() +print(string.format( + [[ + +Archive stats: +Files: %d +Directories: %d +Total size: %d bytes]], + stats.fileCount, + stats.dirCount, + stats.totalSize +)) diff --git a/lib/crc.luau b/lib/crc.luau index bd9fd79..549c5df 100644 --- a/lib/crc.luau +++ b/lib/crc.luau @@ -1,30 +1,30 @@ -local CRC32_TABLE = table.create(256) - --- Initialize the lookup table and lock it in place -for i = 0, 255 do - local crc = i - for _ = 1, 8 do - if bit32.band(crc, 1) == 1 then - crc = bit32.bxor(bit32.rshift(crc, 1), 0xEDB88320) - else - crc = bit32.rshift(crc, 1) - end - end - CRC32_TABLE[i] = crc -end - -table.freeze(CRC32_TABLE) - -local function crc32(buf: buffer): number - local crc = 0xFFFFFFFF - - for i = 0, buffer.len(buf) - 1 do - local byte = buffer.readu8(buf, i) - local index = bit32.band(bit32.bxor(crc, byte), 0xFF) - crc = bit32.bxor(bit32.rshift(crc, 8), CRC32_TABLE[index]) - end - - return bit32.bxor(crc, 0xFFFFFFFF) -end - -return crc32 +local CRC32_TABLE = table.create(256) + +-- Initialize the lookup table and lock it in place +for i = 0, 255 do + local crc = i + for _ = 1, 8 do + if bit32.band(crc, 1) == 1 then + crc = bit32.bxor(bit32.rshift(crc, 1), 0xEDB88320) + else + crc = bit32.rshift(crc, 1) + end + end + CRC32_TABLE[i] = crc +end + +table.freeze(CRC32_TABLE) + +local function crc32(buf: buffer): number + local crc = 0xFFFFFFFF + + for i = 0, buffer.len(buf) - 1 do + local byte = buffer.readu8(buf, i) + local index = bit32.band(bit32.bxor(crc, byte), 0xFF) + crc = bit32.bxor(bit32.rshift(crc, 8), CRC32_TABLE[index]) + end + + return bit32.bxor(crc, 0xFFFFFFFF) +end + +return crc32 diff --git a/lib/inflate.luau b/lib/inflate.luau index b1c96ee..efba03a 100644 --- a/lib/inflate.luau +++ b/lib/inflate.luau @@ -1,61 +1,61 @@ -local Tree = {} - -export type Tree = typeof(setmetatable({} :: TreeInner, { __index = Tree })) -type TreeInner = { - table: { number }, -- len: 16 (🏳️‍⚧️❓) - trans: { number }, -- len: 288 -} - -function Tree.new(): Tree - return setmetatable( - { - table = table.create(16, 0), - trans = table.create(288, 0), - } :: TreeInner, - { __index = Tree } - ) -end - -local Data = {} -export type Data = typeof(setmetatable({} :: DataInner, { __index = Data })) -export type DataInner = { - source: buffer, - sourceIndex: number, - tag: number, - bitcount: number, - - dest: buffer, - destLen: number, - - ltree: Tree, - dtree: Tree, -} - -function Data.new(source: buffer, dest: buffer): Data - return setmetatable( - { - source = source, - sourceIndex = 0, - tag = 0, - bitcount = 0, - dest = dest, - destLen = 0, - ltree = Tree.new(), - dtree = Tree.new(), - } :: DataInner, - { __index = Data } - ) -end - --- Static structures -local staticLengthTree = Tree.new() -local staticDistTree = Tree.new() - --- Extra bits and base tables -local lengthBits = table.create(30, 0) -local lengthBase = table.create(30, 0) -local distBits = table.create(30, 0) -local distBase = table.create(30, 0) +local Tree = {} + +export type Tree = typeof(setmetatable({} :: TreeInner, { __index = Tree })) +type TreeInner = { + table: { number }, -- len: 16 (🏳️‍⚧️❓) + trans: { number }, -- len: 288 +} + +function Tree.new(): Tree + return setmetatable( + { + table = table.create(16, 0), + trans = table.create(288, 0), + } :: TreeInner, + { __index = Tree } + ) +end + +local Data = {} +export type Data = typeof(setmetatable({} :: DataInner, { __index = Data })) +export type DataInner = { + source: buffer, + sourceIndex: number, + tag: number, + bitcount: number, + + dest: buffer, + destLen: number, + + ltree: Tree, + dtree: Tree, +} + +function Data.new(source: buffer, dest: buffer): Data + return setmetatable( + { + source = source, + sourceIndex = 0, + tag = 0, + bitcount = 0, + dest = dest, + destLen = 0, + ltree = Tree.new(), + dtree = Tree.new(), + } :: DataInner, + { __index = Data } + ) +end + +-- Static structures +local staticLengthTree = Tree.new() +local staticDistTree = Tree.new() + +-- Extra bits and base tables +local lengthBits = table.create(30, 0) +local lengthBase = table.create(30, 0) +local distBits = table.create(30, 0) +local distBase = table.create(30, 0) -- Special ordering of code length codes -- stylua: ignore @@ -64,282 +64,282 @@ local clcIndex = { 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 } - -local codeTree = Tree.new() -local lengths = table.create(288 + 32, 0) - -local function buildBitsBase(bits: { number }, base: { number }, delta: number, first: number) - local sum = first - - -- build bits table - for i = 0, delta - 1 do - bits[i] = 0 - end - for i = 0, 29 - delta do - bits[i + delta] = math.floor(i / delta) - end - - -- build base table - for i = 0, 29 do - base[i] = sum - sum = sum + bit32.lshift(1, bits[i]) - end -end - -local function buildFixedTrees(lengthTree: Tree, distTree: Tree) - -- build fixed length tree - for i = 0, 6 do - lengthTree.table[i] = 0 - end - lengthTree.table[7] = 24 - lengthTree.table[8] = 152 - lengthTree.table[9] = 112 - - for i = 0, 23 do - lengthTree.trans[i] = 256 + i - end - for i = 0, 143 do - lengthTree.trans[24 + i] = i - end - for i = 0, 7 do - lengthTree.trans[24 + 144 + i] = 280 + i - end - for i = 0, 111 do - lengthTree.trans[24 + 144 + 8 + i] = 144 + i - end - - -- build fixed distance tree - for i = 0, 4 do - distTree.table[i] = 0 - end - distTree.table[5] = 32 - - for i = 0, 31 do - distTree.trans[i] = i - end -end - -local offs = table.create(16, 0) - -local function buildTree(t: Tree, lengths: { number }, off: number, num: number) - -- clear code length count table - for i = 0, 15 do - t.table[i] = 0 - end - - -- scan symbol lengths, and sum code length counts - for i = 0, num - 1 do - t.table[lengths[off + i]] += 1 - end - - t.table[0] = 0 - - -- compute offset table for distribution sort - local sum = 0 - for i = 0, 15 do - offs[i] = sum - sum = sum + t.table[i] - end - - -- create code->symbol translation table - for i = 0, num - 1 do - local len = lengths[off + i] - if len > 0 then - t.trans[offs[len]] = i - offs[len] += 1 - end - end -end - -local function getBit(d: Data): number - if d.bitcount <= 0 then - d.tag = buffer.readu8(d.source, d.sourceIndex) - d.sourceIndex += 1 - d.bitcount = 8 - end - - local bit = bit32.band(d.tag, 1) - d.tag = bit32.rshift(d.tag, 1) - d.bitcount -= 1 - - return bit -end - -local function readBits(d: Data, num: number?, base: number): number - if not num then - return base - end - - while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do - d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) - d.sourceIndex += 1 - d.bitcount += 8 - end - - local val = bit32.band(d.tag, bit32.rshift(0xffff, 16 - num)) - d.tag = bit32.rshift(d.tag, num) - d.bitcount -= num - - return val + base -end - -local function decodeSymbol(d: Data, t: Tree): number - while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do - d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) - d.sourceIndex += 1 - d.bitcount += 8 - end - - local sum, cur, len = 0, 0, 0 - local tag = d.tag - - repeat - cur = 2 * cur + bit32.band(tag, 1) - tag = bit32.rshift(tag, 1) - len += 1 - sum += t.table[len] - cur -= t.table[len] - until cur < 0 - - d.tag = tag - d.bitcount -= len - - return t.trans[sum + cur] -end - -local function decodeTrees(d: Data, lengthTree: Tree, distTree: Tree) - local hlit = readBits(d, 5, 257) - local hdist = readBits(d, 5, 1) - local hclen = readBits(d, 4, 4) - - for i = 0, 18 do - lengths[i] = 0 - end - - for i = 0, hclen - 1 do - lengths[clcIndex[i + 1]] = readBits(d, 3, 0) - end - - buildTree(codeTree, lengths, 0, 19) - - local num = 0 - while num < hlit + hdist do - local sym = decodeSymbol(d, codeTree) - - if sym == 16 then - local prev = lengths[num - 1] - for _ = 1, readBits(d, 2, 3) do - lengths[num] = prev - num += 1 - end - elseif sym == 17 then - for _ = 1, readBits(d, 3, 3) do - lengths[num] = 0 - num += 1 - end - elseif sym == 18 then - for _ = 1, readBits(d, 7, 11) do - lengths[num] = 0 - num += 1 - end - else - lengths[num] = sym - num += 1 - end - end - - buildTree(lengthTree, lengths, 0, hlit) - buildTree(distTree, lengths, hlit, hdist) -end - -local function inflateBlockData(d: Data, lengthTree: Tree, distTree: Tree) - while true do - local sym = decodeSymbol(d, lengthTree) - - if sym == 256 then - return - end - - if sym < 256 then - buffer.writeu8(d.dest, d.destLen, sym) - d.destLen += 1 - else - sym -= 257 - - local length = readBits(d, lengthBits[sym], lengthBase[sym]) - local dist = decodeSymbol(d, distTree) - - local offs = d.destLen - readBits(d, distBits[dist], distBase[dist]) - - for i = offs, offs + length - 1 do - buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.dest, i)) - d.destLen += 1 - end - end - end -end - -local function inflateUncompressedBlock(d: Data) - while d.bitcount > 8 do - d.sourceIndex -= 1 - d.bitcount -= 8 - end - - local length = buffer.readu8(d.source, d.sourceIndex + 1) - length = 256 * length + buffer.readu8(d.source, d.sourceIndex) - - local invlength = buffer.readu8(d.source, d.sourceIndex + 3) - invlength = 256 * invlength + buffer.readu8(d.source, d.sourceIndex + 2) - - if length ~= bit32.bxor(invlength, 0xffff) then - error("Invalid block length") - end - - d.sourceIndex += 4 - - for _ = 1, length do - buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.source, d.sourceIndex)) - d.destLen += 1 - d.sourceIndex += 1 - end - - d.bitcount = 0 -end - -local function uncompress(source: buffer): buffer - local dest = buffer.create(buffer.len(source) * 4) - local d = Data.new(source, dest) - - repeat - local bfinal = getBit(d) - local btype = readBits(d, 2, 0) - - if btype == 0 then - inflateUncompressedBlock(d) - elseif btype == 1 then - inflateBlockData(d, staticLengthTree, staticDistTree) - elseif btype == 2 then - decodeTrees(d, d.ltree, d.dtree) - inflateBlockData(d, d.ltree, d.dtree) - else - error("Invalid block type") - end - until bfinal == 1 - - if d.destLen < buffer.len(dest) then - local result = buffer.create(d.destLen) - buffer.copy(result, 0, dest, 0, d.destLen) - return result - end - - return dest -end - --- Initialize static trees and tables -buildFixedTrees(staticLengthTree, staticDistTree) -buildBitsBase(lengthBits, lengthBase, 4, 3) -buildBitsBase(distBits, distBase, 2, 1) -lengthBits[28] = 0 -lengthBase[28] = 258 - -return uncompress + +local codeTree = Tree.new() +local lengths = table.create(288 + 32, 0) + +local function buildBitsBase(bits: { number }, base: { number }, delta: number, first: number) + local sum = first + + -- build bits table + for i = 0, delta - 1 do + bits[i] = 0 + end + for i = 0, 29 - delta do + bits[i + delta] = math.floor(i / delta) + end + + -- build base table + for i = 0, 29 do + base[i] = sum + sum = sum + bit32.lshift(1, bits[i]) + end +end + +local function buildFixedTrees(lengthTree: Tree, distTree: Tree) + -- build fixed length tree + for i = 0, 6 do + lengthTree.table[i] = 0 + end + lengthTree.table[7] = 24 + lengthTree.table[8] = 152 + lengthTree.table[9] = 112 + + for i = 0, 23 do + lengthTree.trans[i] = 256 + i + end + for i = 0, 143 do + lengthTree.trans[24 + i] = i + end + for i = 0, 7 do + lengthTree.trans[24 + 144 + i] = 280 + i + end + for i = 0, 111 do + lengthTree.trans[24 + 144 + 8 + i] = 144 + i + end + + -- build fixed distance tree + for i = 0, 4 do + distTree.table[i] = 0 + end + distTree.table[5] = 32 + + for i = 0, 31 do + distTree.trans[i] = i + end +end + +local offs = table.create(16, 0) + +local function buildTree(t: Tree, lengths: { number }, off: number, num: number) + -- clear code length count table + for i = 0, 15 do + t.table[i] = 0 + end + + -- scan symbol lengths, and sum code length counts + for i = 0, num - 1 do + t.table[lengths[off + i]] += 1 + end + + t.table[0] = 0 + + -- compute offset table for distribution sort + local sum = 0 + for i = 0, 15 do + offs[i] = sum + sum = sum + t.table[i] + end + + -- create code->symbol translation table + for i = 0, num - 1 do + local len = lengths[off + i] + if len > 0 then + t.trans[offs[len]] = i + offs[len] += 1 + end + end +end + +local function getBit(d: Data): number + if d.bitcount <= 0 then + d.tag = buffer.readu8(d.source, d.sourceIndex) + d.sourceIndex += 1 + d.bitcount = 8 + end + + local bit = bit32.band(d.tag, 1) + d.tag = bit32.rshift(d.tag, 1) + d.bitcount -= 1 + + return bit +end + +local function readBits(d: Data, num: number?, base: number): number + if not num then + return base + end + + while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do + d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) + d.sourceIndex += 1 + d.bitcount += 8 + end + + local val = bit32.band(d.tag, bit32.rshift(0xffff, 16 - num)) + d.tag = bit32.rshift(d.tag, num) + d.bitcount -= num + + return val + base +end + +local function decodeSymbol(d: Data, t: Tree): number + while d.bitcount < 24 and d.sourceIndex < buffer.len(d.source) do + d.tag = bit32.bor(d.tag, bit32.lshift(buffer.readu8(d.source, d.sourceIndex), d.bitcount)) + d.sourceIndex += 1 + d.bitcount += 8 + end + + local sum, cur, len = 0, 0, 0 + local tag = d.tag + + repeat + cur = 2 * cur + bit32.band(tag, 1) + tag = bit32.rshift(tag, 1) + len += 1 + sum += t.table[len] + cur -= t.table[len] + until cur < 0 + + d.tag = tag + d.bitcount -= len + + return t.trans[sum + cur] +end + +local function decodeTrees(d: Data, lengthTree: Tree, distTree: Tree) + local hlit = readBits(d, 5, 257) + local hdist = readBits(d, 5, 1) + local hclen = readBits(d, 4, 4) + + for i = 0, 18 do + lengths[i] = 0 + end + + for i = 0, hclen - 1 do + lengths[clcIndex[i + 1]] = readBits(d, 3, 0) + end + + buildTree(codeTree, lengths, 0, 19) + + local num = 0 + while num < hlit + hdist do + local sym = decodeSymbol(d, codeTree) + + if sym == 16 then + local prev = lengths[num - 1] + for _ = 1, readBits(d, 2, 3) do + lengths[num] = prev + num += 1 + end + elseif sym == 17 then + for _ = 1, readBits(d, 3, 3) do + lengths[num] = 0 + num += 1 + end + elseif sym == 18 then + for _ = 1, readBits(d, 7, 11) do + lengths[num] = 0 + num += 1 + end + else + lengths[num] = sym + num += 1 + end + end + + buildTree(lengthTree, lengths, 0, hlit) + buildTree(distTree, lengths, hlit, hdist) +end + +local function inflateBlockData(d: Data, lengthTree: Tree, distTree: Tree) + while true do + local sym = decodeSymbol(d, lengthTree) + + if sym == 256 then + return + end + + if sym < 256 then + buffer.writeu8(d.dest, d.destLen, sym) + d.destLen += 1 + else + sym -= 257 + + local length = readBits(d, lengthBits[sym], lengthBase[sym]) + local dist = decodeSymbol(d, distTree) + + local offs = d.destLen - readBits(d, distBits[dist], distBase[dist]) + + for i = offs, offs + length - 1 do + buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.dest, i)) + d.destLen += 1 + end + end + end +end + +local function inflateUncompressedBlock(d: Data) + while d.bitcount > 8 do + d.sourceIndex -= 1 + d.bitcount -= 8 + end + + local length = buffer.readu8(d.source, d.sourceIndex + 1) + length = 256 * length + buffer.readu8(d.source, d.sourceIndex) + + local invlength = buffer.readu8(d.source, d.sourceIndex + 3) + invlength = 256 * invlength + buffer.readu8(d.source, d.sourceIndex + 2) + + if length ~= bit32.bxor(invlength, 0xffff) then + error("Invalid block length") + end + + d.sourceIndex += 4 + + for _ = 1, length do + buffer.writeu8(d.dest, d.destLen, buffer.readu8(d.source, d.sourceIndex)) + d.destLen += 1 + d.sourceIndex += 1 + end + + d.bitcount = 0 +end + +local function uncompress(source: buffer): buffer + local dest = buffer.create(buffer.len(source) * 4) + local d = Data.new(source, dest) + + repeat + local bfinal = getBit(d) + local btype = readBits(d, 2, 0) + + if btype == 0 then + inflateUncompressedBlock(d) + elseif btype == 1 then + inflateBlockData(d, staticLengthTree, staticDistTree) + elseif btype == 2 then + decodeTrees(d, d.ltree, d.dtree) + inflateBlockData(d, d.ltree, d.dtree) + else + error("Invalid block type") + end + until bfinal == 1 + + if d.destLen < buffer.len(dest) then + local result = buffer.create(d.destLen) + buffer.copy(result, 0, dest, 0, d.destLen) + return result + end + + return dest +end + +-- Initialize static trees and tables +buildFixedTrees(staticLengthTree, staticDistTree) +buildBitsBase(lengthBits, lengthBase, 4, 3) +buildBitsBase(distBits, distBase, 2, 1) +lengthBits[28] = 0 +lengthBase[28] = 258 + +return uncompress diff --git a/lib/init.luau b/lib/init.luau index 6350671..13ffcf9 100644 --- a/lib/init.luau +++ b/lib/init.luau @@ -1,51 +1,51 @@ -local inflate = require("./inflate") -local crc32 = require("./crc") - --- Little endian constant signatures used in the ZIP file format -local SIGNATURES = table.freeze({ - -- Marks the beginning of each file in the ZIP - LOCAL_FILE = 0x04034b50, - -- Marks entries in the central directory - CENTRAL_DIR = 0x02014b50, - -- Marks the end of the central directory - END_OF_CENTRAL_DIR = 0x06054b50, -}) - -type CrcValidationOptions = { - skip: boolean, - expected: number, -} - -local function validateCrc(decompressed: buffer, validation: CrcValidationOptions) - -- Unless skipping validation is requested, we verify the checksum - if validation.skip then - local computed = crc32(decompressed) - assert( - validation.expected == computed, - `Validation failed; CRC checksum does not match: {string.format("%x", computed)} ~= {string.format( - "%x", - computed - )} (expected ~= got)` - ) - end -end - -local DECOMPRESSION_ROUTINES: { [number]: (buffer, validation: CrcValidationOptions) -> buffer } = table.freeze({ - [0x00] = function(buf, validation) - validateCrc(buf, validation) - return buf - end, - [0x08] = function(buf, validation) - local decompressed = inflate(buf) - validateCrc(decompressed, validation) - return decompressed - end, -}) - --- TODO: ERROR HANDLING! - -local ZipEntry = {} -export type ZipEntry = typeof(setmetatable({} :: ZipEntryInner, { __index = ZipEntry })) +local inflate = require("./inflate") +local crc32 = require("./crc") + +-- Little endian constant signatures used in the ZIP file format +local SIGNATURES = table.freeze({ + -- Marks the beginning of each file in the ZIP + LOCAL_FILE = 0x04034b50, + -- Marks entries in the central directory + CENTRAL_DIR = 0x02014b50, + -- Marks the end of the central directory + END_OF_CENTRAL_DIR = 0x06054b50, +}) + +type CrcValidationOptions = { + skip: boolean, + expected: number, +} + +local function validateCrc(decompressed: buffer, validation: CrcValidationOptions) + -- Unless skipping validation is requested, we verify the checksum + if validation.skip then + local computed = crc32(decompressed) + assert( + validation.expected == computed, + `Validation failed; CRC checksum does not match: {string.format("%x", computed)} ~= {string.format( + "%x", + computed + )} (expected ~= got)` + ) + end +end + +local DECOMPRESSION_ROUTINES: { [number]: (buffer, validation: CrcValidationOptions) -> buffer } = table.freeze({ + [0x00] = function(buf, validation) + validateCrc(buf, validation) + return buf + end, + [0x08] = function(buf, validation) + local decompressed = inflate(buf) + validateCrc(decompressed, validation) + return decompressed + end, +}) + +-- TODO: ERROR HANDLING! + +local ZipEntry = {} +export type ZipEntry = typeof(setmetatable({} :: ZipEntryInner, { __index = ZipEntry })) -- stylua: ignore type ZipEntryInner = { name: string, -- File path within ZIP, '/' suffix indicates directory @@ -57,37 +57,37 @@ type ZipEntryInner = { parent: ZipEntry?, -- The parent of the current entry, nil for root children: { ZipEntry }, -- The children of the entry } - -function ZipEntry.new(name: string, size: number, offset: number, timestamp: number, crc: number): ZipEntry - return setmetatable( - { - name = name, - size = size, - offset = offset, - timestamp = timestamp, - crc = crc, - isDirectory = string.sub(name, -1) == "/", - parent = nil, - children = {}, - } :: ZipEntryInner, - { __index = ZipEntry } - ) -end - -function ZipEntry.getPath(self: ZipEntry): string - local path = self.name - local current = self.parent - - while current and current.name ~= "/" do - path = current.name .. path - current = current.parent - end - - return path -end - -local ZipReader = {} -export type ZipReader = typeof(setmetatable({} :: ZipReaderInner, { __index = ZipReader })) + +function ZipEntry.new(name: string, size: number, offset: number, timestamp: number, crc: number): ZipEntry + return setmetatable( + { + name = name, + size = size, + offset = offset, + timestamp = timestamp, + crc = crc, + isDirectory = string.sub(name, -1) == "/", + parent = nil, + children = {}, + } :: ZipEntryInner, + { __index = ZipEntry } + ) +end + +function ZipEntry.getPath(self: ZipEntry): string + local path = self.name + local current = self.parent + + while current and current.name ~= "/" do + path = current.name .. path + current = current.parent + end + + return path +end + +local ZipReader = {} +export type ZipReader = typeof(setmetatable({} :: ZipReaderInner, { __index = ZipReader })) -- stylua: ignore type ZipReaderInner = { data: buffer, -- The buffer containing the raw bytes of the ZIP @@ -95,296 +95,298 @@ type ZipReaderInner = { directories: { [string]: ZipEntry }, -- The directories and their respective entries root: ZipEntry, -- The entry of the root directory } - -function ZipReader.new(data): ZipReader - local root = ZipEntry.new("/", 0, 0, 0, 0) - root.isDirectory = true - - local this = setmetatable( - { - data = data, - entries = {}, - directories = {}, - root = root, - } :: ZipReaderInner, - { __index = ZipReader } - ) - - this:parseCentralDirectory() - this:buildDirectoryTree() - return this -end - -function ZipReader.parseCentralDirectory(self: ZipReader): () - -- ZIP files are read from the end, starting with the End of Central Directory record - -- The EoCD is at least 22 bytes and contains pointers to the rest of the ZIP structure - local bufSize = buffer.len(self.data) - local pos = bufSize - 22 - - -- Search backwards for the EoCD signature - while pos > 0 do - -- Read 4 bytes as uint32 in little-endian format - if buffer.readu32(self.data, pos) == SIGNATURES.END_OF_CENTRAL_DIR then - break - end - pos = pos - 1 - end - - -- Central Directory offset is stored 16 bytes into the EoCD record - local cdOffset = buffer.readu32(self.data, pos + 16) - -- Number of entries is stored 10 bytes into the EoCD record - local cdEntries = buffer.readu16(self.data, pos + 10) - - -- Process each entry in the Central Directory - pos = cdOffset - for i = 1, cdEntries do - -- Central Directory Entry format: - -- Offset Bytes Description - -- ------------------------------------------------ - -- 0 4 Central directory entry signature - -- 28 2 File name length (n) - -- 30 2 Extra field length (m) - -- 32 2 Comment length (k) - -- 12 4 Last mod time/date - -- 16 4 CRC-32 - -- 24 4 Uncompressed size - -- 42 4 Local header offset - -- 46 n File name - -- 46+n m Extra field - -- 46+n+m k Comment - - local nameLength = buffer.readu16(self.data, pos + 28) - local extraLength = buffer.readu16(self.data, pos + 30) - local commentLength = buffer.readu16(self.data, pos + 32) - local timestamp = buffer.readu32(self.data, pos + 12) - local crc = buffer.readu32(self.data, pos + 16) - local size = buffer.readu32(self.data, pos + 24) - local offset = buffer.readu32(self.data, pos + 42) - - local nameBuffer = buffer.create(nameLength) - buffer.copy(nameBuffer, 0, self.data, pos + 46, nameLength) - local name = buffer.tostring(nameBuffer) - - local entry = ZipEntry.new(name, size, offset, timestamp, crc) - table.insert(self.entries, entry) - - pos = pos + 46 + nameLength + extraLength + commentLength - end -end - -function ZipReader.buildDirectoryTree(self: ZipReader): () - for _, entry in self.entries do - local parts = {} - -- Split entry path into individual components - -- e.g. "folder/subfolder/file.txt" -> {"folder", "subfolder", "file.txt"} - for part in string.gmatch(entry.name, "([^/]+)/?") do - table.insert(parts, part) - end - - -- Start from root directory - local current = self.root - local path = "" - - -- Process each path component - for i, part in parts do - path ..= part - if i < #parts then - -- Create missing directory entries for intermediate paths - if not self.directories[path] then - local dir = ZipEntry.new(path, 0, 0, entry.timestamp, 0) - dir.isDirectory = true - dir.parent = current - - -- Track directory in both lookup table and parent's children - self.directories[path] = dir - table.insert(current.children, dir) - end - - -- Move deeper into the tree - current = self.directories[path] - continue - end - - -- Link file entry to its parent directory - entry.parent = current - table.insert(current.children, entry) - end - end -end - -function ZipReader.findEntry(self: ZipReader, path: string): ZipEntry - if path == "/" then - -- If the root directory's entry was requested we do not - -- need to do any additional work - return self.root - end - - -- Normalize path by removing leading and trailing slashes - -- This ensures consistent lookup regardless of input format - -- e.g., "/folder/file.txt/" -> "folder/file.txt" - path = string.gsub(path, "^/", ""):gsub("/$", "") - - -- First check regular files and explicit directories - for _, entry in self.entries do - -- Compare normalized paths - if string.gsub(entry.name, "/$", "") == path then - return entry - end - end - - -- If not found, check virtual directory entries - -- These are directories that were created implicitly - return self.directories[path] -end - -type ExtractionOptions = { - decompress: boolean?, - isString: boolean?, - skipValidation: boolean?, -} -function ZipReader.extract(self: ZipReader, entry: ZipEntry, options: ExtractionOptions?): buffer | string - -- Local File Header format: - -- Offset Bytes Description - -- 0 4 Local file header signature - -- 8 2 Compression method (8 = DEFLATE) - -- 14 4 CRC32 checksume - -- 18 4 Compressed size - -- 22 4 Uncompressed size - -- 26 2 File name length (n) - -- 28 2 Extra field length (m) - -- 30 n File name - -- 30+n m Extra field - -- 30+n+m - File data - - if entry.isDirectory then - error("Cannot extract directory") - end - - local defaultOptions: ExtractionOptions = { - decompress = true, - isString = false, - skipValidation = false, - } - - -- TODO: Use a `Partial` type function for this in the future! - local optionsOrDefault: { - decompress: boolean, - isString: boolean, - skipValidation: boolean - } = if options then setmetatable(options, { __index = defaultOptions }) :: any else defaultOptions - - local pos = entry.offset - if buffer.readu32(self.data, pos) ~= SIGNATURES.LOCAL_FILE then - error("Invalid local file header") - end - - local crcChecksum = buffer.readu32(self.data, pos + 14) - local compressedSize = buffer.readu32(self.data, pos + 18) - local uncompressedSize = buffer.readu32(self.data, pos + 22) - local nameLength = buffer.readu16(self.data, pos + 26) - local extraLength = buffer.readu16(self.data, pos + 28) - - pos = pos + 30 + nameLength + extraLength - - local content = buffer.create(compressedSize) - buffer.copy(content, 0, self.data, pos, compressedSize) - - if optionsOrDefault.decompress then - local compressionMethod = buffer.readu16(self.data, entry.offset + 8) - local decompress = DECOMPRESSION_ROUTINES[compressionMethod] - if decompress == nil then - error(`Unsupported compression, ID: {compressionMethod}`) - end - - content = decompress(content, { - expected = crcChecksum, - skip = optionsOrDefault.skipValidation, - }) - - -- Unless skipping validation is requested, we make sure the uncompressed size matches - assert( - optionsOrDefault.skipValidation or uncompressedSize == buffer.len(content), - "Validation failed; uncompressed size does not match" - ) - end - - return if optionsOrDefault.isString then buffer.tostring(content) else content -end - -function ZipReader.extractDirectory( - self: ZipReader, - path: string, - options: ExtractionOptions -): { [string]: buffer } | { [string]: string } - local files: { [string]: buffer } | { [string]: string } = {} - -- Normalize path by removing leading slash for consistent prefix matching - path = string.gsub(path, "^/", "") - - -- Iterate through all entries to find files within target directory - for _, entry in self.entries do - -- Check if entry is a file (not directory) and its path starts with target directory - if not entry.isDirectory and string.sub(entry.name, 1, #path) == path then - -- Store extracted content mapped to full path - files[entry.name] = self:extract(entry, options) - end - end - - -- Return a map of file to contents - return files -end - -function ZipReader.listDirectory(self: ZipReader, path: string): { ZipEntry } - -- Locate the entry with the path - local entry = self:findEntry(path) - if not entry or not entry.isDirectory then - -- If an entry was not found, we error - error("Not a directory") - end - - -- Return the children of our discovered entry - return entry.children -end - -function ZipReader.walk(self: ZipReader, callback: (entry: ZipEntry, depth: number) -> ()): () - -- Wrapper function which recursively calls callback for every child - -- in an entry - local function walkEntry(entry: ZipEntry, depth: number) - callback(entry, depth) - - for _, child in entry.children do - -- ooo spooky recursion... blame this if shit go wrong - walkEntry(child, depth + 1) - end - end - - walkEntry(self.root, 0) -end - -export type ZipStatistics = { fileCount: number, dirCount: number, totalSize: number } -function ZipReader.getStats(self: ZipReader): ZipStatistics - local stats: ZipStatistics = { - fileCount = 0, - dirCount = 0, - totalSize = 0, - } - - -- Iterate through the entries, updating stats - for _, entry in self.entries do - if entry.isDirectory then - stats.dirCount = stats.dirCount + 1 - continue - end - - stats.fileCount = stats.fileCount + 1 - stats.totalSize = stats.totalSize + entry.size - end - - return stats -end - -return { - -- Creates a `ZipReader` from a `buffer` of ZIP data. - load = function(data: buffer) - return ZipReader.new(data) - end, -} + +function ZipReader.new(data): ZipReader + local root = ZipEntry.new("/", 0, 0, 0, 0) + root.isDirectory = true + + local this = setmetatable( + { + data = data, + entries = {}, + directories = {}, + root = root, + } :: ZipReaderInner, + { __index = ZipReader } + ) + + this:parseCentralDirectory() + this:buildDirectoryTree() + return this +end + +function ZipReader.parseCentralDirectory(self: ZipReader): () + -- ZIP files are read from the end, starting with the End of Central Directory record + -- The EoCD is at least 22 bytes and contains pointers to the rest of the ZIP structure + local bufSize = buffer.len(self.data) + local pos = bufSize - 22 + + -- Search backwards for the EoCD signature + while pos > 0 do + -- Read 4 bytes as uint32 in little-endian format + if buffer.readu32(self.data, pos) == SIGNATURES.END_OF_CENTRAL_DIR then + break + end + pos = pos - 1 + end + + -- Central Directory offset is stored 16 bytes into the EoCD record + local cdOffset = buffer.readu32(self.data, pos + 16) + -- Number of entries is stored 10 bytes into the EoCD record + local cdEntries = buffer.readu16(self.data, pos + 10) + + -- Process each entry in the Central Directory + pos = cdOffset + for i = 1, cdEntries do + -- Central Directory Entry format: + -- Offset Bytes Description + -- ------------------------------------------------ + -- 0 4 Central directory entry signature + -- 28 2 File name length (n) + -- 30 2 Extra field length (m) + -- 32 2 Comment length (k) + -- 12 4 Last mod time/date + -- 16 4 CRC-32 + -- 24 4 Uncompressed size + -- 42 4 Local header offset + -- 46 n File name + -- 46+n m Extra field + -- 46+n+m k Comment + + local nameLength = buffer.readu16(self.data, pos + 28) + local extraLength = buffer.readu16(self.data, pos + 30) + local commentLength = buffer.readu16(self.data, pos + 32) + local timestamp = buffer.readu32(self.data, pos + 12) + local crc = buffer.readu32(self.data, pos + 16) + local size = buffer.readu32(self.data, pos + 24) + local offset = buffer.readu32(self.data, pos + 42) + + local nameBuffer = buffer.create(nameLength) + buffer.copy(nameBuffer, 0, self.data, pos + 46, nameLength) + local name = buffer.tostring(nameBuffer) + + local entry = ZipEntry.new(name, size, offset, timestamp, crc) + table.insert(self.entries, entry) + + pos = pos + 46 + nameLength + extraLength + commentLength + end +end + +function ZipReader.buildDirectoryTree(self: ZipReader): () + for _, entry in self.entries do + local parts = {} + -- Split entry path into individual components + -- e.g. "folder/subfolder/file.txt" -> {"folder", "subfolder", "file.txt"} + for part in string.gmatch(entry.name, "([^/]+)/?") do + table.insert(parts, part) + end + + -- Start from root directory + local current = self.root + local path = "" + + -- Process each path component + for i, part in parts do + path ..= part + if i < #parts then + -- Create missing directory entries for intermediate paths + if not self.directories[path] then + local dir = ZipEntry.new(path, 0, 0, entry.timestamp, 0) + dir.isDirectory = true + dir.parent = current + + -- Track directory in both lookup table and parent's children + self.directories[path] = dir + table.insert(current.children, dir) + end + + -- Move deeper into the tree + current = self.directories[path] + continue + end + + -- Link file entry to its parent directory + entry.parent = current + table.insert(current.children, entry) + end + end +end + +function ZipReader.findEntry(self: ZipReader, path: string): ZipEntry + if path == "/" then + -- If the root directory's entry was requested we do not + -- need to do any additional work + return self.root + end + + -- Normalize path by removing leading and trailing slashes + -- This ensures consistent lookup regardless of input format + -- e.g., "/folder/file.txt/" -> "folder/file.txt" + path = string.gsub(path, "^/", ""):gsub("/$", "") + + -- First check regular files and explicit directories + for _, entry in self.entries do + -- Compare normalized paths + if string.gsub(entry.name, "/$", "") == path then + return entry + end + end + + -- If not found, check virtual directory entries + -- These are directories that were created implicitly + return self.directories[path] +end + +type ExtractionOptions = { + decompress: boolean?, + isString: boolean?, + skipValidation: boolean?, +} +function ZipReader.extract(self: ZipReader, entry: ZipEntry, options: ExtractionOptions?): buffer | string + -- Local File Header format: + -- Offset Bytes Description + -- 0 4 Local file header signature + -- 8 2 Compression method (8 = DEFLATE) + -- 14 4 CRC32 checksume + -- 18 4 Compressed size + -- 22 4 Uncompressed size + -- 26 2 File name length (n) + -- 28 2 Extra field length (m) + -- 30 n File name + -- 30+n m Extra field + -- 30+n+m - File data + + if entry.isDirectory then + error("Cannot extract directory") + end + + local defaultOptions: ExtractionOptions = { + decompress = true, + isString = false, + skipValidation = false, + } + + -- TODO: Use a `Partial` type function for this in the future! + local optionsOrDefault: { + decompress: boolean, + isString: boolean, + skipValidation: boolean, + } = if options + then setmetatable(options, { __index = defaultOptions }) :: any + else defaultOptions + + local pos = entry.offset + if buffer.readu32(self.data, pos) ~= SIGNATURES.LOCAL_FILE then + error("Invalid local file header") + end + + local crcChecksum = buffer.readu32(self.data, pos + 14) + local compressedSize = buffer.readu32(self.data, pos + 18) + local uncompressedSize = buffer.readu32(self.data, pos + 22) + local nameLength = buffer.readu16(self.data, pos + 26) + local extraLength = buffer.readu16(self.data, pos + 28) + + pos = pos + 30 + nameLength + extraLength + + local content = buffer.create(compressedSize) + buffer.copy(content, 0, self.data, pos, compressedSize) + + if optionsOrDefault.decompress then + local compressionMethod = buffer.readu16(self.data, entry.offset + 8) + local decompress = DECOMPRESSION_ROUTINES[compressionMethod] + if decompress == nil then + error(`Unsupported compression, ID: {compressionMethod}`) + end + + content = decompress(content, { + expected = crcChecksum, + skip = optionsOrDefault.skipValidation, + }) + + -- Unless skipping validation is requested, we make sure the uncompressed size matches + assert( + optionsOrDefault.skipValidation or uncompressedSize == buffer.len(content), + "Validation failed; uncompressed size does not match" + ) + end + + return if optionsOrDefault.isString then buffer.tostring(content) else content +end + +function ZipReader.extractDirectory( + self: ZipReader, + path: string, + options: ExtractionOptions +): { [string]: buffer } | { [string]: string } + local files: { [string]: buffer } | { [string]: string } = {} + -- Normalize path by removing leading slash for consistent prefix matching + path = string.gsub(path, "^/", "") + + -- Iterate through all entries to find files within target directory + for _, entry in self.entries do + -- Check if entry is a file (not directory) and its path starts with target directory + if not entry.isDirectory and string.sub(entry.name, 1, #path) == path then + -- Store extracted content mapped to full path + files[entry.name] = self:extract(entry, options) + end + end + + -- Return a map of file to contents + return files +end + +function ZipReader.listDirectory(self: ZipReader, path: string): { ZipEntry } + -- Locate the entry with the path + local entry = self:findEntry(path) + if not entry or not entry.isDirectory then + -- If an entry was not found, we error + error("Not a directory") + end + + -- Return the children of our discovered entry + return entry.children +end + +function ZipReader.walk(self: ZipReader, callback: (entry: ZipEntry, depth: number) -> ()): () + -- Wrapper function which recursively calls callback for every child + -- in an entry + local function walkEntry(entry: ZipEntry, depth: number) + callback(entry, depth) + + for _, child in entry.children do + -- ooo spooky recursion... blame this if shit go wrong + walkEntry(child, depth + 1) + end + end + + walkEntry(self.root, 0) +end + +export type ZipStatistics = { fileCount: number, dirCount: number, totalSize: number } +function ZipReader.getStats(self: ZipReader): ZipStatistics + local stats: ZipStatistics = { + fileCount = 0, + dirCount = 0, + totalSize = 0, + } + + -- Iterate through the entries, updating stats + for _, entry in self.entries do + if entry.isDirectory then + stats.dirCount = stats.dirCount + 1 + continue + end + + stats.fileCount = stats.fileCount + 1 + stats.totalSize = stats.totalSize + entry.size + end + + return stats +end + +return { + -- Creates a `ZipReader` from a `buffer` of ZIP data. + load = function(data: buffer) + return ZipReader.new(data) + end, +} diff --git a/pesde.lock b/pesde.lock index f0fbad0..4a45469 100644 --- a/pesde.lock +++ b/pesde.lock @@ -2,6 +2,80 @@ name = "0x5eal/unzip" version = "0.1.0" target = "luau" +[graph."jiwonz/dirs"."0.1.2 lune"] +resolved_ty = "standard" + +[graph."jiwonz/dirs"."0.1.2 lune".target] +environment = "lune" +lib = "src/init.luau" + +[graph."jiwonz/dirs"."0.1.2 lune".dependencies] +"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"] + +[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref] +ref_ty = "pesde" +name = "jiwonz/dirs" +version = "0.1.2" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.dependencies] +pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] + +[graph."jiwonz/dirs"."0.1.2 lune".pkg_ref.target] +environment = "lune" +lib = "src/init.luau" + +[graph."jiwonz/pathfs"."0.1.0 lune"] +resolved_ty = "standard" + +[graph."jiwonz/pathfs"."0.1.0 lune".target] +environment = "lune" +lib = "init.luau" + +[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref] +ref_ty = "pesde" +name = "jiwonz/pathfs" +version = "0.1.0" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."jiwonz/pathfs"."0.1.0 lune".pkg_ref.target] +environment = "lune" +lib = "init.luau" + +[graph."lukadev_0/option"."1.2.0 lune"] +resolved_ty = "standard" + +[graph."lukadev_0/option"."1.2.0 lune".target] +environment = "lune" +lib = "lib/init.luau" + +[graph."lukadev_0/option"."1.2.0 lune".pkg_ref] +ref_ty = "pesde" +name = "lukadev_0/option" +version = "1.2.0" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."lukadev_0/option"."1.2.0 lune".pkg_ref.target] +environment = "lune" +lib = "lib/init.luau" + +[graph."lukadev_0/result"."1.2.0 lune"] +resolved_ty = "standard" + +[graph."lukadev_0/result"."1.2.0 lune".target] +environment = "lune" +lib = "lib/init.luau" + +[graph."lukadev_0/result"."1.2.0 lune".pkg_ref] +ref_ty = "pesde" +name = "lukadev_0/result" +version = "1.2.0" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."lukadev_0/result"."1.2.0 lune".pkg_ref.target] +environment = "lune" +lib = "lib/init.luau" + [graph."lukadev_0/result"."1.2.0 luau"] direct = ["result", { name = "lukadev_0/result", version = "^1.2.0" }, "standard"] resolved_ty = "standard" @@ -19,3 +93,60 @@ index_url = "https://github.com/pesde-pkg/index" [graph."lukadev_0/result"."1.2.0 luau".pkg_ref.target] environment = "luau" lib = "lib/init.luau" + +[graph."pesde/stylua"."2.0.2 lune"] +direct = ["stylua", { name = "pesde/stylua", version = "^2.0.2", target = "lune" }, "dev"] +resolved_ty = "dev" + +[graph."pesde/stylua"."2.0.2 lune".target] +environment = "lune" +bin = "init.luau" + +[graph."pesde/stylua"."2.0.2 lune".dependencies] +"lukadev_0/option" = ["1.2.0 lune", "option"] +"lukadev_0/result" = ["1.2.0 lune", "result"] +"pesde/toolchainlib" = ["0.1.7 lune", "core"] + +[graph."pesde/stylua"."2.0.2 lune".pkg_ref] +ref_ty = "pesde" +name = "pesde/stylua" +version = "2.0.2" +index_url = "https://github.com/pesde-pkg/index" + +[graph."pesde/stylua"."2.0.2 lune".pkg_ref.dependencies] +core = [{ name = "pesde/toolchainlib", version = "^0.1.3", index = "https://github.com/daimond113/pesde-index", target = "lune" }, "standard"] +option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] +result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] + +[graph."pesde/stylua"."2.0.2 lune".pkg_ref.target] +environment = "lune" +bin = "init.luau" + +[graph."pesde/toolchainlib"."0.1.7 lune"] +resolved_ty = "standard" + +[graph."pesde/toolchainlib"."0.1.7 lune".target] +environment = "lune" +lib = "src/init.luau" + +[graph."pesde/toolchainlib"."0.1.7 lune".dependencies] +"jiwonz/dirs" = ["0.1.2 lune", "dirs"] +"jiwonz/pathfs" = ["0.1.0 lune", "pathfs"] +"lukadev_0/option" = ["1.2.0 lune", "option"] +"lukadev_0/result" = ["1.2.0 lune", "result"] + +[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref] +ref_ty = "pesde" +name = "pesde/toolchainlib" +version = "0.1.7" +index_url = "https://github.com/daimond113/pesde-index" + +[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref.dependencies] +dirs = [{ name = "jiwonz/dirs", version = "^0.1.1", index = "https://github.com/daimond113/pesde-index" }, "standard"] +option = [{ name = "lukadev_0/option", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"] +pathfs = [{ name = "jiwonz/pathfs", version = "^0.1.0", index = "https://github.com/daimond113/pesde-index" }, "standard"] +result = [{ name = "lukadev_0/result", version = "^1.2.0", index = "https://github.com/daimond113/pesde-index" }, "peer"] + +[graph."pesde/toolchainlib"."0.1.7 lune".pkg_ref.target] +environment = "lune" +lib = "src/init.luau" diff --git a/pesde.toml b/pesde.toml index a1ee59e..557e5e9 100644 --- a/pesde.toml +++ b/pesde.toml @@ -13,3 +13,6 @@ default = "https://github.com/pesde-pkg/index" [dependencies] result = { name = "lukadev_0/result", version = "^1.2.0" } + +[dev_dependencies] +stylua = { name = "pesde/stylua", version = "^2.0.2", target = "lune" }