diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f2ede5f1..5be6672d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,15 @@ FROM ubuntu:22.04 +ENV DEBIAN_FRONTEND=noninteractive + RUN apt-get update && apt-get install -y \ - luajit \ - libluajit-5.1-dev \ build-essential \ - git + cmake \ + ninja-build \ + curl \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://github.com/lde-org/lde/releases/download/nightly/lde-linux-x86-64 \ + -o /usr/local/bin/lde \ + && chmod +x /usr/local/bin/lde \ + && lde --setup diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 1e9a700f..72d4ba06 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -13,31 +13,18 @@ jobs: matrix: include: # - os: ubuntu-22.04 - # artifact: luajit-linux-x86-64-gnu # outfile: lde-linux-x86-64 - # sea_cc: gcc # - os: ubuntu-22.04-arm - # artifact: luajit-linux-aarch64-gnu # outfile: lde-linux-aarch64 - # sea_cc: gcc # - os: windows-latest - # artifact: luajit-windows-x86-64-gnu # outfile: lde-windows-x86-64 - # sea_cc: gcc # - os: windows-11-arm - # artifact: luajit-windows-aarch64-gnu # outfile: lde-windows-aarch64 - # sea_cc: clang # - os: macos-15 - # artifact: luajit-macos-aarch64 # outfile: lde-macos-aarch64 - # sea_cc: gcc # - os: macos-15-intel - # artifact: luajit-macos-x86-64 # outfile: lde-macos-x86-64 - # sea_cc: gcc - - os: ubuntu-22.04 - artifact: luajit-linux-x86-64-gnu + - os: ubuntu-22.04-arm outfile: lde-android-aarch64 android: true @@ -47,63 +34,39 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Cache LuaJIT - id: cache-luajit + - name: Fix workspace permissions (Android) + if: matrix.android + run: chmod -R a+rw ${{ github.workspace }} + + - name: Cache test image (Android) + if: matrix.android + id: cache-android-image uses: actions/cache@v5 with: - path: ${{ matrix.artifact }} - key: ${{ matrix.artifact }}-latest - - - name: Download LuaJIT - if: steps.cache-luajit.outputs.cache-hit != 'true' - run: | - curl -L -o luajit.tar.gz https://github.com/lde-org/lj-dist/releases/download/latest/${{ matrix.artifact }}.tar.gz - tar -xzf luajit.tar.gz + path: /tmp/termux-android.tar + key: termux-android-v3 - - name: Install llvm-mingw (Windows ARM64) - if: matrix.os == 'windows-11-arm' - shell: pwsh - run: | - curl -L -o llvm-mingw.zip https://github.com/mstorsjo/llvm-mingw/releases/download/20260311/llvm-mingw-20260311-ucrt-aarch64.zip - Expand-Archive -Path llvm-mingw.zip -DestinationPath . - echo "$env:GITHUB_WORKSPACE\llvm-mingw-20260311-ucrt-aarch64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Build lde (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - cd packages/lde - $env:BOOTSTRAP=1 - $env:SEA_CC="${{ matrix.sea_cc }}" - & "$env:GITHUB_WORKSPACE/${{ matrix.artifact }}/luajit.exe" ./src/init.lua compile --outfile ${{ matrix.outfile }}.exe - - - name: Set up Android NDK compiler + - name: Build lde (Android) if: matrix.android run: | - echo "SEA_CC=$ANDROID_NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang" >> $GITHUB_ENV + if [[ "${{ steps.cache-android-image.outputs.cache-hit }}" != "true" ]]; then + docker run --privileged --platform linux/arm64 \ + --name termux-build termux/termux-docker:aarch64 \ + bash -c "apt update && apt install -y luajit clang cmake ninja openssl git" + docker commit termux-build termux-android:latest + docker rm termux-build + docker save termux-android:latest -o /tmp/termux-android.tar + fi - - name: Build lde (Unix) - if: runner.os != 'Windows' - run: | - cd packages/lde - BOOTSTRAP=1 SEA_CC="${SEA_CC:-${{ matrix.sea_cc }}}" $GITHUB_WORKSPACE/${{ matrix.artifact }}/luajit ./src/init.lua compile --outfile ${{ matrix.outfile }} + docker load -i /tmp/termux-android.tar - - name: Upload nightly release asset (Windows) - if: runner.os == 'Windows' - uses: softprops/action-gh-release@v2 - with: - tag_name: nightly - name: Nightly Build - prerelease: true - body: | - Automated nightly build from master. - Commit: ${{ github.sha }} - Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - files: packages/lde/${{ matrix.outfile }}.exe - fail_on_unmatched_files: true + docker run --rm --privileged --platform linux/arm64 \ + -v ${{ github.workspace }}:/workspace \ + -w /workspace \ + termux-android:latest \ + bash -c "cd /workspace/packages/lde && BOOTSTRAP=1 luajit /workspace/packages/lde/src/init.lua compile --outfile /workspace/packages/lde/${{ matrix.outfile }}" - - name: Upload nightly release asset (Unix) - if: runner.os != 'Windows' + - name: Upload nightly release asset uses: softprops/action-gh-release@v2 with: tag_name: nightly diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8e053948..716c6fa6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -74,10 +74,6 @@ jobs: shell: pwsh run: echo "SEA_CC=aarch64-w64-mingw32-clang" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - name: Install macOS build deps - if: startsWith(matrix.os, 'macos') - run: brew install autoconf automake libtool - - uses: lde-org/setup-lde@master with: version: nightly diff --git a/AGENTS.md b/AGENTS.md index c6685149..1691a3e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ packages/ lde/ # The CLI binary itself (entry: src/init.lua) lde-core/ # Core library: Package, Lockfile, runtime, install logic lde-test/ # Built-in test framework - ansi/clap/env/fs/git/http/json/path/process2/semver/util/ # Internal packages + ansi/clap/env/fs/git/http/json/path/process/semver/util/ # Internal packages sea/ # Single-executable assembly (compiles bundles into binaries) archive/ # Archive extraction support luarocks/ # LuaRocks integration @@ -37,6 +37,7 @@ The key: **the require name is the key in `lde.json` `dependencies`, not the pac During `lde test`, the runner automatically exposes the package's `tests/` directory as `target/tests` (symlinked or copied). This means test files can `require("tests.lib.something")` to share helpers across test files. Example from `packages/lde/tests/main.test.lua`: + ```lua local ldecli = require("tests.lib.ldecli") ``` @@ -107,6 +108,7 @@ This outputs `packages/lde/lde` (or `lde.exe` on Windows). To install it, copy i ## Runtime Isolation (`lde-core.runtime`) `lde run` / `lde test` execute scripts in an isolated environment: + - `package.loaded` is cleared of non-builtins before and restored after each run. - A fresh `_G` metatable is created per execution (`setfenv`). - `ffi.cdef` is patched to silently ignore "attempt to redefine" errors (safe for repeated runs in the same process). @@ -116,16 +118,16 @@ This means multiple `lde run` calls in the same process don't pollute each other ## Key Packages -| Package | Purpose | -|---|---| -| `lde-core` | `Package`, `Lockfile`, install/build/run/test/compile logic | -| `lde-test` | Test framework (`require("lde-test")` in test files) | -| `clap` | CLI argument parsing (`args:option()`, `args:flag()`, `args:pop()`) | -| `ansi` | Colored terminal output (`ansi.printf("{red}msg")`) | -| `fs` | Filesystem ops (`read`, `write`, `mkdir`, `mklink`, `scan`, `stat`) | -| `process2` | Process execution (`process.exec(bin, args, opts)`) | -| `sea` | Compiles a bundled Lua string + native libs into a self-contained binary | -| `env` | Env vars, cwd, `env.execPath()` | +| Package | Purpose | +| ---------- | ------------------------------------------------------------------------ | +| `lde-core` | `Package`, `Lockfile`, install/build/run/test/compile logic | +| `lde-test` | Test framework (`require("lde-test")` in test files) | +| `clap` | CLI argument parsing (`args:option()`, `args:flag()`, `args:pop()`) | +| `ansi` | Colored terminal output (`ansi.printf("{red}msg")`) | +| `fs` | Filesystem ops (`read`, `write`, `mkdir`, `mklink`, `scan`, `stat`) | +| `process` | Process execution (`process.exec(bin, args, opts)`) | +| `sea` | Compiles a bundled Lua string + native libs into a self-contained binary | +| `env` | Env vars, cwd, `env.execPath()` | ## Monorepo Conventions diff --git a/CLAUDE.md b/CLAUDE.md index 835c206a..e49b8254 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ packages/ lde/ # The CLI binary itself (entry: src/init.lua) lde-core/ # Core library: Package, Lockfile, runtime, install logic lde-test/ # Built-in test framework - ansi/clap/env/fs/git/http/json/path/process2/semver/util/ # Internal packages + ansi/clap/env/fs/git/http/json/path/process/semver/util/ # Internal packages sea/ # Single-executable assembly (compiles bundles into binaries) archive/ # Archive extraction support luarocks/ # LuaRocks integration @@ -213,7 +213,7 @@ args:drain(start) -- returns and removes all remaining args (or from i args:count() -- number of remaining args ``` -### `process2` +### `process` ```lua -- Blocking execution @@ -228,7 +228,7 @@ child:kill(force) -- opts: { cwd, env, stdin, stdout, stderr } -- stdout/stderr: "pipe" (default for exec), "inherit", "null" -process2.platform -- "linux", "darwin", "win32", "unix" +process.platform -- "linux", "darwin", "win32", "unix" ``` ### `env` diff --git a/benchmarks/lde.json b/benchmarks/lde.json index 93576ec8..2dcea32e 100644 --- a/benchmarks/lde.json +++ b/benchmarks/lde.json @@ -2,7 +2,7 @@ "name": "benchmarks", "version": "0.1.0", "dependencies": { - "process2": { "path": "../packages/process2" }, + "process": { "git": "https://github.com/lde-org/process" }, "ansi": { "path": "../packages/ansi" } } } diff --git a/benchmarks/src/init.lua b/benchmarks/src/init.lua index 4fe07985..1cb5e236 100644 --- a/benchmarks/src/init.lua +++ b/benchmarks/src/init.lua @@ -1,6 +1,6 @@ local ffi = require("ffi") -local process = require("process2") +local process = require("process") local ansi = require("ansi") ---@type fun(): number @@ -91,7 +91,8 @@ local function runBenchmarks(tool, tmpdir) bench("install busted (cold)", function() local code, _, stderr if tool == "lde" then - code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:busted" }, { stdout = "null" }) + code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:busted" }, + { stdout = "null" }) elseif tool == "luarocks" then code, _, stderr = process.exec("luarocks", { "--tree", tmpdir .. "/rocks", "install", "busted" }) elseif tool == "lx" then @@ -103,7 +104,8 @@ local function runBenchmarks(tool, tmpdir) bench("install busted (warm)", function() local code, _, stderr if tool == "lde" then - code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:busted" }, { stdout = "null" }) + code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:busted" }, + { stdout = "null" }) elseif tool == "luarocks" then code, _, stderr = process.exec("luarocks", { "--tree", tmpdir .. "/rocks", "install", "busted" }) elseif tool == "lx" then @@ -115,7 +117,8 @@ local function runBenchmarks(tool, tmpdir) bench("build C rock (luafilesystem)", function() local code, _, stderr if tool == "lde" then - code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:luafilesystem" }, { stdout = "null" }) + code, _, stderr = process.exec("lde", { "--tree", tmpdir .. "/lde", "install", "rocks:luafilesystem" }, + { stdout = "null" }) elseif tool == "luarocks" then code, _, stderr = process.exec("luarocks", { "--tree", tmpdir .. "/rocks", "install", "luafilesystem" }) elseif tool == "lx" then diff --git a/packages/archive/.gitignore b/packages/archive/.gitignore deleted file mode 100644 index 8a3d2033..00000000 --- a/packages/archive/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -/lde.lock \ No newline at end of file diff --git a/packages/archive/lde.json b/packages/archive/lde.json deleted file mode 100644 index bb814856..00000000 --- a/packages/archive/lde.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "archive", - "description": "Archive extraction library. Reads magic bytes to detect zip vs tar.", - "version": "0.1.0", - "dependencies": { - "fs": { "path": "../fs" }, - "path": { "path": "../path" }, - "deflate-sys": { "git": "https://github.com/lde-org/deflate-sys" } - }, - "devDependencies": { - "lde-test": { "path": "../lde-test" }, - "fs": { "path": "../fs" }, - "env": { "path": "../env" }, - "path": { "path": "../path" } - } -} diff --git a/packages/archive/src/init.lua b/packages/archive/src/init.lua deleted file mode 100644 index 42e745be..00000000 --- a/packages/archive/src/init.lua +++ /dev/null @@ -1,278 +0,0 @@ ----@diagnostic disable: assign-type-mismatch - -local ffi = require("ffi") -local buf = require("string.buffer") -local deflate = require("deflate-sys") -local fs = require("fs") -local path = require("path") - -ffi.cdef [[ - typedef struct __attribute__((packed)) { - uint32_t sig; uint16_t ver, flags, method, mtime, mdate; - uint32_t crc, compSize, uncompSize; - uint16_t nameLen, extraLen; - } ZipLocal; - - typedef struct __attribute__((packed)) { - uint32_t sig; uint16_t verMade, verNeed, flags, method, mtime, mdate; - uint32_t crc, compSize, uncompSize; - uint16_t nameLen, extraLen, commentLen, disk, iattr; - uint32_t eattr, offset; - } ZipCD; - - typedef struct __attribute__((packed)) { - uint32_t sig; uint16_t disk, diskCd, count, total; - uint32_t cdSize, cdOffset; - uint16_t commentLen; - } ZipEOCD; - - typedef struct __attribute__((packed)) { - char name[100], mode[8], uid[8], gid[8], size[12], mtime[12], - checksum[8], typeflag, linkname[100], magic[6], version[2], - uname[32], gname[32], devmajor[8], devminor[8], prefix[155], pad[12]; - } TarHeader; -]] - ----@class ZipLocal: ffi.cdata* ----@field sig number ----@field ver number ----@field flags number ----@field method number ----@field crc number ----@field compSize number ----@field uncompSize number ----@field nameLen number ----@field extraLen number - ----@class ZipCD: ffi.cdata* ----@field sig number ----@field crc number ----@field compSize number ----@field uncompSize number ----@field nameLen number ----@field extraLen number ----@field commentLen number ----@field method number ----@field offset number - ----@class ZipEOCD: ffi.cdata* ----@field sig number ----@field count number ----@field total number ----@field cdSize number ----@field cdOffset number - ----@class TarHeader: ffi.cdata* ----@field name string ----@field mode string ----@field size string ----@field mtime string ----@field checksum string ----@field typeflag number ----@field magic string ----@field version string - ----@type fun(...): ZipLocal -local ZipLocalT = ffi.typeof("ZipLocal") ----@type fun(...): ZipCD -local ZipCDT = ffi.typeof("ZipCD") ----@type fun(...): ZipEOCD -local ZipEOCDT = ffi.typeof("ZipEOCD") ----@type fun(): TarHeader -local TarHeaderT = ffi.typeof("TarHeader") - -local tarHeaderSize = ffi.sizeof("TarHeader") - ----@param base string ----@param name string ----@param content string -local function writeFile(base, name, content) - local out = path.join(base, name) - fs.mkdirAll(path.dirname(out)) - fs.write(out, content) -end - --- ── ZIP extract ─────────────────────────────────────────────────────────────── - ----@param data string ----@param toPath string ----@param strip boolean -local function zipExtract(data, toPath, strip) - local dptr = ffi.cast("const uint8_t *", data) - local eocdOff = #data - 22 - while eocdOff >= 0 and ffi.cast("ZipEOCD *", dptr + eocdOff).sig ~= 0x06054b50 do - eocdOff = eocdOff - 1 - end - assert(eocdOff >= 0, "ZIP: EOCD not found") - ---@type ZipEOCD - local eocd = ffi.cast("ZipEOCD *", dptr + eocdOff) - local cd = ffi.cast("const uint8_t *", dptr + eocd.cdOffset) - - for _ = 1, eocd.total do - ---@type ZipCD - local e = ffi.cast("ZipCD *", cd) - assert(e.sig == 0x02014b50, "ZIP: bad CD entry") - local name = ffi.string(cd + ffi.sizeof("ZipCD"), e.nameLen) - if strip then name = name:match("^[^/]*/(.+)") or name end - if name:sub(-1) ~= "/" then - ---@type ZipLocal - local lh = ffi.cast("ZipLocal *", dptr + e.offset) - local raw = ffi.string(dptr + e.offset + ffi.sizeof("ZipLocal") + lh.nameLen + lh.extraLen, e.compSize) - local content = e.method == 0 and raw or deflate.deflateDecompress(raw, e.uncompSize) - writeFile(toPath, name, content) - else - fs.mkdir(path.join(toPath, name)) - end - cd = cd + ffi.sizeof("ZipCD") + e.nameLen + e.extraLen + e.commentLen - end -end - --- ── ZIP save ────────────────────────────────────────────────────────────────── - ----@param files table ----@param toPath string -local function zipSave(files, toPath) - local out = buf.new() - local cdBuf = buf.new() - local offset, count = 0, 0 - - for name, content in pairs(files) do - local comp = deflate.deflateCompress(content, 6) - local crc = deflate.crc32(content) - - local lh = ZipLocalT(0x04034b50, 20, 0, 8, 0, 0, crc, #comp, #content, #name, 0) - out:putcdata(lh, ffi.sizeof(lh)); out:put(name, comp) - - local cd = ZipCDT(0x02014b50, 20, 20, 0, 8, 0, 0, crc, #comp, #content, #name, 0, 0, 0, 0, 0, offset) - cdBuf:putcdata(cd, ffi.sizeof(cd)); cdBuf:put(name) - - offset = offset + ffi.sizeof(lh) + #name + #comp - count = count + 1 - end - - local cdStr = cdBuf:tostring() - local eocd = ZipEOCDT(0x06054b50, 0, 0, count, count, #cdStr, offset, 0) - out:put(cdStr); out:putcdata(eocd, ffi.sizeof(eocd)) - return fs.write(toPath, out:tostring()) -end - --- ── TAR extract ─────────────────────────────────────────────────────────────── - ----@param data string ----@param toPath string ----@param strip boolean -local function tarExtract(data, toPath, strip) - local dptr = ffi.cast("const uint8_t *", data) - local pos = 0 - while pos + tarHeaderSize <= #data do - ---@type TarHeader - local h = ffi.cast("TarHeader *", dptr + pos) - if h.name[0] == 0 then break end - local name = ffi.string(h.name) - local size = tonumber(ffi.string(h.size, 11), 8) or 0 - pos = pos + tarHeaderSize - if strip then name = name:match("^[^/]*/(.+)") or name end - if h.typeflag == string.byte("5") or name:sub(-1) == "/" then - fs.mkdir(path.join(toPath, name)) - elseif h.typeflag == string.byte("0") or h.typeflag == 0 then - writeFile(toPath, name, ffi.string(dptr + pos, size)) - end - pos = pos + math.ceil(size / 512) * 512 - end -end - --- ── TAR save ───────────────────────────────────────────────────────────────── - ----@param files table ----@param toPath string -local function tarSave(files, toPath) - local out = buf.new() - for name, content in pairs(files) do - ---@type TarHeader - local h = TarHeaderT() - ffi.copy(h.name, name, math.min(#name, 100)) - ffi.copy(h.mode, "0000644\0", 8) - ffi.copy(h.size, string.format("%011o", #content), 11) - ffi.copy(h.mtime, "00000000000", 11) - ffi.copy(h.magic, "ustar", 5) - ffi.copy(h.version, "00", 2) - h.typeflag = string.byte("0") - local sum = 8 * 32 - local hp = ffi.cast("const uint8_t *", h) - for i = 0, tarHeaderSize - 1 do sum = sum + hp[i] end - ffi.copy(h.checksum, string.format("%06o\0 ", sum), 8) - out:putcdata(h, tarHeaderSize) - out:put(content) - local pad = (512 - (#content % 512)) % 512 - if pad > 0 then out:put(string.rep("\0", pad)) end - end - out:put(string.rep("\0", 1024)) - local tarData = out:tostring() - local final = toPath:match("%.tar%.gz$") and deflate.gzipCompress(tarData) or tarData - return fs.write(toPath, final) -end - --- ── Archive ─────────────────────────────────────────────────────────────────── - ----@class Archive ----@field _source string | table -local Archive = {} -Archive.__index = Archive - ----@class Archive.ExtractOptions ----@field stripComponents boolean? - ---- Create a new Archive. ---- Pass a file path string to decode, or a table of `{ [path] = content }` to encode. ----@param source string | table ----@return Archive -function Archive.new(source) - return setmetatable({ _source = source }, Archive) -end - ---- Extract the archive to the given output directory. ----@param toPath string ----@param opts Archive.ExtractOptions? ----@return boolean ok ----@return string? err -function Archive:extract(toPath, opts) - local src = self._source - if type(src) ~= "string" then return false, "extract() is only valid for file-backed archives" end - local f = io.open(src, "rb") - if not f then return false, "cannot open: " .. src end - local data = f:read("*a"); f:close() - local strip = opts and opts.stripComponents or false - fs.mkdir(toPath) - local ok, err = pcall(function() - if ffi.cast("const uint32_t *", data)[0] == 0x04034b50 then - zipExtract(data, toPath, strip) - else - local raw = data:sub(1, 2) == "\31\139" and deflate.gzipDecompress(data, math.max(#data * 10, 1024 * 1024)) or data - tarExtract(raw, toPath, strip) - end - end) - if not ok then return false, err end - return true -end - ---- Save the in-memory file table to an archive. ---- Infers format from extension: `.zip`, `.tar`, or `.tar.gz`. ----@param toPath string ----@return boolean ok ----@return string? err -function Archive:save(toPath) - local src = self._source - if type(src) ~= "table" then return false, "save() is only valid for table-backed archives" end - local isZip = toPath:match("%.zip$") - local isTar = toPath:match("%.tar") - if not isZip and not isTar then - return false, "cannot determine archive format from path (expected .zip or .tar.gz)" - end - local ok, err = pcall(function() - if isZip then zipSave(src, toPath) else tarSave(src, toPath) end - end) - if not ok then return false, err end - return true -end - -return Archive diff --git a/packages/archive/tests/archive.test.lua b/packages/archive/tests/archive.test.lua deleted file mode 100644 index e7ac8efa..00000000 --- a/packages/archive/tests/archive.test.lua +++ /dev/null @@ -1,153 +0,0 @@ -local test = require("lde-test") -local Archive = require("archive") -local fs = require("fs") -local env = require("env") -local path = require("path") - -local tmpBase = path.join(env.tmpdir(), "lde-archive-tests") -fs.rmdir(tmpBase) -fs.mkdir(tmpBase) - -local function tmp(name) - return path.join(tmpBase, name) -end - --- --- Archive.new --- - -test.it("Archive.new with string returns Archive", function() - local a = Archive.new("/some/path.tar.gz") - test.truthy(a) -end) - -test.it("Archive.new with table returns Archive", function() - local a = Archive.new({ ["hello.txt"] = "hello" }) - test.truthy(a) -end) - -test.it("extract fails when source is a table", function() - local a = Archive.new({ ["hello.txt"] = "hello" }) - local ok, err = a:extract(tmp("out-table")) - test.falsy(ok) - test.truthy(err) -end) - -test.it("save fails when source is a string", function() - local a = Archive.new("/some/path.tar.gz") - local ok, err = a:save(tmp("out.zip")) - test.falsy(ok) - test.truthy(err) -end) - -test.it("save fails for unknown extension", function() - local a = Archive.new({ ["hello.txt"] = "hello" }) - local ok, err = a:save(tmp("out.rar")) - test.falsy(ok) - test.truthy(err) -end) - -test.it("save encodes to .zip and files are extractable", function() - local zipPath = tmp("saved.zip") - local outDir = tmp("out-saved-zip") - fs.mkdir(outDir) - - local a = Archive.new({ ["hello.txt"] = "zip content" }) - local ok = a:save(zipPath) - test.truthy(ok) - test.truthy(fs.exists(zipPath)) - - local b = Archive.new(zipPath) - local ok2 = b:extract(outDir) - test.truthy(ok2) - test.equal(fs.read(path.join(outDir, "hello.txt")), "zip content") -end) - -test.it("save encodes to .tar.gz and files are extractable", function() - local tarPath = tmp("saved.tar.gz") - local outDir = tmp("out-saved-tar") - fs.mkdir(outDir) - - local a = Archive.new({ ["hello.txt"] = "tar content" }) - local ok = a:save(tarPath) - test.truthy(ok) - test.truthy(fs.exists(tarPath)) - - local b = Archive.new(tarPath) - local ok2 = b:extract(outDir) - test.truthy(ok2) - test.equal(fs.read(path.join(outDir, "hello.txt")), "tar content") -end) - -test.it("extracts a .tar archive", function() - local tarPath = tmp("test.tar") - local outDir = tmp("out-tar") - fs.mkdir(outDir) - - local a = Archive.new({ ["hello.txt"] = "tar content" }) - local ok = a:save(tarPath) - test.truthy(ok) - - local b = Archive.new(tarPath) - local ok2 = b:extract(outDir) - test.truthy(ok2) - test.truthy(fs.exists(path.join(outDir, "hello.txt"))) -end) - -test.it("extracts a .zip archive", function() - local zipPath = tmp("test2.zip") - local outDir = tmp("out-zip2") - fs.mkdir(outDir) - - local a = Archive.new({ ["hello.txt"] = "zip content" }) - local ok = a:save(zipPath) - test.truthy(ok) - - local b = Archive.new(zipPath) - local ok2 = b:extract(outDir) - test.truthy(ok2) - test.truthy(fs.exists(path.join(outDir, "hello.txt"))) -end) - -test.it("stripComponents strips top-level dir from zip", function() - local zipPath = tmp("strip.zip") - local outDir = tmp("out-strip-zip") - fs.mkdir(outDir) - - local a = Archive.new({ ["topdir/hello.txt"] = "stripped" }) - a:save(zipPath) - - local b = Archive.new(zipPath) - b:extract(outDir, { stripComponents = true }) - test.equal(fs.read(path.join(outDir, "hello.txt")), "stripped") -end) - --- regression: zips with no explicit directory entries (e.g. .src.rock files) --- must still extract deeply nested files by creating parent dirs recursively -test.it("extracts zip with deeply nested files and no explicit dir entries", function() - local zipPath = tmp("nested.zip") - local outDir = tmp("out-nested") - fs.mkdir(outDir) - - -- save creates file entries only, no dir entries — matches .src.rock behavior - local a = Archive.new({ ["a/b/c/deep.lua"] = "deep content" }) - a:save(zipPath) - - local b = Archive.new(zipPath) - local ok = b:extract(outDir) - test.truthy(ok) - test.equal(fs.read(path.join(outDir, "a/b/c/deep.lua")), "deep content") -end) - -test.it("stripComponents strips top-level dir from tar.gz", function() - local tarPath = tmp("strip.tar.gz") - local outDir = tmp("out-strip-tar") - fs.mkdir(outDir) - - local a = Archive.new({ ["topdir/hello.txt"] = "stripped" }) - a:save(tarPath) - - local b = Archive.new(tarPath) - b:extract(outDir, { stripComponents = true }) - test.equal(fs.read(path.join(outDir, "hello.txt")), "stripped") -end) diff --git a/packages/env/lde.json b/packages/env/lde.json deleted file mode 100644 index 9044a423..00000000 --- a/packages/env/lde.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "env", - "version": "0.1.0", - "dependencies": { - "path": { "path": "../path" } - } -} diff --git a/packages/env/src/init.lua b/packages/env/src/init.lua deleted file mode 100644 index 66799b5c..00000000 --- a/packages/env/src/init.lua +++ /dev/null @@ -1,38 +0,0 @@ -local path = require("path") - ----@class env.raw ----@field var fun(name: string): string? ----@field set fun(name: string, value: string?): boolean ----@field tmpdir fun(): string ----@field cwd fun(): string ----@field chdir fun(dir: string): boolean ----@field execPath fun(): string? - -local rawenv ---@type env.raw -if jit.os == "Windows" then - rawenv = require("env.raw.windows") -elseif jit.os == "Linux" then - rawenv = require("env.raw.linux") -elseif jit.os == "OSX" then - rawenv = require("env.raw.macos") -else - error("Unsupported OS: " .. jit.os) -end - ----@class env: env.raw -local env = {} - -for k, v in pairs(rawenv) do - env[k] = v -end - -local tmpCounter = 0 - ---- Returns a unique temporary file path. ---- Safe replacement for os.tmpname() which can segfault in compiled LuaJIT on Windows. -function env.tmpfile() - tmpCounter = tmpCounter + 1 - return path.join(env.tmpdir(), string.format("luaenv_%d_%d.tmp", os.clock() * 1000, tmpCounter)) -end - -return env diff --git a/packages/env/src/raw/linux.lua b/packages/env/src/raw/linux.lua deleted file mode 100644 index ddddcd4a..00000000 --- a/packages/env/src/raw/linux.lua +++ /dev/null @@ -1,20 +0,0 @@ -local ffi = require("ffi") - ----@class env.raw.linux: env.raw.posix -local env = require("env.raw.posix") - -ffi.cdef([[ - ssize_t readlink(const char* path, char* buf, size_t bufsiz); -]]) - -function env.execPath() - local buf = ffi.new("char[?]", 4096) - local len = ffi.C.readlink("/proc/self/exe", buf, 4096) - if len == -1 then - return nil - end - - return ffi.string(buf, len) -end - -return env diff --git a/packages/env/src/raw/macos.lua b/packages/env/src/raw/macos.lua deleted file mode 100644 index d02d04e9..00000000 --- a/packages/env/src/raw/macos.lua +++ /dev/null @@ -1,20 +0,0 @@ -local ffi = require("ffi") - ----@class env.raw.macos: env.raw.posix -local env = require("env.raw.posix") - -ffi.cdef([[ - int _NSGetExecutablePath(char* buf, uint32_t* bufsize); -]]) - -function env.execPath() - local size = ffi.new("uint32_t[1]", 4096) - local buf = ffi.new("char[?]", size[0]) - if ffi.C._NSGetExecutablePath(buf, size) ~= 0 then - return nil - end - - return ffi.string(buf) -end - -return env diff --git a/packages/env/src/raw/posix.lua b/packages/env/src/raw/posix.lua deleted file mode 100644 index bafe8111..00000000 --- a/packages/env/src/raw/posix.lua +++ /dev/null @@ -1,48 +0,0 @@ -local ffi = require("ffi") - -ffi.cdef([[ - char* getenv(const char* name); - int setenv(const char* name, const char* value, int overwrite); - char* getcwd(char* buf, size_t size); - int chdir(const char* path); -]]) - ----@class env.raw.posix -local env = {} - ----@param name string -function env.var(name) ---@return string? - local v = ffi.C.getenv(name) - if v == nil then - return nil - end - - return ffi.string(v) -end - ----@param name string ----@param value string -function env.set(name, value) ---@return boolean - return ffi.C.setenv(name, value, 1) == 0 -end - -function env.tmpdir() - return env.var("TMPDIR") or "/tmp" -end - -function env.cwd() - local buf = ffi.new("char[?]", 4096) - - local result = ffi.C.getcwd(buf, 4096) - if result == nil then - return nil - end - - return ffi.string(buf) -end - -function env.chdir(dir) ---@return boolean - return ffi.C.chdir(dir) == 0 -end - -return env diff --git a/packages/env/src/raw/windows.lua b/packages/env/src/raw/windows.lua deleted file mode 100644 index 2325d4c3..00000000 --- a/packages/env/src/raw/windows.lua +++ /dev/null @@ -1,85 +0,0 @@ -local ffi = require("ffi") - -ffi.cdef([[ - typedef void* HANDLE; - typedef uint32_t DWORD; - typedef uint16_t WORD; - typedef unsigned char BYTE; - typedef int BOOL; - - DWORD GetEnvironmentVariableA(const char* lpName, char* lpBuffer, DWORD nSize); - BOOL SetEnvironmentVariableA(const char* lpName, const char* lpValue); - DWORD GetCurrentDirectoryA(DWORD nBufferLength, char* lpBuffer); - BOOL SetCurrentDirectoryA(const char* lpPathName); - DWORD GetModuleFileNameA(void* hModule, char* lpFilename, DWORD nSize); - - int _putenv_s(const char* name, const char* value); -]]) - -local kernel32 = ffi.load("kernel32") - ----@class env.raw.windows -local env = {} - ----@param name string -function env.var(name) ---@return string? - local bufSize = 1024 - local buf = ffi.new("char[?]", bufSize) - local len = kernel32.GetEnvironmentVariableA(name, buf, bufSize) - - if len == 0 then - return nil - end - - if len > bufSize then - bufSize = len - buf = ffi.new("char[?]", bufSize) - len = kernel32.GetEnvironmentVariableA(name, buf, bufSize) - if len == 0 then - return nil - end - end - - return ffi.string(buf, len) -end - ----@param name string ----@param value string? -function env.set(name, value) ---@return boolean - local result = kernel32.SetEnvironmentVariableA(name, value) - -- Also update the CRT environment so os.getenv() stays in sync - ffi.C._putenv_s(name, value or "") - return result ~= 0 -end - -function env.tmpdir() - return env.var("TEMP") or env.var("TMP") or "C:\\Windows\\Temp" -end - -function env.cwd() - local buf = ffi.new("char[?]", 4096) - local len = kernel32.GetCurrentDirectoryA(4096, buf) - - if len == 0 then - return nil - end - - return ffi.string(buf, len) -end - -function env.chdir(dir) ---@return boolean - return kernel32.SetCurrentDirectoryA(dir) ~= 0 -end - -function env.execPath() - local buf = ffi.new("char[?]", 4096) - local len = kernel32.GetModuleFileNameA(nil, buf, 4096) - - if len == 0 then - return nil - end - - return ffi.string(buf, len) -end - -return env diff --git a/packages/ffix/.gitignore b/packages/ffix/.gitignore deleted file mode 100644 index 8a3d2033..00000000 --- a/packages/ffix/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -/lde.lock \ No newline at end of file diff --git a/packages/ffix/README.md b/packages/ffix/README.md deleted file mode 100644 index 96330ec4..00000000 --- a/packages/ffix/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# ffix - -This is a namespaced version of the `ffi` library for LuaJIT. - -It works by parsing your C code and individually renaming types and symbols to be namespaced to an `ffix.context()`. - -This solves the issue of ffi redefinition fears that are all too common with a large amount of ffi definitions in LuaJIT. - -## Usage - -``` -lde add ffix --git https://github.com/lde-org/lde -``` diff --git a/packages/ffix/lde.json b/packages/ffix/lde.json deleted file mode 100644 index 70505231..00000000 --- a/packages/ffix/lde.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "ffix", - "version": "0.1.0", - "dependencies": {} -} \ No newline at end of file diff --git a/packages/ffix/src/init.lua b/packages/ffix/src/init.lua deleted file mode 100644 index 0afab0ea..00000000 --- a/packages/ffix/src/init.lua +++ /dev/null @@ -1,184 +0,0 @@ -local ffi = require("ffi") - -local ffix = {} - -local Tokenizer = require("ffix.tokenizer") -local Parser = require("ffix.parser") -local Printer = require("ffix.printer") - ----@class ffix.Context ----@field private pfx string ----@field private names table -- original -> prefixed ----@field C table -- proxy for ffi.C; ctx.C.foo resolves to ffi.C[prefixed_name] -local Context = {} -Context.__index = Context - ----@param t ffix.c.Parser.Type ----@return ffix.c.Parser.Type -function Context:rewriteInlineType(t) - local result = { - qualifiers = t.qualifiers, - inline_kind = t.inline_kind, - inline_tag = t.inline_tag, - inline_attrs = t.inline_attrs, - pointer = t.pointer, - reference = t.reference - } - if t.inline_kind == "enum" then - result.inline_variants = t.inline_variants - else - local fields = {} - for _, f in ipairs(t.inline_fields) do - fields[#fields + 1] = { - type = self:rewriteType(f.type), - name = f.name, - array_size = f.array_size, - attrs = f - .attrs - } - end - result.inline_fields = fields - end - return result -end - ----@param t ffix.c.Parser.Type ----@return ffix.c.Parser.Type -function Context:rewriteType(t) - if t.inline_kind then - return self:rewriteInlineType(t) - end - - local name = t.name - - local kw, base = name:match("^(%a+) ([%a_][%w_]*)$") - if kw == "struct" or kw == "enum" or kw == "union" then - name = kw .. " " .. (self.names[base] or base) - else - name = self.names[name] or name - end - - return { qualifiers = t.qualifiers, name = name, pointer = t.pointer, reference = t.reference } -end - ----@param params ffix.c.Parser.Param[] ----@return ffix.c.Parser.Param[] -function Context:rewriteParams(params) - local out = {} - for _, p in ipairs(params) do - out[#out + 1] = { type = self:rewriteType(p.type), name = p.name } - end - - return out -end - ----@format disable-next ----@private ----@param node ffix.c.Parser.Node ----@return ffix.c.Parser.Node -function Context:rewriteNode(node) - local k = node.kind - local renamed = self.names[node.name] or node.name - - if k == "typedef_alias" then - return { kind = k, name = renamed, type = self:rewriteType(node.type) } - elseif k == "typedef_struct" then - local fields = {} - for _, f in ipairs(node.fields) do - fields[#fields + 1] = { type = self:rewriteType(f.type), name = f.name, array_size = f.array_size, attrs = f.attrs } - end - - return { kind = k, name = renamed, tag = node.tag and (self.names[node.tag] or node.tag), fields = fields, attrs = node.attrs } - elseif k == "typedef_enum" then - return { kind = k, name = renamed, tag = node.tag and (self.names[node.tag] or node.tag), variants = node.variants } - elseif k == "typedef_fnptr" then - return { kind = k, name = renamed, ret = self:rewriteType(node.ret), params = self:rewriteParams(node.params) } - elseif k == "fn_decl" then - return { kind = k, name = renamed, asm_name = node.asm_name or node.name, ret = self:rewriteType(node.ret), params = self:rewriteParams(node.params), attrs = node.attrs } - elseif k == "extern_var" then - return { kind = k, name = renamed, asm_name = node.name, type = self:rewriteType(node.type) } - end - - error("unknown node kind: " .. tostring(node.kind)) -end - ----@param code string -function Context:cdef(code) - local tokens = Tokenizer.new():tokenize(code) - local ok, nodes = Parser.new():parse(tokens) - if not ok then error("ffix: failed to parse cdef block") end - - -- first pass: register all declared names - for _, node in ipairs(nodes) do - if node.name then - self.names[node.name] = self.pfx .. "_" .. node.name - end - end - - -- second pass: rewrite and emit - local rewritten = {} - for _, node in ipairs(nodes) do - rewritten[#rewritten + 1] = self:rewriteNode(node) - end - - ffi.cdef(Printer.new():print(rewritten)) -end - ----@param typename string -function Context:new(typename, ...) - return ffi.new(self.names[typename] or typename, ...) -end - ----@param typename string -function Context:cast(typename, ...) - local base, tail = typename:match("^([%a_][%w_]*)(.*)") - if base and self.names[base] then typename = self.names[base] .. tail end - return ffi.cast(typename, ...) -end - ----@param typename string -function Context:typeof(typename) - return ffi.typeof(self.names[typename] or typename) -end - ----@param typename string -function Context:sizeof(typename) - return ffi.sizeof(self.names[typename] or typename) -end - ----@param lib string -function Context:load(lib) - return ffi.load(lib) -end - ----@param typename string ----@param mt table -function Context:metatype(typename, mt) - return ffi.metatype(self.names[typename] or typename, mt) -end - ----@param typename string -function Context:istype(typename, obj) - return ffi.istype(self.names[typename] or typename, obj) -end - ----@param ctx table -local function generatePrefix(ctx) - return string.format("%d%p", os.clock() * 1e6, ctx) -end - ----@param pfx string? -function ffix.context(pfx) - local ctx = setmetatable({ names = {} }, Context) - ctx.pfx = pfx or generatePrefix(ctx) - - ctx.C = setmetatable({}, { - __index = function(_, k) - return ffi.C[ctx.names[k] or k] - end - }) - - return ctx -end - -return ffix diff --git a/packages/ffix/src/parser.lua b/packages/ffix/src/parser.lua deleted file mode 100644 index f0830af1..00000000 --- a/packages/ffix/src/parser.lua +++ /dev/null @@ -1,453 +0,0 @@ ----@class ffix.c.Parser ----@field private ptr number ----@field private tokens ffix.c.Tokenizer.Token[] -local Parser = {} -Parser.__index = Parser - ----@class ffix.c.Parser.Type ----@field qualifiers string[] ----@field name string? ----@field inline_kind ("struct"|"union"|"enum")? ----@field inline_tag string? ----@field inline_fields ffix.c.Parser.Field[]? ----@field inline_variants ffix.c.Parser.Variant[]? ----@field inline_attrs ffix.c.Attr[]? ----@field pointer number ----@field reference boolean? - ----@class ffix.c.Attr ----@field name string ----@field args string? - ----@class ffix.c.Parser.Field ----@field type ffix.c.Parser.Type ----@field name string? ----@field array_size string? ----@field attrs ffix.c.Attr[]? - ----@class ffix.c.Parser.Variant ----@field name string - ----@class ffix.c.Parser.Param ----@field type ffix.c.Parser.Type ----@field name string? - ----@class ffix.c.Parser.Node.TypedefAlias ----@field kind "typedef_alias" ----@field type ffix.c.Parser.Type ----@field name string - ----@class ffix.c.Parser.Node.TypedefStruct ----@field kind "typedef_struct" ----@field tag string? ----@field fields ffix.c.Parser.Field[] ----@field name string ----@field attrs ffix.c.Attr[]? - ----@class ffix.c.Parser.Node.TypedefEnum ----@field kind "typedef_enum" ----@field tag string? ----@field variants ffix.c.Parser.Variant[] ----@field name string - ----@class ffix.c.Parser.Node.TypedefFnPtr ----@field kind "typedef_fnptr" ----@field ret ffix.c.Parser.Type ----@field name string ----@field params ffix.c.Parser.Param[] - ----@class ffix.c.Parser.Node.FnDecl ----@field kind "fn_decl" ----@field ret ffix.c.Parser.Type ----@field name string ----@field params ffix.c.Parser.Param[] ----@field asm_name string? ----@field attrs ffix.c.Attr[]? - ----@class ffix.c.Parser.Node.ExternVar ----@field kind "extern_var" ----@field type ffix.c.Parser.Type ----@field name string ----@field asm_name string? - ----@alias ffix.c.Parser.Node ---- | ffix.c.Parser.Node.TypedefAlias ---- | ffix.c.Parser.Node.TypedefStruct ---- | ffix.c.Parser.Node.TypedefEnum ---- | ffix.c.Parser.Node.TypedefFnPtr ---- | ffix.c.Parser.Node.FnDecl ---- | ffix.c.Parser.Node.ExternVar - -function Parser.new() - return setmetatable({}, Parser) -end - ----@return ffix.c.Tokenizer.Token? -function Parser:peek() - return self.tokens[self.ptr] -end - ----@return ffix.c.Tokenizer.Token? -function Parser:advance() - local tok = self.tokens[self.ptr] - if tok then self.ptr = self.ptr + 1 end - return tok -end - ----@param variant string ----@return ffix.c.Tokenizer.Token? -function Parser:consume(variant) - local tok = self.tokens[self.ptr] - if tok and tok.variant == variant then - self.ptr = self.ptr + 1 - return tok - end -end - ----@param variant string ----@return ffix.c.Tokenizer.Token -function Parser:expect(variant) - local tok = self:consume(variant) - if not tok then - local got = self.tokens[self.ptr] - error("expected '" .. variant .. "' got '" .. (got and got.variant or "EOF") .. "'") - end - return tok -end - -local type_quals = { const = true, volatile = true, restrict = true, unsigned = true, signed = true, long = true, short = true } -local base_types = { void = true, char = true, int = true, float = true, double = true } - ----@return ffix.c.Parser.Type -function Parser:parseType() - local quals = {} - local name - - while true do - local tok = self:peek() - if not tok then break end - - if type_quals[tok.variant] then - quals[#quals + 1] = tok.variant - self:advance() - elseif base_types[tok.variant] then - name = tok.variant - self:advance() - break - elseif tok.variant == "struct" or tok.variant == "enum" or tok.variant == "union" then - local kw = tok.variant - self:advance() - local tag_tok = self:consume("ident") - if self:peek() and self:peek().variant == "{" then - self:advance() - local inline_fields, inline_variants, inline_attrs - if kw == "enum" then - inline_variants = self:parseVariants() - else - inline_fields = self:parseFields() - inline_attrs = self:parseAttrs() - end - local pointer = 0 - while self:consume("*") do - pointer = pointer + 1 - while true do - local qtok = self:peek() - if qtok and (qtok.variant == "const" or qtok.variant == "volatile" or qtok.variant == "restrict") then - self:advance() - else break end - end - end - local reference = self:consume("&") ~= nil - return { - qualifiers = quals, - inline_kind = kw, - inline_tag = tag_tok and tag_tok.ident, - inline_fields = inline_fields, - inline_variants = inline_variants, - inline_attrs = inline_attrs, - pointer = pointer, - reference = reference or nil, - } - end - if not tag_tok then error("expected tag name or '{' after " .. kw) end - name = kw .. " " .. tag_tok.ident - break - elseif tok.variant == "ident" then - -- if we already have qualifiers (e.g. "unsigned long"), peek at the - -- token after this ident: if it looks like a declaration suffix - -- then this ident is a name not a type, so stop here without consuming - local next = self.tokens[self.ptr + 1] - local next_v = next and next.variant - if #quals > 0 and (next_v == "(" or next_v == ";" or next_v == "," or next_v == ")") then - break - end - - name = tok.ident - self:advance() - break - else - break - end - end - - -- trailing const/volatile after name - while true do - local tok = self:peek() - if tok and type_quals[tok.variant] then - quals[#quals + 1] = tok.variant - self:advance() - else - break - end - end - - if not name then - -- qualifiers only (e.g. "unsigned" as shorthand for "unsigned int") - if #quals > 0 then - name = quals[#quals] - quals[#quals] = nil - else - error("expected type") - end - end - - local pointer = 0 - while self:consume("*") do - pointer = pointer + 1 - -- eat pointer-level qualifiers - while true do - local tok = self:peek() - if tok and (tok.variant == "const" or tok.variant == "volatile" or tok.variant == "restrict") then - self:advance() - else - break - end - end - end - - local reference = self:consume("&") ~= nil - - return { qualifiers = quals, name = name, pointer = pointer, reference = reference or nil } -end - ----@return ffix.c.Parser.Field[] -function Parser:parseFields() - local fields = {} - while not self:consume("}") do - local ftype = self:parseType() - local name_tok - if ftype.inline_kind then - name_tok = self:consume("ident") - else - name_tok = self:expect("ident") - end - local array_size - if self:consume("[") then - local parts = {} - while not self:consume("]") do - local t = self:advance() - if t.variant == "ident" then - parts[#parts + 1] = t.ident - elseif t.variant == "number" then - local n = t.number - parts[#parts + 1] = n == math.floor(n) and tostring(math.floor(n)) or tostring(n) - else - parts[#parts + 1] = t.variant - end - end - array_size = table.concat(parts) - end - local attrs = self:parseAttrs() - self:expect(";") - fields[#fields + 1] = { type = ftype, name = name_tok and name_tok.ident, array_size = array_size, attrs = attrs } - end - return fields -end - ----@return ffix.c.Parser.Variant[] -function Parser:parseVariants() - local variants = {} - while not self:consume("}") do - local name = self:expect("ident") - self:consume(",") - variants[#variants + 1] = { name = name.ident } - end - return variants -end - ----@return ffix.c.Parser.Param[] -function Parser:parseParams() - self:expect("(") - local params = {} - if self:consume(")") then return params end - -- (void) means no params, but (void *) is a real param — peek ahead - if self.tokens[self.ptr] and self.tokens[self.ptr].variant == "void" - and self.tokens[self.ptr + 1] and self.tokens[self.ptr + 1].variant == ")" then - self.ptr = self.ptr + 2 - return params - end - while true do - if self:consume("...") then - self:consume(")") - break - end - local ptype = self:parseType() - local name_tok = self:consume("ident") - params[#params + 1] = { type = ptype, name = name_tok and name_tok.ident } - if self:consume(")") then break end - self:expect(",") - end - return params -end - ----@return string? -function Parser:parseAsmName() - local tok = self:peek() - if tok and tok.variant == "ident" and (tok.ident == "__asm__" or tok.ident == "asm") then - self:advance() - self:expect("(") - local str = self:expect("string") - self:expect(")") - return str.string - end -end - ----@return ffix.c.Attr[]? -function Parser:parseAttrs() - local tok = self:peek() - if not (tok and tok.variant == "ident" and tok.ident == "__attribute__") then return nil end - self:advance() - self:expect("(") - self:expect("(") - local attrs = {} - while true do - if self:consume(")") then break end - local name_tok = self:advance() - local name = name_tok.variant == "ident" and name_tok.ident or name_tok.variant - local args - if self:consume("(") then - local parts = {} - local depth = 0 - while true do - local t = self:peek() - if not t then error("unterminated __attribute__ args") end - if t.variant == ")" then - if depth == 0 then break end - depth = depth - 1 - parts[#parts + 1] = ")" - self:advance() - elseif t.variant == "(" then - depth = depth + 1 - parts[#parts + 1] = "(" - self:advance() - elseif t.variant == "ident" then - parts[#parts + 1] = t.ident - self:advance() - elseif t.variant == "number" then - local n = t.number - parts[#parts + 1] = n == math.floor(n) and tostring(math.floor(n)) or tostring(n) - self:advance() - else - parts[#parts + 1] = t.variant - self:advance() - end - end - args = table.concat(parts) - self:expect(")") - end - attrs[#attrs + 1] = { name = name, args = args } - self:consume(",") - end - self:expect(")") - return attrs -end - ----@return ffix.c.Parser.Node -function Parser:parseDecl() - if self:consume("typedef") then - local kw = self:peek() - - if kw and (kw.variant == "struct" or kw.variant == "union") then - self:advance() - local pre_attrs = self:parseAttrs() - local tag_tok = self:consume("ident") - self:expect("{") - local fields = self:parseFields() - local post_attrs = self:parseAttrs() - local name = self:expect("ident") - self:expect(";") - local attrs - if pre_attrs or post_attrs then - attrs = {} - if pre_attrs then for _, a in ipairs(pre_attrs) do attrs[#attrs + 1] = a end end - if post_attrs then for _, a in ipairs(post_attrs) do attrs[#attrs + 1] = a end end - end - return { kind = "typedef_struct", tag = tag_tok and tag_tok.ident, fields = fields, name = name.ident, attrs = attrs } - end - - if kw and kw.variant == "enum" then - self:advance() - local tag_tok = self:consume("ident") - self:expect("{") - local variants = self:parseVariants() - local name = self:expect("ident") - self:expect(";") - return { kind = "typedef_enum", tag = tag_tok and tag_tok.ident, variants = variants, name = name.ident } - end - - local ret = self:parseType() - - -- function pointer: typedef ret (*name)(params); - if self:consume("(") then - self:expect("*") - local name = self:expect("ident") - self:expect(")") - local params = self:parseParams() - self:expect(";") - return { kind = "typedef_fnptr", ret = ret, name = name.ident, params = params } - end - - local name = self:expect("ident") - self:expect(";") - return { kind = "typedef_alias", type = ret, name = name.ident } - end - - if self:consume("extern") then - local type = self:parseType() - local name = self:expect("ident") - local asm_name = self:parseAsmName() - self:expect(";") - return { kind = "extern_var", type = type, name = name.ident, asm_name = asm_name } - end - - local ret = self:parseType() - local name = self:expect("ident") - local params = self:parseParams() - local asm_name = self:parseAsmName() - local attrs = self:parseAttrs() - self:expect(";") - - return { kind = "fn_decl", ret = ret, name = name.ident, params = params, asm_name = asm_name, attrs = attrs } -end - ----@param tokens ffix.c.Tokenizer.Token[] ----@return boolean, ffix.c.Parser.Node[] -function Parser:parse(tokens) - self.ptr = 1 - self.tokens = tokens - - local nodes = {} - local ok, err = pcall(function() - while self.ptr <= #self.tokens do - nodes[#nodes + 1] = self:parseDecl() - end - end) - - if not ok then - return false, nodes - end - - return true, nodes -end - -return Parser diff --git a/packages/ffix/src/printer.lua b/packages/ffix/src/printer.lua deleted file mode 100644 index ac9d5d97..00000000 --- a/packages/ffix/src/printer.lua +++ /dev/null @@ -1,127 +0,0 @@ ----@class ffix.c.Printer -local Printer = {} -Printer.__index = Printer - -function Printer.new() - return setmetatable({}, Printer) -end - ----@param t ffix.c.Parser.Type ----@return string -function Printer:inlineType(t) - local kw = t.inline_kind - local tag_part = t.inline_tag and (" " .. t.inline_tag) or "" - local attr_str = (t.inline_attrs and #t.inline_attrs > 0) and (" " .. self:attrsStr(t.inline_attrs)) or "" - if kw == "enum" then - local parts = {} - for _, v in ipairs(t.inline_variants) do parts[#parts + 1] = v.name end - return "enum" .. tag_part .. " { " .. table.concat(parts, ", ") .. " }" - else - local parts = {} - for _, f in ipairs(t.inline_fields) do - local arr = f.array_size and ("[" .. f.array_size .. "]") or "" - local fattr = (f.attrs and #f.attrs > 0) and (" " .. self:attrsStr(f.attrs)) or "" - parts[#parts + 1] = self:typedName(f.type, f.name) .. arr .. fattr .. ";" - end - return kw .. tag_part .. attr_str .. " { " .. table.concat(parts, " ") .. " }" - end -end - ----@param t ffix.c.Parser.Type ----@param name string? ----@return string -function Printer:typedName(t, name) - local base - if t.inline_kind then - base = self:inlineType(t) - else - local parts = {} - for _, q in ipairs(t.qualifiers) do parts[#parts + 1] = q end - parts[#parts + 1] = t.name - base = table.concat(parts, " ") - end - local stars = string.rep("*", t.pointer) .. (t.reference and "&" or "") - if t.pointer > 0 or t.reference then - return base .. " " .. stars .. (name or "") - end - return name and (base .. " " .. name) or base -end - ----@param params ffix.c.Parser.Param[] ----@return string -function Printer:paramList(params) - if #params == 0 then return "void" end - local parts = {} - for _, p in ipairs(params) do - parts[#parts + 1] = self:typedName(p.type, p.name) - end - return table.concat(parts, ", ") -end - ----@param attrs ffix.c.Attr[] ----@return string -function Printer:attrsStr(attrs) - local parts = {} - for _, a in ipairs(attrs) do - parts[#parts + 1] = a.args and (a.name .. "(" .. a.args .. ")") or a.name - end - return "__attribute__((" .. table.concat(parts, ", ") .. "))" -end - ----@param node ffix.c.Parser.Node ----@return string -function Printer:node(node) - local k = node.kind - - if k == "typedef_alias" then - return "typedef " .. self:typedName(node.type, node.name) .. ";" - - elseif k == "typedef_struct" then - local attr_str = (node.attrs and #node.attrs > 0) and (" " .. self:attrsStr(node.attrs)) or "" - local lines = { "typedef struct" .. (node.tag and (" " .. node.tag) or "") .. attr_str .. " {" } - for _, f in ipairs(node.fields) do - local arr = f.array_size and ("[" .. f.array_size .. "]") or "" - local fattr = (f.attrs and #f.attrs > 0) and (" " .. self:attrsStr(f.attrs)) or "" - lines[#lines + 1] = "\t" .. self:typedName(f.type, f.name) .. arr .. fattr .. ";" - end - lines[#lines + 1] = "} " .. node.name .. ";" - return table.concat(lines, "\n") - - elseif k == "typedef_enum" then - local lines = { "typedef enum" .. (node.tag and (" " .. node.tag) or "") .. " {" } - for _, v in ipairs(node.variants) do - lines[#lines + 1] = "\t" .. v.name .. "," - end - lines[#lines + 1] = "} " .. node.name .. ";" - return table.concat(lines, "\n") - - elseif k == "typedef_fnptr" then - return "typedef " .. self:typedName(node.ret, "(*" .. node.name .. ")") .. "(" - .. self:paramList(node.params) .. ");" - - elseif k == "fn_decl" then - local s = self:typedName(node.ret, node.name) .. "(" .. self:paramList(node.params) .. ")" - if node.asm_name then s = s .. " __asm__(\"" .. node.asm_name .. "\")" end - if node.attrs and #node.attrs > 0 then s = s .. " " .. self:attrsStr(node.attrs) end - return s .. ";" - - elseif k == "extern_var" then - local s = "extern " .. self:typedName(node.type, node.name) - if node.asm_name then s = s .. " __asm__(\"" .. node.asm_name .. "\")" end - return s .. ";" - end - - error("unknown node kind: " .. tostring(node.kind)) -end - ----@param nodes ffix.c.Parser.Node[] ----@return string -function Printer:print(nodes) - local parts = {} - for _, n in ipairs(nodes) do - parts[#parts + 1] = self:node(n) - end - return table.concat(parts, "\n") -end - -return Printer diff --git a/packages/ffix/src/tokenizer.lua b/packages/ffix/src/tokenizer.lua deleted file mode 100644 index 0c364663..00000000 --- a/packages/ffix/src/tokenizer.lua +++ /dev/null @@ -1,145 +0,0 @@ ----@class ffix.c.Tokenizer ----@field private ptr number ----@field private len number ----@field private src string -local Tokenizer = {} -Tokenizer.__index = Tokenizer - -function Tokenizer.new() - return setmetatable({}, Tokenizer) -end - ----@param pattern string -function Tokenizer:skip(pattern) - local start, finish = string.find(self.src, pattern, self.ptr) - if start then - self.ptr = finish + 1 - return true - end -end - ----@param pattern string ----@return string? -function Tokenizer:consume(pattern) - local start, finish, match = string.find(self.src, pattern, self.ptr) - if start then - self.ptr = finish + 1 - return match or true - end -end - -function Tokenizer:skipWhitespace() - return self:skip("^%s+") -end - -function Tokenizer:skipComments() - return self:skip("^//[^\n]+\n") or self:skip("^#[^\n]+\n") -end - ----@class ffix.c.Tokenizer.Token.Ident ----@field variant "ident" ----@field ident string - ----@class ffix.c.Tokenizer.Token.Number ----@field variant "number" ----@field number number - ----@class ffix.c.Tokenizer.Token.String ----@field variant "string" ----@field number string - ----@class ffix.c.Tokenizer.Token.Special ----@field variant string - ----@alias ffix.c.Tokenizer.Token ---- | ffix.c.Tokenizer.Token.Ident ---- | ffix.c.Tokenizer.Token.String ---- | ffix.c.Tokenizer.Token.Number ---- | ffix.c.Tokenizer.Token.Special - ----@type table -local special = {} - -for _, s in ipairs({ - "typedef", "{", "}", "[", "]", "(", ")", ",", ".", ";", ":", "<", ">", "*", "&", "~", "...", "::", - "struct", "enum", "union", "const", "restrict", "extern", "static", "volatile", - "unsigned", "signed", "void", "char", "short", "int", "long", "float", "double" -}) do - special[s] = true -end - ----@return ffix.c.Tokenizer.Token? -function Tokenizer:next() - local ident = self:consume("^([%a_][%w_]*)") - if ident then - if special[ident] then - return { variant = ident } - end - - return { variant = "ident", ident = ident } - end - - local dec = self:consume("^(%d+%.%d+)") - if dec then - return { variant = "number", number = tonumber(dec) } - end - - local hex = self:consume("^0x([%x]+)") - if hex then - return { variant = "number", number = tonumber(hex, 16) } - end - - local int = self:consume("^(%d+)[uUlL]*") - if int then - return { variant = "number", number = tonumber(int) } - end - - local str = self:consume("^\"([^\"]+)\"") - if str then - return { variant = "string", string = str } - end - - local three = string.sub(self.src, self.ptr, self.ptr + 2) - if special[three] then - self.ptr = self.ptr + 3 - return { variant = three } - end - - local two = string.sub(self.src, self.ptr, self.ptr + 1) - if special[two] then - self.ptr = self.ptr + 2 - return { variant = two } - end - - local one = string.sub(self.src, self.ptr, self.ptr) - if special[one] then - self.ptr = self.ptr + 1 - return { variant = one } - end -end - ----@param src string -function Tokenizer:tokenize(src) - self.ptr = 1 - self.len = #src - self.src = src - - ---@type ffix.c.Tokenizer.Token[] - local tokens = {} - - while true do - while self:skipWhitespace() or self:skipComments() do end - if self.ptr > self.len then break end - - local tok = self:next() - if not tok then - error("Unrecognized character: " .. string.sub(self.src, self.ptr, self.ptr)) - end - - tokens[#tokens + 1] = tok - end - - return tokens -end - -return Tokenizer diff --git a/packages/ffix/tests/ffi.test.lua b/packages/ffix/tests/ffi.test.lua deleted file mode 100644 index 0b68eb0e..00000000 --- a/packages/ffix/tests/ffi.test.lua +++ /dev/null @@ -1,168 +0,0 @@ -local test = require("lde-test") -local ffi = require("ffi") -local ffix = require("ffix") - --- each test gets a unique prefix so cdef doesn't see duplicate type names across runs -local n = 0 -local function ctx() - n = n + 1 - return ffix.context("t" .. n) -end - --- sizeof - -test.it("sizeof resolves prefixed struct", function() - local c = ctx() - c:cdef("typedef struct { int x; int y; } Point;") - test.equal(c:sizeof("Point"), ffi.sizeof("int") * 2) -end) - -test.it("sizeof resolves prefixed alias", function() - local c = ctx() - c:cdef("typedef int MyInt;") - test.equal(c:sizeof("MyInt"), ffi.sizeof("int")) -end) - --- typeof - -test.it("typeof returns the right ctype", function() - local c = ctx() - c:cdef("typedef struct { float x; float y; float z; } Vec3;") - local ct = c:typeof("Vec3") - test.equal(ffi.sizeof(ct), ffi.sizeof("float") * 3) -end) - --- new - -test.it("new creates a zero-initialised struct", function() - local c = ctx() - c:cdef("typedef struct { int a; int b; } Pair;") - local p = c:new("Pair") - test.equal(p.a, 0) - test.equal(p.b, 0) -end) - -test.it("new with initialiser sets fields", function() - local c = ctx() - c:cdef("typedef struct { int x; int y; } Coord;") - local p = c:new("Coord", { x = 3, y = 7 }) - test.equal(p.x, 3) - test.equal(p.y, 7) -end) - -test.it("new field writes survive a read back", function() - local c = ctx() - c:cdef("typedef struct { int val; } Box;") - local b = c:new("Box") - b.val = 99 - test.equal(b.val, 99) -end) - --- cast - -test.it("cast with bare type name works", function() - local c = ctx() - c:cdef("typedef struct { int n; } Wrap;") - local w = c:new("Wrap", { n = 42 }) - local p = c:cast("Wrap *", w) - test.equal(p.n, 42) -end) - -test.it("cast pointer write is visible through original", function() - local c = ctx() - c:cdef("typedef struct { int n; } Cell;") - local cell = c:new("Cell", { n = 1 }) - local ptr = c:cast("Cell *", cell) - ptr.n = 55 - test.equal(cell.n, 55) -end) - --- function resolution via __asm__ - -test.it("declared function resolves to the real symbol via asm", function() - local c = ctx() - c:cdef("unsigned long strlen(const char * s);") - -- rewriter emits: unsigned long tN_strlen(const char *s) __asm__("strlen"); - local pfx_strlen = ffi.C[c.names["strlen"]] - test.equal(tonumber(pfx_strlen("hello")), 5) - test.equal(tonumber(pfx_strlen("")), 0) -end) - -test.it("multiple functions resolve independently", function() - local c = ctx() - c:cdef([[ - unsigned long strlen(const char * s); - int atoi(const char * s); - ]]) - test.equal(tonumber(ffi.C[c.names["strlen"]]("abc")), 3) - test.equal(tonumber(ffi.C[c.names["atoi"]]("123")), 123) -end) - --- ctx.C - -test.it("ctx.C.fn calls through to the real symbol", function() - local c = ctx() - c:cdef("unsigned long strlen(const char * s);") - test.equal(tonumber(c.C.strlen("hello")), 5) - test.equal(tonumber(c.C.strlen("")), 0) -end) - -test.it("ctx.C resolves multiple functions independently", function() - local c = ctx() - c:cdef([[ - unsigned long strlen(const char * s); - int atoi(const char * s); - ]]) - test.equal(tonumber(c.C.strlen("abc")), 3) - test.equal(tonumber(c.C.atoi("42")), 42) -end) - -test.it("ctx.C from different contexts do not collide", function() - local c1 = ctx() - local c2 = ctx() - c1:cdef("unsigned long strlen(const char * s);") - c2:cdef("unsigned long strlen(const char * s);") - test.equal(tonumber(c1.C.strlen("hi")), 2) - test.equal(tonumber(c2.C.strlen("hello")), 5) -end) - --- metatype - -test.it("metatype registers methods accessible on new instances", function() - local c = ctx() - c:cdef("typedef struct { int x; int y; } Point;") - c:metatype("Point", { - __index = { - sum = function(self) return self.x + self.y end, - }, - }) - local p = c:new("Point", { x = 3, y = 4 }) - test.equal(p:sum(), 7) -end) - -test.it("metatype __tostring is called on tostring()", function() - local c = ctx() - c:cdef("typedef struct { int n; } Num;") - c:metatype("Num", { - __tostring = function(self) return "Num(" .. self.n .. ")" end, - }) - local v = c:new("Num", { n = 99 }) - test.equal(tostring(v), "Num(99)") -end) - --- istype - -test.it("istype returns true for matching ctype", function() - local c = ctx() - c:cdef("typedef struct { int x; } Vec;") - local v = c:new("Vec") - test.truthy(c:istype("Vec", v)) -end) - -test.it("istype returns false for non-matching ctype", function() - local c = ctx() - c:cdef("typedef struct { int x; } A;") - c:cdef("typedef struct { int x; } B;") - local a = c:new("A") - test.falsy(c:istype("B", a)) -end) diff --git a/packages/ffix/tests/parser.test.lua b/packages/ffix/tests/parser.test.lua deleted file mode 100644 index 22fe02fc..00000000 --- a/packages/ffix/tests/parser.test.lua +++ /dev/null @@ -1,461 +0,0 @@ -local test = require("lde-test") -local Tokenizer = require("ffix.tokenizer") -local Parser = require("ffix.parser") - -local function parse(src) - local tokens = Tokenizer.new():tokenize(src) - local ok, nodes = Parser.new():parse(tokens) - test.truthy(ok) - return nodes -end - --- typedef alias - -test.it("typedef primitive alias", function() - test.match(parse("typedef int MyInt;"), { - { kind = "typedef_alias", name = "MyInt", type = { name = "int", pointer = 0, qualifiers = {} } }, - }) -end) - -test.it("typedef pointer alias", function() - test.match(parse("typedef char * string_t;"), { - { kind = "typedef_alias", name = "string_t", type = { name = "char", pointer = 1 } }, - }) -end) - -test.it("typedef with qualifier", function() - test.match(parse("typedef const unsigned int uint_t;"), { - { kind = "typedef_alias", name = "uint_t", type = { name = "int", qualifiers = { "const", "unsigned" } } }, - }) -end) - -test.it("typedef double-pointer", function() - test.match(parse("typedef void ** handle_t;"), { - { kind = "typedef_alias", name = "handle_t", type = { name = "void", pointer = 2 } }, - }) -end) - --- typedef struct - -test.it("typedef anonymous struct", function() - test.match(parse("typedef struct { int x; int y; } Point;"), { - { - kind = "typedef_struct", - name = "Point", - tag = nil, - fields = { - { name = "x", type = { name = "int" } }, - { name = "y", type = { name = "int" } }, - }, - }, - }) -end) - -test.it("typedef struct with tag", function() - test.match(parse("typedef struct Node { int val; } Node;"), { - { kind = "typedef_struct", name = "Node", tag = "Node" }, - }) -end) - -test.it("typedef struct with pointer field", function() - test.match(parse("typedef struct { struct Node * next; } Node;"), { - { - kind = "typedef_struct", - fields = { { name = "next", type = { name = "struct Node", pointer = 1 } } }, - }, - }) -end) - -test.it("typedef struct with array field", function() - test.match(parse("typedef struct { char buf[256]; } Buf;"), { - { kind = "typedef_struct", fields = { { name = "buf", type = { name = "char" } } } }, - }) -end) - -test.it("typedef struct with multiple fields of different types", function() - test.match(parse("typedef struct { unsigned int id; const char * name; } Record;"), { - { - kind = "typedef_struct", - name = "Record", - fields = { - { name = "id", type = { name = "int", qualifiers = { "unsigned" } } }, - { name = "name", type = { name = "char", pointer = 1 } }, - }, - }, - }) -end) - --- typedef enum - -test.it("typedef enum", function() - test.match(parse("typedef enum { RED, GREEN, BLUE, } Color;"), { - { - kind = "typedef_enum", - name = "Color", - variants = { { name = "RED" }, { name = "GREEN" }, { name = "BLUE" } }, - }, - }) -end) - -test.it("typedef enum with tag", function() - test.match(parse("typedef enum Dir { UP, DOWN, } Dir;"), { - { kind = "typedef_enum", name = "Dir", tag = "Dir" }, - }) -end) - --- typedef function pointer - -test.it("typedef function pointer no params", function() - test.match(parse("typedef void (*Callback)(void);"), { - { kind = "typedef_fnptr", name = "Callback", ret = { name = "void" }, params = {} }, - }) -end) - -test.it("typedef function pointer with params", function() - test.match(parse("typedef int (*Comparator)(const void * a, const void * b);"), { - { - kind = "typedef_fnptr", - name = "Comparator", - ret = { name = "int" }, - params = { - { type = { name = "void", pointer = 1 } }, - { type = { name = "void", pointer = 1 } }, - }, - }, - }) -end) - -test.it("typedef function pointer returning pointer", function() - test.match(parse("typedef char * (*Getter)(int key);"), { - { - kind = "typedef_fnptr", - name = "Getter", - ret = { name = "char", pointer = 1 }, - params = { { type = { name = "int" } } }, - }, - }) -end) - --- function declarations - -test.it("void function no params", function() - test.match(parse("void init(void);"), { - { kind = "fn_decl", name = "init", ret = { name = "void" }, params = {} }, - }) -end) - -test.it("function with named params", function() - test.match(parse("int add(int a, int b);"), { - { - kind = "fn_decl", - name = "add", - ret = { name = "int" }, - params = { - { name = "a", type = { name = "int" } }, - { name = "b", type = { name = "int" } }, - }, - }, - }) -end) - -test.it("function with unnamed params", function() - test.match(parse("int add(int, int);"), { - { - kind = "fn_decl", - name = "add", - params = { - { name = nil, type = { name = "int" } }, - { name = nil, type = { name = "int" } }, - }, - }, - }) -end) - -test.it("function returning pointer", function() - test.match(parse("char * strdup(const char * s);"), { - { - kind = "fn_decl", - name = "strdup", - ret = { name = "char", pointer = 1 }, - params = { { type = { name = "char", pointer = 1 } } }, - }, - }) -end) - -test.it("variadic function", function() - test.match(parse("int printf(const char * fmt, ...);"), { - { - kind = "fn_decl", - name = "printf", - params = { { type = { name = "char", pointer = 1 } } }, - }, - }) -end) - --- extern variable - -test.it("extern int", function() - test.match(parse("extern int errno;"), { - { kind = "extern_var", name = "errno", type = { name = "int" } }, - }) -end) - -test.it("extern pointer", function() - test.match(parse("extern char * environ;"), { - { kind = "extern_var", name = "environ", type = { name = "char", pointer = 1 } }, - }) -end) - --- multiple declarations - -test.it("parses multiple declarations in sequence", function() - local nodes = parse([[ - typedef int size_t; - extern int errno; - void free(void * ptr); - ]]) - test.equal(#nodes, 3) - test.equal(nodes[1].kind, "typedef_alias") - test.equal(nodes[2].kind, "extern_var") - test.equal(nodes[3].kind, "fn_decl") -end) - --- __asm__ attribute - -test.it("fn_decl with __asm__", function() - test.match(parse("int mylib_add(int a, int b) __asm__(\"add\");"), { - { kind = "fn_decl", name = "mylib_add", asm_name = "add" }, - }) -end) - -test.it("fn_decl with asm (no underscores)", function() - test.match(parse("void mylib_free(void * ptr) asm(\"free\");"), { - { kind = "fn_decl", name = "mylib_free", asm_name = "free" }, - }) -end) - -test.it("extern_var with __asm__", function() - test.match(parse("extern int mylib_errno __asm__(\"errno\");"), { - { kind = "extern_var", name = "mylib_errno", asm_name = "errno" }, - }) -end) - -test.it("fn_decl without __asm__ has nil asm_name", function() - test.match(parse("int add(int a, int b);"), { - { kind = "fn_decl", name = "add", asm_name = nil }, - }) -end) - --- array sizes - -test.it("struct field array size is preserved", function() - test.match(parse("typedef struct { char buf[256]; } Buf;"), { - { kind = "typedef_struct", fields = { { name = "buf", type = { name = "char" }, array_size = "256" } } }, - }) -end) - -test.it("struct field symbolic array size is preserved", function() - test.match(parse("typedef struct { int data[MAX_SIZE]; } S;"), { - { kind = "typedef_struct", fields = { { name = "data", array_size = "MAX_SIZE" } } }, - }) -end) - --- __attribute__ - -test.it("struct field __attribute__((aligned(4)))", function() - test.match(parse("typedef struct { int x __attribute__((aligned(4))); } S;"), { - { kind = "typedef_struct", fields = { - { name = "x", attrs = { { name = "aligned", args = "4" } } }, - } }, - }) -end) - -test.it("struct field __attribute__((packed))", function() - test.match(parse("typedef struct { char c __attribute__((packed)); } S;"), { - { kind = "typedef_struct", fields = { - { name = "c", attrs = { { name = "packed", args = nil } } }, - } }, - }) -end) - -test.it("struct field __attribute__((mode(__word__)))", function() - test.match(parse("typedef struct { int x __attribute__((mode(__word__))); } S;"), { - { kind = "typedef_struct", fields = { - { name = "x", attrs = { { name = "mode", args = "__word__" } } }, - } }, - }) -end) - -test.it("struct field __attribute__((vector_size(16)))", function() - test.match(parse("typedef struct { float v __attribute__((vector_size(16))); } S;"), { - { kind = "typedef_struct", fields = { - { name = "v", attrs = { { name = "vector_size", args = "16" } } }, - } }, - }) -end) - -test.it("struct __attribute__((packed)) before body", function() - test.match(parse("typedef struct __attribute__((packed)) { int x; } S;"), { - { kind = "typedef_struct", attrs = { { name = "packed" } } }, - }) -end) - -test.it("struct __attribute__((packed)) after body", function() - test.match(parse("typedef struct { int x; } __attribute__((packed)) S;"), { - { kind = "typedef_struct", attrs = { { name = "packed" } } }, - }) -end) - -test.it("struct __attribute__((aligned(8))) before body", function() - test.match(parse("typedef struct __attribute__((aligned(8))) { int x; } S;"), { - { kind = "typedef_struct", attrs = { { name = "aligned", args = "8" } } }, - }) -end) - -test.it("fn_decl __attribute__((cdecl))", function() - test.match(parse("void foo(void) __attribute__((cdecl));"), { - { kind = "fn_decl", name = "foo", attrs = { { name = "cdecl" } } }, - }) -end) - -test.it("fn_decl __attribute__((stdcall))", function() - test.match(parse("int bar(int x) __attribute__((stdcall));"), { - { kind = "fn_decl", name = "bar", attrs = { { name = "stdcall" } } }, - }) -end) - -test.it("fn_decl __attribute__((fastcall))", function() - test.match(parse("void baz(void) __attribute__((fastcall));"), { - { kind = "fn_decl", attrs = { { name = "fastcall" } } }, - }) -end) - -test.it("fn_decl __attribute__((thiscall))", function() - test.match(parse("void qux(void) __attribute__((thiscall));"), { - { kind = "fn_decl", attrs = { { name = "thiscall" } } }, - }) -end) - -test.it("fn_decl with __asm__ and __attribute__ preserves both", function() - test.match(parse("void foo(void) __asm__(\"_foo\") __attribute__((cdecl));"), { - { kind = "fn_decl", asm_name = "_foo", attrs = { { name = "cdecl" } } }, - }) -end) - -test.it("field has no attrs when none present", function() - test.match(parse("typedef struct { int x; } S;"), { - { kind = "typedef_struct", fields = { { name = "x", attrs = nil, array_size = nil } } }, - }) -end) - --- reference types - -test.it("typedef reference alias", function() - test.match(parse("typedef int & IntRef;"), { - { kind = "typedef_alias", name = "IntRef", type = { name = "int", pointer = 0, reference = true } }, - }) -end) - -test.it("function with reference param", function() - test.match(parse("void swap(int & a, int & b);"), { - { - kind = "fn_decl", - name = "swap", - params = { - { name = "a", type = { name = "int", reference = true } }, - { name = "b", type = { name = "int", reference = true } }, - }, - }, - }) -end) - -test.it("function returning reference", function() - test.match(parse("int & at(int idx);"), { - { kind = "fn_decl", name = "at", ret = { name = "int", reference = true } }, - }) -end) - -test.it("non-reference type has nil reference field", function() - test.match(parse("typedef int MyInt;"), { - { kind = "typedef_alias", name = "MyInt", type = { reference = nil } }, - }) -end) - --- anonymous / inline struct, union, enum - -test.it("anonymous union field inside struct", function() - test.match(parse("typedef struct { union { int a; float b; }; int c; } Foo;"), { - { - kind = "typedef_struct", - name = "Foo", - fields = { - { name = nil, type = { inline_kind = "union", inline_tag = nil, inline_fields = { - { name = "a", type = { name = "int" } }, - { name = "b", type = { name = "float" } }, - } } }, - { name = "c", type = { name = "int" } }, - }, - }, - }) -end) - -test.it("named inline struct field", function() - test.match(parse("typedef struct { struct { int x; int y; } pos; } Entity;"), { - { - kind = "typedef_struct", - name = "Entity", - fields = { - { name = "pos", type = { inline_kind = "struct", inline_tag = nil, inline_fields = { - { name = "x", type = { name = "int" } }, - { name = "y", type = { name = "int" } }, - } } }, - }, - }, - }) -end) - -test.it("tagged inline union field", function() - test.match(parse("typedef struct { union Val { int i; float f; } val; } S;"), { - { - kind = "typedef_struct", - fields = { - { name = "val", type = { inline_kind = "union", inline_tag = "Val" } }, - }, - }, - }) -end) - -test.it("anonymous struct inside union", function() - test.match(parse("typedef union { struct { int x; int y; }; long long flat; } Vec2;"), { - { - kind = "typedef_struct", - name = "Vec2", - fields = { - { name = nil, type = { inline_kind = "struct" } }, - { name = "flat", type = { name = "long" } }, - }, - }, - }) -end) - -test.it("nested anonymous unions", function() - test.match(parse("typedef struct { union { struct { int x; int y; }; int arr[2]; }; } S;"), { - { - kind = "typedef_struct", - fields = { - { name = nil, type = { inline_kind = "union", inline_fields = { - { name = nil, type = { inline_kind = "struct" } }, - { name = "arr", type = { name = "int" }, array_size = "2" }, - } } }, - }, - }, - }) -end) - --- error handling - -test.it("returns false on invalid input", function() - local tokens = Tokenizer.new():tokenize("int;") - local ok, nodes = Parser.new():parse(tokens) - test.falsy(ok) -end) diff --git a/packages/ffix/tests/printer.test.lua b/packages/ffix/tests/printer.test.lua deleted file mode 100644 index 5d50f71c..00000000 --- a/packages/ffix/tests/printer.test.lua +++ /dev/null @@ -1,254 +0,0 @@ -local test = require("lde-test") -local Tokenizer = require("ffix.tokenizer") -local Parser = require("ffix.parser") -local Printer = require("ffix.printer") - -local function roundtrip(src) - local tokens = Tokenizer.new():tokenize(src) - local ok, nodes = Parser.new():parse(tokens) - test.truthy(ok) - return Printer.new():print(nodes) -end - -test.it("typedef alias", function() - test.equal(roundtrip("typedef int MyInt;"), "typedef int MyInt;") -end) - -test.it("typedef pointer alias", function() - test.equal(roundtrip("typedef char * string_t;"), "typedef char *string_t;") -end) - -test.it("typedef qualified alias", function() - test.equal(roundtrip("typedef const unsigned int uint_t;"), "typedef const unsigned int uint_t;") -end) - -test.it("typedef double-pointer alias", function() - test.equal(roundtrip("typedef void ** handle_t;"), "typedef void **handle_t;") -end) - -test.it("typedef struct anonymous", function() - test.equal(roundtrip("typedef struct { int x; int y; } Point;"), [[ -typedef struct { - int x; - int y; -} Point;]]) -end) - -test.it("typedef struct with tag", function() - test.equal(roundtrip("typedef struct Node { int val; } Node;"), [[ -typedef struct Node { - int val; -} Node;]]) -end) - -test.it("typedef struct pointer field", function() - test.equal(roundtrip("typedef struct { struct Node * next; } Node;"), [[ -typedef struct { - struct Node *next; -} Node;]]) -end) - -test.it("typedef enum", function() - test.equal(roundtrip("typedef enum { RED, GREEN, BLUE, } Color;"), [[ -typedef enum { - RED, - GREEN, - BLUE, -} Color;]]) -end) - -test.it("typedef enum with tag", function() - test.equal(roundtrip("typedef enum Dir { UP, DOWN, } Dir;"), [[ -typedef enum Dir { - UP, - DOWN, -} Dir;]]) -end) - -test.it("typedef function pointer no params", function() - test.equal(roundtrip("typedef void (*Callback)(void);"), "typedef void (*Callback)(void);") -end) - -test.it("typedef function pointer with params", function() - test.equal( - roundtrip("typedef int (*Comparator)(const void * a, const void * b);"), - "typedef int (*Comparator)(const void *a, const void *b);" - ) -end) - -test.it("typedef function pointer returning pointer", function() - test.equal(roundtrip("typedef char * (*Getter)(int key);"), "typedef char *(*Getter)(int key);") -end) - -test.it("function declaration no params", function() - test.equal(roundtrip("void init(void);"), "void init(void);") -end) - -test.it("function declaration named params", function() - test.equal(roundtrip("int add(int a, int b);"), "int add(int a, int b);") -end) - -test.it("function declaration unnamed params", function() - test.equal(roundtrip("int add(int, int);"), "int add(int, int);") -end) - -test.it("function returning pointer", function() - test.equal(roundtrip("char * strdup(const char * s);"), "char *strdup(const char *s);") -end) - -test.it("function with void pointer param", function() - test.equal(roundtrip("void free(void * ptr);"), "void free(void *ptr);") -end) - -test.it("extern variable", function() - test.equal(roundtrip("extern int errno;"), "extern int errno;") -end) - -test.it("extern pointer", function() - test.equal(roundtrip("extern char * environ;"), "extern char *environ;") -end) - -test.it("fn_decl with __asm__ roundtrips", function() - test.equal( - roundtrip("int mylib_add(int a, int b) __asm__(\"add\");"), - "int mylib_add(int a, int b) __asm__(\"add\");" - ) -end) - -test.it("extern_var with __asm__ roundtrips", function() - test.equal( - roundtrip("extern int mylib_errno __asm__(\"errno\");"), - "extern int mylib_errno __asm__(\"errno\");" - ) -end) - -test.it("multiple nodes", function() - test.equal(roundtrip("typedef int size_t;\nextern int errno;"), "typedef int size_t;\nextern int errno;") -end) - --- array sizes - -test.it("struct field with array size roundtrips", function() - test.equal(roundtrip("typedef struct { char buf[256]; } Buf;"), [[ -typedef struct { - char buf[256]; -} Buf;]]) -end) - -test.it("struct field with symbolic array size roundtrips", function() - test.equal(roundtrip("typedef struct { int data[MAX_SIZE]; } S;"), [[ -typedef struct { - int data[MAX_SIZE]; -} S;]]) -end) - --- __attribute__ - -test.it("struct __attribute__((packed)) before body roundtrips", function() - test.equal(roundtrip("typedef struct __attribute__((packed)) { int x; } S;"), [[ -typedef struct __attribute__((packed)) { - int x; -} S;]]) -end) - -test.it("struct __attribute__((packed)) after body normalises to before body", function() - test.equal(roundtrip("typedef struct { int x; } __attribute__((packed)) S;"), [[ -typedef struct __attribute__((packed)) { - int x; -} S;]]) -end) - -test.it("struct __attribute__((aligned(8))) roundtrips", function() - test.equal(roundtrip("typedef struct __attribute__((aligned(8))) { int x; } S;"), [[ -typedef struct __attribute__((aligned(8))) { - int x; -} S;]]) -end) - -test.it("field __attribute__((aligned(4))) roundtrips", function() - test.equal(roundtrip("typedef struct { int x __attribute__((aligned(4))); } S;"), [[ -typedef struct { - int x __attribute__((aligned(4))); -} S;]]) -end) - -test.it("field __attribute__((mode(__word__))) roundtrips", function() - test.equal(roundtrip("typedef struct { int x __attribute__((mode(__word__))); } S;"), [[ -typedef struct { - int x __attribute__((mode(__word__))); -} S;]]) -end) - -test.it("field __attribute__((vector_size(16))) roundtrips", function() - test.equal(roundtrip("typedef struct { float v __attribute__((vector_size(16))); } S;"), [[ -typedef struct { - float v __attribute__((vector_size(16))); -} S;]]) -end) - -test.it("field with array size and __attribute__ roundtrips", function() - test.equal(roundtrip("typedef struct { int arr[4] __attribute__((aligned(16))); } S;"), [[ -typedef struct { - int arr[4] __attribute__((aligned(16))); -} S;]]) -end) - -test.it("fn_decl __attribute__((cdecl)) roundtrips", function() - test.equal(roundtrip("void foo(void) __attribute__((cdecl));"), "void foo(void) __attribute__((cdecl));") -end) - -test.it("fn_decl __attribute__((stdcall)) roundtrips", function() - test.equal(roundtrip("int bar(int x) __attribute__((stdcall));"), "int bar(int x) __attribute__((stdcall));") -end) - -test.it("fn_decl __asm__ and __attribute__ roundtrips", function() - test.equal( - roundtrip("void foo(void) __asm__(\"_foo\") __attribute__((cdecl));"), - "void foo(void) __asm__(\"_foo\") __attribute__((cdecl));" - ) -end) - --- reference types - -test.it("typedef reference alias roundtrips", function() - test.equal(roundtrip("typedef int & IntRef;"), "typedef int &IntRef;") -end) - -test.it("function with reference params roundtrips", function() - test.equal(roundtrip("void swap(int & a, int & b);"), "void swap(int &a, int &b);") -end) - -test.it("function returning reference roundtrips", function() - test.equal(roundtrip("int & at(int idx);"), "int &at(int idx);") -end) - --- anonymous / inline struct, union, enum - -test.it("anonymous union field inside struct", function() - test.equal(roundtrip("typedef struct { union { int a; float b; }; int c; } Foo;"), [[ -typedef struct { - union { int a; float b; }; - int c; -} Foo;]]) -end) - -test.it("named inline struct field", function() - test.equal(roundtrip("typedef struct { struct { int x; int y; } pos; } Entity;"), [[ -typedef struct { - struct { int x; int y; } pos; -} Entity;]]) -end) - -test.it("tagged inline union field", function() - test.equal(roundtrip("typedef struct { union Val { int i; float f; } val; } S;"), [[ -typedef struct { - union Val { int i; float f; } val; -} S;]]) -end) - -test.it("nested anonymous union inside struct", function() - test.equal(roundtrip("typedef struct { union { struct { int x; int y; }; int arr[2]; }; } S;"), [[ -typedef struct { - union { struct { int x; int y; }; int arr[2]; }; -} S;]]) -end) diff --git a/packages/ffix/tests/rewrite.test.lua b/packages/ffix/tests/rewrite.test.lua deleted file mode 100644 index 9266ac5d..00000000 --- a/packages/ffix/tests/rewrite.test.lua +++ /dev/null @@ -1,112 +0,0 @@ -local test = require("lde-test") -local ffix = require("ffix") -local Tokenizer = require("ffix.tokenizer") -local Parser = require("ffix.parser") -local Printer = require("ffix.printer") - --- parse + rewrite + print without calling ffi.cdef -local function rewrite(pfx, src) - local tokens = Tokenizer.new():tokenize(src) - local ok, nodes = Parser.new():parse(tokens) - test.truthy(ok) - - local ctx = ffix.context(pfx) - for _, node in ipairs(nodes) do - if node.name then ctx.names[node.name] = pfx .. "_" .. node.name end - end - - local out = {} - for _, node in ipairs(nodes) do - out[#out + 1] = ctx:rewriteNode(node) - end - return Printer.new():print(out) -end - -test.it("typedef alias is prefixed", function() - test.equal(rewrite("mylib", "typedef int MyInt;"), "typedef int mylib_MyInt;") -end) - -test.it("typedef alias referencing another type is rewritten", function() - test.equal( - rewrite("mylib", "typedef int MyInt;\ntypedef MyInt MyOtherInt;"), - "typedef int mylib_MyInt;\ntypedef mylib_MyInt mylib_MyOtherInt;" - ) -end) - -test.it("typedef struct fields with user types are rewritten", function() - test.equal( - rewrite("mylib", "typedef struct { int x; } Point;\ntypedef struct { Point * origin; } Rect;"), - "typedef struct {\n\tint x;\n} mylib_Point;\ntypedef struct {\n\tmylib_Point *origin;\n} mylib_Rect;" - ) -end) - -test.it("typedef struct with tag rewrites tag too", function() - test.equal( - rewrite("mylib", "typedef struct Node { int val; } Node;"), - "typedef struct mylib_Node {\n\tint val;\n} mylib_Node;" - ) -end) - -test.it("typedef enum is prefixed", function() - test.equal( - rewrite("mylib", "typedef enum { A, B, } Color;"), - "typedef enum {\n\tA,\n\tB,\n} mylib_Color;" - ) -end) - -test.it("typedef fnptr with user type param is rewritten", function() - test.equal( - rewrite("mylib", "typedef struct { int x; } Point;\ntypedef int (*Callback)(Point * p);"), - "typedef struct {\n\tint x;\n} mylib_Point;\ntypedef int (*mylib_Callback)(mylib_Point *p);" - ) -end) - -test.it("fn_decl gets prefixed name and asm attribute", function() - test.equal(rewrite("mylib", "int add(int a, int b);"), "int mylib_add(int a, int b) __asm__(\"add\");") -end) - -test.it("fn_decl with user type params rewrites param types", function() - test.equal( - rewrite("mylib", "typedef struct { int x; } Point;\nvoid transform(Point * p);"), - "typedef struct {\n\tint x;\n} mylib_Point;\nvoid mylib_transform(mylib_Point *p) __asm__(\"transform\");" - ) -end) - -test.it("fn_decl preserves existing __asm__ as the asm target", function() - test.equal( - rewrite("mylib", "int mylib_add(int a, int b) __asm__(\"add\");"), - "int mylib_mylib_add(int a, int b) __asm__(\"add\");" - ) -end) - -test.it("extern_var gets prefixed name and asm attribute", function() - test.equal(rewrite("mylib", "extern int errno_val;"), "extern int mylib_errno_val __asm__(\"errno_val\");") -end) - -test.it("extern_var with pointer type is rewritten", function() - test.equal( - rewrite("mylib", "extern char * global_buf;"), - "extern char *mylib_global_buf __asm__(\"global_buf\");" - ) -end) - -test.it("struct field reference to tagged struct is rewritten", function() - test.equal( - rewrite("mylib", "typedef struct Node { struct Node * next; } Node;"), - "typedef struct mylib_Node {\n\tstruct mylib_Node *next;\n} mylib_Node;" - ) -end) - -test.it("anonymous union field inside struct is preserved through rewrite", function() - test.equal( - rewrite("mylib", "typedef struct { union { int a; float b; }; int c; } Foo;"), - "typedef struct {\n\tunion { int a; float b; };\n\tint c;\n} mylib_Foo;" - ) -end) - -test.it("named inline struct field with user type is rewritten", function() - test.equal( - rewrite("mylib", "typedef int MyInt;\ntypedef struct { struct { MyInt x; MyInt y; } pos; } Entity;"), - "typedef int mylib_MyInt;\ntypedef struct {\n\tstruct { mylib_MyInt x; mylib_MyInt y; } pos;\n} mylib_Entity;" - ) -end) diff --git a/packages/ffix/tests/tokenizer.test.lua b/packages/ffix/tests/tokenizer.test.lua deleted file mode 100644 index 51adae2b..00000000 --- a/packages/ffix/tests/tokenizer.test.lua +++ /dev/null @@ -1,114 +0,0 @@ -local test = require("lde-test") -local Tokenizer = require("ffix.tokenizer") - -local function tok(src) - return Tokenizer.new():tokenize(src) -end - --- idents and keywords - -test.it("tokenizes a plain ident", function() - test.match(tok("myVar"), { { variant = "ident", ident = "myVar" } }) -end) - -test.it("tokenizes underscore ident", function() - test.match(tok("_size_t"), { { variant = "ident", ident = "_size_t" } }) -end) - -test.it("keywords produce their variant directly", function() - for _, kw in ipairs({ "typedef", "struct", "enum", "union", "const", "extern", - "unsigned", "signed", "void", "char", "short", "int", "long", "float", "double", - "static", "volatile", "restrict" }) do - test.match(tok(kw), { { variant = kw } }) - end -end) - --- numbers - -test.it("tokenizes decimal integer", function() - test.match(tok("42"), { { variant = "number", number = 42 } }) -end) - -test.it("tokenizes integer with suffix", function() - test.match(tok("100u"), { { variant = "number", number = 100 } }) -end) - -test.it("tokenizes hex number", function() - test.match(tok("0xff"), { { variant = "number", number = 255 } }) -end) - -test.it("tokenizes float", function() - test.match(tok("3.14"), { { variant = "number", number = 3.14 } }) -end) - --- strings - -test.it("tokenizes a double-quoted string", function() - test.match(tok('"hello world"'), { { variant = "string", string = "hello world" } }) -end) - --- specials - -test.it("tokenizes single-char specials", function() - for _, s in ipairs({ "{", "}", "[", "]", "(", ")", ",", ".", ";", ":", "<", ">", "*", "&", "~" }) do - test.match(tok(s), { { variant = s } }) - end -end) - -test.it("tokenizes :: as one token", function() - test.match(tok("::"), { { variant = "::" } }) -end) - -test.it("tokenizes ... as one token", function() - test.match(tok("..."), { { variant = "..." } }) -end) - --- whitespace and comments - -test.it("skips whitespace", function() - test.match(tok(" int "), { { variant = "int" } }) -end) - -test.it("skips // comments", function() - test.match(tok("// comment\nint"), { { variant = "int" } }) -end) - -test.it("skips # comments", function() - test.match(tok("# preprocessor\nchar"), { { variant = "char" } }) -end) - --- multi-token sequences - -test.it("tokenizes typedef sequence", function() - test.match(tok("typedef int MyInt;"), { - { variant = "typedef" }, - { variant = "int" }, - { variant = "ident", ident = "MyInt" }, - { variant = ";" }, - }) -end) - -test.it("tokenizes pointer with qualifiers", function() - test.match(tok("const char * restrict"), { - { variant = "const" }, - { variant = "char" }, - { variant = "*" }, - { variant = "restrict" }, - }) -end) - -test.it("tokenizes a function signature", function() - test.match(tok("void foo(int x, char *y);"), { - { variant = "void" }, - { variant = "ident", ident = "foo" }, - { variant = "(" }, - { variant = "int" }, - { variant = "ident", ident = "x" }, - { variant = "," }, - { variant = "char" }, - { variant = "*" }, - { variant = "ident", ident = "y" }, - { variant = ")" }, - { variant = ";" }, - }) -end) diff --git a/packages/fs/lde.json b/packages/fs/lde.json deleted file mode 100644 index 96f0df7b..00000000 --- a/packages/fs/lde.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "fs", - "version": "0.1.0", - "dependencies": {}, - "devDependencies": { - "lde-test": { "path": "../lde-test" }, - "fs": { "path": "../fs" }, - "env": { "path": "../env" }, - "path": { "path": "../path" } - } -} diff --git a/packages/fs/src/init.lua b/packages/fs/src/init.lua deleted file mode 100644 index a6c72bb4..00000000 --- a/packages/fs/src/init.lua +++ /dev/null @@ -1,237 +0,0 @@ -local path = require("path") - ----@class fs.Stat ----@field size number # Size in bytes ----@field accessTime number ----@field modifyTime number ----@field type fs.Stat.Type? ----@field mode number? # Permission bits (Unix only) - ----@alias fs.Stat.Type fs.DirEntry.Type ----@alias fs.DirEntry.Type "file" | "dir" | "symlink" | "unknown" - ----@class fs.DirEntry ----@field name string ----@field type fs.DirEntry.Type - ----@alias fs.WatchEvent "create" | "modify" | "delete" | "rename" - ----@class fs.Watcher ----@field poll fun() ----@field wait fun() ----@field close fun() - ----@class fs.raw ----@field exists fun(p: string): boolean ----@field isdir fun(p: string): boolean ----@field islink fun(p: string): boolean ----@field isfile fun(p: string): boolean ----@field readdir fun(p: string): (fun(): fs.DirEntry?)? ----@field mkdir fun(p: string): boolean ----@field mklink fun(src: string, dest: string): boolean ----@field rmlink fun(p: string): boolean ----@field stat fun(p: string): fs.Stat? ----@field lstat fun(p: string): fs.Stat? ----@field watch fun(p: string, callback: fun(event: fs.WatchEvent, name: string), opts: { recursive: boolean? }?): fs.Watcher? - -local rawfs ---@type fs.raw -if jit.os == "Windows" then - rawfs = require("fs.raw.windows") -elseif jit.os == "Linux" then - rawfs = require("fs.raw.linux") -elseif jit.os == "OSX" then - rawfs = require("fs.raw.macos") -else - error("Unsupported OS: " .. jit.os) -end - ----@class fs: fs.raw -local fs = {} - -for k, v in pairs(rawfs) do - fs[k] = v -end - ----@param p string ----@return string|nil -function fs.read(p) - local file = io.open(p, "rb") - if not file then - return nil - end - - local content = file:read("*a") - file:close() - - return content -end - ----@param p string ----@param content string ----@return boolean -function fs.write(p, content) - local file = io.open(p, "wb") - if not file then - return false - end - - file:write(content) - file:close() - - return true -end - ----@param src string ----@param dest string -function fs.copy(src, dest) - if fs.isfile(src) then - local content = fs.read(src) - if not content then return false end - fs.write(dest, content) - return true - end - - local iter = fs.readdir(src) - if not iter then return false end - if not fs.isdir(dest) and not fs.mkdir(dest) then return false end - - for entry in iter do - local srcPath = path.join(src, entry.name) - local destPath = path.join(dest, entry.name) - - local r = fs.copy(srcPath, destPath) - if not r then - return false - end - end - - return true -end - ----@param old string ----@param new string -function fs.move(old, new) - -- Fast path: os.rename works for both files and dirs on same device - if os.rename(old, new) then - return true - end - - -- Fallback to copy+delete for cross-device moves - if not fs.copy(old, new) then return false, "Failed to copy" end - local ok = fs.isdir(old) and fs.rmdir(old) or fs.delete(old) - if not ok then return false, "Failed to delete" end - - return true -end - ----@param p string -function fs.delete(p) - return os.remove(p) ~= nil -end - ---- Recursively removes a directory and all its contents. ----@param dir string ----@return boolean -function fs.rmdir(dir) - if not fs.exists(dir) then return false end - - -- Symlinks/junctions: remove the link itself without recursing into the target. - -- On Windows, junctions require RemoveDirectoryA (not DeleteFileA/os.remove). - if fs.islink(dir) then - return fs.rmlink(dir) - end - - local iter = fs.readdir(dir) - if not iter then return false end - - for entry in iter do - local full = path.join(dir, entry.name) - if entry.type == "symlink" then - fs.rmlink(full) - elseif entry.type == "dir" then - fs.rmdir(full) - else - os.remove(full) - end - end - - return fs.rmlink(dir) -end - -local sep = string.sub(package.config, 1, 1) - ----@param glob string -function fs.globToPattern(glob) - local pattern = glob - :gsub("([%^%$%(%)%%%.%[%]%+%-])", "%%%1") - :gsub("%*%*", "\001") - :gsub("%*", "[^/\\]*") - :gsub("%?", "[^/\\]") - :gsub("\001", ".*") - - return "^" .. pattern .. "$" -end - ----@param cwd string ----@param glob string ----@param opts { absolute: boolean, followSymlinks: boolean }? ----@return string[] -function fs.scan(cwd, glob, opts) - if not fs.isdir(cwd) then - error("not a directory: '" .. cwd .. "'") - end - - local absolute = opts and opts.absolute or false - local followSymlinks = opts and opts.followSymlinks or false - - local pattern = fs.globToPattern(glob) - local entries = {} - - local function dir(p) - local dirIter = fs.readdir(p) - if not dirIter then - return - end - - for entry in dirIter do - local entryPath = p .. sep .. entry.name - local entryType = entry.type - - -- d_type can be DT_UNKNOWN on some filesystems; fall back to lstat - if entryType == "unknown" then - local s = fs.lstat(entryPath) - entryType = s and s.type or "unknown" - end - - -- Resolve symlinks when followSymlinks is set - if entryType == "symlink" and followSymlinks then - local s = fs.stat(entryPath) - entryType = s and s.type or "unknown" - end - - if entryType == "dir" then - dir(entryPath) - elseif entryType == "file" then - if string.find(entryPath, pattern) then - if absolute then - entries[#entries + 1] = entryPath - else - entries[#entries + 1] = path.relative(cwd, entryPath) - end - end - end - end - end - - dir(cwd) - return entries -end - ----@param dir string -function fs.mkdirAll(dir) - if fs.isdir(dir) then return end - fs.mkdirAll(path.dirname(dir)) - fs.mkdir(dir) -end - -return fs diff --git a/packages/fs/src/raw/linux.lua b/packages/fs/src/raw/linux.lua deleted file mode 100644 index fcf2bb76..00000000 --- a/packages/fs/src/raw/linux.lua +++ /dev/null @@ -1,213 +0,0 @@ -local ffi = require("ffi") - -if jit.arch == "x64" then - ffi.cdef([[ - struct stat { - unsigned long st_dev; - unsigned long st_ino; - unsigned long st_nlink; - unsigned int st_mode; - unsigned int st_uid; - unsigned int st_gid; - unsigned int __pad0; - unsigned long st_rdev; - long st_size; - long st_blksize; - long st_blocks; - unsigned long st_atime; - unsigned long st_atime_nsec; - unsigned long st_mtime; - unsigned long st_mtime_nsec; - unsigned long st_ctime; - unsigned long st_ctime_nsec; - long __unused[3]; - }; - ]]) -elseif jit.arch == "arm64" then - ffi.cdef([[ - struct stat { - unsigned long st_dev; - unsigned long st_ino; - unsigned int st_mode; - unsigned int st_nlink; - unsigned int st_uid; - unsigned int st_gid; - unsigned long st_rdev; - unsigned long __pad1; - long st_size; - int st_blksize; - int __pad2; - long st_blocks; - long st_atime; - unsigned long st_atime_nsec; - long st_mtime; - unsigned long st_mtime_nsec; - long st_ctime; - unsigned long st_ctime_nsec; - unsigned int __unused[2]; - }; - ]]) -else - error("Unsupported architecture: " .. jit.arch) -end - -ffi.cdef([[ - struct dirent { - unsigned long d_ino; - unsigned long d_off; - unsigned short d_reclen; - unsigned char d_type; - char d_name[256]; - }; -]]) - -pcall(ffi.cdef, [[ - int inotify_init1(int flags); - int inotify_add_watch(int fd, const char* pathname, uint32_t mask); - int inotify_rm_watch(int fd, int wd); - long read(int fd, void* buf, size_t count); - int close(int fd); -]]) - -local IN_CREATE = 0x00000100 -local IN_DELETE = 0x00000200 -local IN_MODIFY = 0x00000002 -local IN_MOVED_FROM = 0x00000040 -local IN_MOVED_TO = 0x00000080 -local IN_NONBLOCK = 0x800 - -pcall(ffi.cdef, [[ - int fcntl(int fd, int cmd, ...); -]]) -local F_GETFL = 3 -local F_SETFL = 4 -local O_NONBLOCK = 0x800 - ----@class fs.raw.linux: fs.raw.posix -local fs = require("fs.raw.posix")(function(s, modeToStatType) - return { - size = s.st_size, - modifyTime = s.st_mtime, - accessTime = s.st_atime, - type = modeToStatType[bit.band(s.st_mode, 0xF000)], - mode = bit.band(s.st_mode, 0x1FF) - } -end) - ----@alias fs.WatchEvent "create" | "modify" | "delete" | "rename" - ----@class fs.Watcher ----@field close fun() ----@field poll fun() ----@field wait fun() - ---- Watch a path for changes. Calls callback(event, name) for each change. ---- Returns a watcher with :poll() (non-blocking), :wait() (blocking), and :close(). ----@param p string ----@param callback fun(event: fs.WatchEvent, name: string) ----@param opts { recursive: boolean? }? ----@return fs.Watcher? -function fs.watch(p, callback, opts) - local recursive = opts and opts.recursive or false - - local ifd = ffi.C.inotify_init1(IN_NONBLOCK) - if ifd < 0 then return nil end - - local mask = bit.bor(IN_CREATE, IN_DELETE, IN_MODIFY, IN_MOVED_FROM, IN_MOVED_TO) - - -- wd -> absolute dir path, for resolving event names in recursive mode - local wdPaths = {} ---@type table - - local function addWatch(dir) - local wd = ffi.C.inotify_add_watch(ifd, dir, mask) - if wd >= 0 then wdPaths[tonumber(wd)] = dir end - return wd - end - - if addWatch(p) < 0 then - ffi.C.close(ifd) - return nil - end - - if recursive then - local function walkDirs(dir) - local iter = fs.readdir(dir) - if not iter then return end - for entry in iter do - if entry.type == "dir" then - local sub = dir .. "/" .. entry.name - addWatch(sub) - walkDirs(sub) - end - end - end - walkDirs(p) - end - - local bufSize = 4096 - local buf = ffi.new("uint8_t[?]", bufSize) - - local function drain() - local n = ffi.C.read(ifd, buf, bufSize) - if n <= 0 then return end - - local i = 0 - while i < n do - local ptr = buf + i - local wd = ffi.cast("int32_t*", ptr)[0] - local evMask = ffi.cast("uint32_t*", ptr + 4)[0] - local nameLen = ffi.cast("uint32_t*", ptr + 12)[0] - local name = nameLen > 0 and ffi.string(ptr + 16) or "" - - -- In recursive mode, prefix name with the relative subdir path - if recursive and wdPaths[tonumber(wd)] and wdPaths[tonumber(wd)] ~= p then - local rel = string.sub(wdPaths[tonumber(wd)], #p + 2) - name = rel .. "/" .. name - end - - local event ---@type fs.WatchEvent - if bit.band(evMask, IN_CREATE) ~= 0 then - event = "create" - -- Watch newly created subdirectories - if recursive and bit.band(evMask, 0x40000000) ~= 0 then -- IN_ISDIR - local newDir = wdPaths[tonumber(wd)] .. "/" .. (nameLen > 0 and ffi.string(ptr + 16) or "") - addWatch(newDir) - end - elseif bit.band(evMask, IN_DELETE) ~= 0 then - event = "delete" - elseif bit.band(evMask, IN_MODIFY) ~= 0 then - event = "modify" - elseif bit.band(evMask, bit.bor(IN_MOVED_FROM, IN_MOVED_TO)) ~= 0 then - event = "rename" - end - - if event then callback(event, name) end - i = i + 16 + nameLen - end - end - - ---@type fs.Watcher - local watcher = {} - - function watcher.poll() - drain() - end - - function watcher.wait() - local flags = ffi.C.fcntl(ifd, F_GETFL) - ffi.C.fcntl(ifd, F_SETFL, bit.band(flags, bit.bnot(O_NONBLOCK))) - drain() - ffi.C.fcntl(ifd, F_SETFL, flags) - end - - function watcher.close() - for wd in pairs(wdPaths) do - ffi.C.inotify_rm_watch(ifd, wd) - end - ffi.C.close(ifd) - end - - return watcher -end - -return fs diff --git a/packages/fs/src/raw/macos.lua b/packages/fs/src/raw/macos.lua deleted file mode 100644 index 59cab97c..00000000 --- a/packages/fs/src/raw/macos.lua +++ /dev/null @@ -1,324 +0,0 @@ -local ffi = require("ffi") - -ffi.cdef([[typedef uint64_t ino_t;]]) - -ffi.cdef([[ - struct timespec { - long tv_sec; - long tv_nsec; - }; -]]) - -if jit.arch == "arm64" then - ffi.cdef([[ - struct stat { - int32_t st_dev; - uint16_t st_mode; - uint16_t st_nlink; - ino_t st_ino; - uint32_t st_uid; - uint32_t st_gid; - int32_t st_rdev; - struct timespec st_atimespec; - struct timespec st_mtimespec; - struct timespec st_ctimespec; - struct timespec st_birthtimespec; - int64_t st_size; - int64_t st_blocks; - int32_t st_blksize; - uint32_t st_flags; - uint32_t st_gen; - int32_t st_lspare; - int64_t st_qspare[2]; - }; - - struct dirent { - ino_t d_ino; - uint64_t d_seekoff; - uint16_t d_reclen; - uint16_t d_namlen; - uint8_t d_type; - char d_name[1024]; - }; - ]]) -else - -- x86-64 macOS: plain `stat` uses old 32-bit inode layout; must use stat$INODE64 - ffi.cdef([[ - struct stat { - int32_t st_dev; - uint16_t st_mode; - uint16_t st_nlink; - ino_t st_ino; - uint32_t st_uid; - uint32_t st_gid; - int32_t st_rdev; - int32_t st_rdev_pad; - struct timespec st_atimespec; - struct timespec st_mtimespec; - struct timespec st_ctimespec; - struct timespec st_birthtimespec; - int64_t st_size; - int64_t st_blocks; - int32_t st_blksize; - uint32_t st_flags; - uint32_t st_gen; - int32_t st_lspare; - int64_t st_qspare[2]; - }; - int stat(const char* pathname, struct stat* statbuf) asm("stat$INODE64"); - int lstat(const char* pathname, struct stat* statbuf) asm("lstat$INODE64"); - - struct dirent { - uint32_t d_ino; - uint16_t d_reclen; - uint8_t d_type; - uint8_t d_namlen; - char d_name[1024]; - }; - ]]) -end - -pcall(ffi.cdef, [[ - int kqueue(void); - typedef int64_t intptr_t; - typedef uint64_t uintptr_t; - - struct kevent { - uintptr_t ident; - int16_t filter; - uint16_t flags; - uint32_t fflags; - intptr_t data; - void* udata; - }; - - struct timespec_kq { - long tv_sec; - long tv_nsec; - }; - - int kevent(int kq, const struct kevent* changelist, int nchanges, - struct kevent* eventlist, int nevents, const struct timespec_kq* timeout); - - int open(const char* path, int oflag, ...); - int close(int fd); -]]) - -local O_EVTONLY = 0x8000 -local EVFILT_VNODE = -4 -local EV_ADD = 0x0001 -local EV_ENABLE = 0x0004 -local EV_CLEAR = 0x0020 -local NOTE_WRITE = 0x00000002 -local NOTE_DELETE = 0x00000001 -local NOTE_RENAME = 0x00000020 -local NOTE_ATTRIB = 0x00000008 - ----@class fs.raw.macos: fs.raw.posix -local fs = require("fs.raw.posix")(function(s, modeToStatType) - return { - size = s.st_size, - modifyTime = s.st_mtimespec.tv_sec, - accessTime = s.st_atimespec.tv_sec, - type = modeToStatType[bit.band(s.st_mode, 0xF000)], - mode = bit.band(s.st_mode, 0x1FF) - } -end) - ----@alias fs.WatchEvent "create" | "modify" | "delete" | "rename" - ----@class fs.Watcher ----@field close fun() ----@field poll fun() ----@field wait fun() - ---- Watch a path for changes. Calls callback(event, name) for each change. ---- Returns a watcher with :poll() (non-blocking), :wait() (blocking), and :close(). ----@param p string ----@param callback fun(event: fs.WatchEvent, name: string) ----@param opts { recursive: boolean? }? ----@return fs.Watcher? -function fs.watch(p, callback, opts) - local recursive = opts and opts.recursive or false - - local kq = ffi.C.kqueue() - if kq < 0 then return nil end - - local isDir = fs.isdir(p) - - local change = ffi.new("struct kevent[1]") - local function register(fd) - change[0].ident = fd - change[0].filter = EVFILT_VNODE - change[0].flags = bit.bor(EV_ADD, EV_ENABLE, EV_CLEAR) - change[0].fflags = bit.bor(NOTE_WRITE, NOTE_DELETE, NOTE_RENAME, NOTE_ATTRIB) - change[0].data = 0 - change[0].udata = nil - ffi.C.kevent(kq, change, 1, nil, 0, nil) - end - - local dirfd = ffi.C.open(p, O_EVTONLY) - if dirfd < 0 then - ffi.C.close(kq); return nil - end - register(dirfd) - - -- fd -> relative path (from p) for all watched entries - local filefds = {} ---@type table fd -> relative path - -- fd -> absolute dir path for watched subdirs (recursive mode) - local subdirfds = {} ---@type table fd -> absolute dir path - -- dir absolute path -> snapshot of children names - local dirSnaps = {} ---@type table> - - local function watchEntry(absPath, relPath, isDirectory) - local fd = ffi.C.open(absPath, O_EVTONLY) - if fd < 0 then return end - register(fd) - if isDirectory then - subdirfds[tonumber(fd)] = absPath - else - filefds[tonumber(fd)] = relPath - end - end - - local function snapDir(absDir) - local snap = {} - local iter = fs.readdir(absDir) - if iter then for entry in iter do snap[entry.name] = true end end - return snap - end - - local function walkDir(absDir, relBase) - local snap = snapDir(absDir) - dirSnaps[absDir] = snap - for name in pairs(snap) do - local absChild = absDir .. "/" .. name - local relChild = relBase ~= "" and (relBase .. "/" .. name) or name - local childIsDir = fs.isdir(absChild) - watchEntry(absChild, relChild, childIsDir) - if recursive and childIsDir then - walkDir(absChild, relChild) - end - end - end - - local prev ---@type table? - if isDir then - prev = snapDir(p) - dirSnaps[p] = prev - for name in pairs(prev) do - local absChild = p .. "/" .. name - local childIsDir = fs.isdir(absChild) - watchEntry(absChild, name, childIsDir) - if recursive and childIsDir then - walkDir(absChild, name) - end - end - end - - local events = ffi.new("struct kevent[16]") - local zero = ffi.new("struct timespec_kq[1]", { { 0, 0 } }) - - local function process(n) - for i = 0, n - 1 do - local ident = tonumber(events[i].ident) - local ff = events[i].fflags - - if ident == tonumber(dirfd) then - if isDir and bit.band(ff, NOTE_WRITE) ~= 0 then - local curr = snapDir(p) - for name in pairs(curr) do - if not prev[name] then - local absChild = p .. "/" .. name - local childIsDir = fs.isdir(absChild) - callback("create", name) - watchEntry(absChild, name, childIsDir) - if recursive and childIsDir then walkDir(absChild, name) end - end - end - for name in pairs(prev) do - if not curr[name] then callback("delete", name) end - end - prev = curr - dirSnaps[p] = curr - end - if bit.band(ff, NOTE_DELETE) ~= 0 then - callback("delete", p) - elseif bit.band(ff, NOTE_RENAME) ~= 0 then - callback("rename", p) - end - elseif subdirfds[ident] then - -- Event on a watched subdir (recursive mode) - local absDir = subdirfds[ident] - local relDir = string.sub(absDir, #p + 2) - if bit.band(ff, NOTE_WRITE) ~= 0 then - local oldSnap = dirSnaps[absDir] or {} - local curr = snapDir(absDir) - for name in pairs(curr) do - if not oldSnap[name] then - local absChild = absDir .. "/" .. name - local relChild = relDir .. "/" .. name - local childIsDir = fs.isdir(absChild) - callback("create", relChild) - watchEntry(absChild, relChild, childIsDir) - if childIsDir then walkDir(absChild, relChild) end - end - end - for name in pairs(oldSnap) do - if not curr[name] then callback("delete", relDir .. "/" .. name) end - end - dirSnaps[absDir] = curr - end - if bit.band(ff, NOTE_DELETE) ~= 0 then - callback("delete", relDir) - elseif bit.band(ff, NOTE_RENAME) ~= 0 then - callback("rename", relDir) - end - else - local relPath = filefds[ident] - if relPath then - if bit.band(ff, NOTE_WRITE) ~= 0 or bit.band(ff, NOTE_ATTRIB) ~= 0 then - callback("modify", relPath) - end - if bit.band(ff, NOTE_DELETE) ~= 0 or bit.band(ff, NOTE_RENAME) ~= 0 then - ffi.C.close(ident) - filefds[ident] = nil - end - elseif not isDir then - if bit.band(ff, NOTE_WRITE) ~= 0 or bit.band(ff, NOTE_ATTRIB) ~= 0 then - callback("modify", p) - end - if bit.band(ff, NOTE_DELETE) ~= 0 then - callback("delete", p) - elseif bit.band(ff, NOTE_RENAME) ~= 0 then - callback("rename", p) - end - end - end - end - end - - ---@type fs.Watcher - local watcher = {} - - function watcher.poll() - local n = ffi.C.kevent(kq, nil, 0, events, 16, zero) - process(n) - end - - function watcher.wait() - local n = ffi.C.kevent(kq, nil, 0, events, 16, nil) - process(n) - end - - function watcher.close() - for fd in pairs(filefds) do ffi.C.close(fd) end - for fd in pairs(subdirfds) do ffi.C.close(fd) end - ffi.C.close(dirfd) - ffi.C.close(kq) - end - - return watcher -end - -return fs diff --git a/packages/fs/src/raw/posix.lua b/packages/fs/src/raw/posix.lua deleted file mode 100644 index 5c9d34ad..00000000 --- a/packages/fs/src/raw/posix.lua +++ /dev/null @@ -1,142 +0,0 @@ -local ffi = require("ffi") - -ffi.cdef([[ - typedef struct __dirstream DIR; - DIR* opendir(const char* name); - int closedir(DIR* dirp); - int mkdir(const char* pathname, unsigned int mode); - int symlink(const char* target, const char* linkpath); - int chmod(const char* pathname, unsigned int mode); -]]) - ----@type table -local dTypeToEntryType = { - [0] = "unknown", - [4] = "dir", - [8] = "file", - [10] = "symlink" -} - ----@type table -local modeToStatType = { - [0x4000] = "dir", - [0x8000] = "file", - [0xA000] = "symlink" -} - ---- Call after defining struct dirent and struct stat in ffi. ----@param rawToCrossStat fun(s: ffi.cdata*, modeToStatType: table): fs.Stat ----@return fs.raw.posix -return function(rawToCrossStat) - ffi.cdef([[ - struct dirent* readdir(DIR* dirp); - int stat(const char* pathname, struct stat* statbuf); - int lstat(const char* pathname, struct stat* statbuf); - ]]) - - ---@class fs.raw.posix: fs.raw - local fs = {} - - local newStat = ffi.typeof("struct stat") - - local function rawStat(p) - local buf = newStat() - if ffi.C.stat(p, buf) ~= 0 then return nil end - return buf - end - - local function rawLstat(p) - local buf = newStat() - if ffi.C.lstat(p, buf) ~= 0 then return nil end - return buf - end - - ---@param p string - ---@return (fun(): fs.DirEntry?)? - function fs.readdir(p) - local dir = ffi.C.opendir(p) - if dir == nil then return nil end - - return function() - while true do - local entry = ffi.C.readdir(dir) - if entry == nil then - ffi.C.closedir(dir) - return nil - end - - local name = ffi.string(entry.d_name) - if name ~= "." and name ~= ".." then - return { - name = name, - type = dTypeToEntryType[entry.d_type] or "unknown" - } - end - end - end - end - - ---@param p string - function fs.exists(p) - return rawStat(p) ~= nil - end - - ---@param p string - function fs.stat(p) - local s = rawStat(p) - if s == nil then return nil end - return rawToCrossStat(s, modeToStatType) - end - - ---@param p string - function fs.lstat(p) - local s = rawLstat(p) - if s == nil then return nil end - return rawToCrossStat(s, modeToStatType) - end - - ---@param p string - function fs.isdir(p) - local s = rawStat(p) - if s == nil then return false end - return bit.band(s.st_mode, 0x4000) ~= 0 - end - - ---@param p string - function fs.isfile(p) - local s = rawStat(p) - if s == nil then return false end - return bit.band(s.st_mode, 0x8000) ~= 0 - end - - ---@param p string - function fs.islink(p) - local s = rawLstat(p) - if s == nil then return false end - return bit.band(s.st_mode, 0xA000) ~= 0 - end - - ---@param p string - function fs.mkdir(p) - return ffi.C.mkdir(p, 511) == 0 - end - - ---@param src string - ---@param dest string - function fs.mklink(src, dest) - return ffi.C.symlink(src, dest) == 0 - end - - ---@param p string - function fs.rmlink(p) - return os.remove(p) ~= nil - end - - ---@param p string - ---@param mode number - function fs.chmod(p, mode) - return ffi.C.chmod(p, mode) == 0 - end - - return fs -end diff --git a/packages/fs/src/raw/windows.lua b/packages/fs/src/raw/windows.lua deleted file mode 100644 index 8bd51e95..00000000 --- a/packages/fs/src/raw/windows.lua +++ /dev/null @@ -1,586 +0,0 @@ -local ffi = require("ffi") - -ffi.cdef([[ - typedef void* HANDLE; - typedef uint32_t DWORD; - typedef uint16_t WORD; - typedef unsigned char BYTE; - typedef int BOOL; - typedef unsigned short WCHAR; - - typedef struct { - DWORD dwLowDateTime; - DWORD dwHighDateTime; - } FILETIME; - - typedef struct { - DWORD dwFileAttributes; - FILETIME ftCreationTime; - FILETIME ftLastAccessTime; - FILETIME ftLastWriteTime; - DWORD nFileSizeHigh; - DWORD nFileSizeLow; - DWORD dwReserved0; - DWORD dwReserved1; - char cFileName[260]; - char cAlternateFileName[14]; - } WIN32_FIND_DATAA; - - HANDLE FindFirstFileA(const char* lpFileName, WIN32_FIND_DATAA* lpFindFileData); - BOOL FindNextFileA(HANDLE hFindFile, WIN32_FIND_DATAA* lpFindFileData); - BOOL FindClose(HANDLE hFindFile); - BOOL CreateDirectoryA(const char* lpPathName, void* lpSecurityAttributes); - BOOL CreateSymbolicLinkA(const char* lpSymlinkFileName, const char* lpTargetFileName, DWORD dwFlags); - DWORD GetFileAttributesA(const char* lpFileName); - - typedef struct { - DWORD dwFileAttributes; - FILETIME ftCreationTime; - FILETIME ftLastAccessTime; - FILETIME ftLastWriteTime; - DWORD nFileSizeHigh; - DWORD nFileSizeLow; - } WIN32_FILE_ATTRIBUTE_DATA; - - BOOL GetFileAttributesExA(const char* lpFileName, int fInfoLevelClass, WIN32_FILE_ATTRIBUTE_DATA* lpFileInformation); - - HANDLE CreateFileA( - const char* lpFileName, - DWORD dwDesiredAccess, - DWORD dwShareMode, - void* lpSecurityAttributes, - DWORD dwCreationDisposition, - DWORD dwFlagsAndAttributes, - HANDLE hTemplateFile - ); - - BOOL DeviceIoControl( - HANDLE hDevice, - DWORD dwIoControlCode, - void* lpInBuffer, - DWORD nInBufferSize, - void* lpOutBuffer, - DWORD nOutBufferSize, - DWORD* lpBytesReturned, - void* lpOverlapped - ); - - BOOL CloseHandle(HANDLE hObject); - - DWORD GetFullPathNameA( - const char* lpFileName, - DWORD nBufferLength, - char* lpBuffer, - char** lpFilePart - ); - - BOOL RemoveDirectoryA(const char* lpPathName); - BOOL DeleteFileA(const char* lpFileName); - BOOL CreateHardLinkA(const char* lpFileName, const char* lpExistingFileName, void* lpSecurityAttributes); - - HANDLE CreateIoCompletionPort(HANDLE FileHandle, HANDLE ExistingCompletionPort, - uintptr_t CompletionKey, DWORD NumberOfConcurrentThreads); - BOOL ReadDirectoryChangesW( - HANDLE hDirectory, - void* lpBuffer, - DWORD nBufferLength, - BOOL bWatchSubtree, - DWORD dwNotifyFilter, - DWORD* lpBytesReturned, - void* lpOverlapped, - void* lpCompletionRoutine - ); - BOOL GetOverlappedResult(HANDLE hFile, void* lpOverlapped, DWORD* lpNumberOfBytesTransferred, BOOL bWait); - BOOL HasOverlappedIoCompleted(void* lpOverlapped); -]]) - -local kernel32 = ffi.load("kernel32") - -local INVALID_HANDLE_VALUE = ffi.cast("HANDLE", -1) -local INVALID_FILE_ATTRIBUTES = 0xFFFFFFFF -local FILE_ATTRIBUTE_DIRECTORY = 0x10 -local FILE_ATTRIBUTE_REPARSE_POINT = 0x400 - ----@class fs.raw.windows: fs.raw -local fs = {} - ----@param p string ----@return (fun(): fs.DirEntry?)? -function fs.readdir(p) - local searchPath = p .. "\\*" - - ---@type { cFileName: string, dwFileAttributes: number } - local findData = ffi.new("WIN32_FIND_DATAA") - - local handle = kernel32.FindFirstFileA(searchPath, findData) - if handle == INVALID_HANDLE_VALUE then - return nil - end - - local first = true - - return function() - while true do - local hasNext - if first then - first = false - hasNext = true - else - hasNext = kernel32.FindNextFileA(handle, findData) ~= 0 - end - - if not hasNext then - kernel32.FindClose(handle) - return nil - end - - local name = ffi.string(findData.cFileName) - if name ~= "." and name ~= ".." then - local isDir = bit.band(findData.dwFileAttributes, FILE_ATTRIBUTE_DIRECTORY) ~= 0 - local isLink = bit.band(findData.dwFileAttributes, FILE_ATTRIBUTE_REPARSE_POINT) ~= 0 - - local entryType - if isLink then - entryType = "symlink" - elseif isDir then - entryType = "dir" - else - entryType = "file" - end - - return { - name = name, - type = entryType - } - end - end - end -end - ----@param p string ----@return number? -local function getFileAttrs(p) - local attrs = kernel32.GetFileAttributesA(p) - if attrs == INVALID_FILE_ATTRIBUTES then - return nil - end - return attrs -end - ----@param p string ----@return boolean -function fs.exists(p) - return getFileAttrs(p) ~= nil -end - ----@param p string -function fs.isdir(p) - local attrs = getFileAttrs(p) - if attrs == nil then - return false - end - - return bit.band(attrs, FILE_ATTRIBUTE_DIRECTORY) ~= 0 -end - ----@param p string -function fs.mkdir(p) - return kernel32.CreateDirectoryA(p, nil) ~= 0 -end - -local GENERIC_WRITE = 0x40000000 -local OPEN_EXISTING = 3 -local FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 -local FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 -local FSCTL_SET_REPARSE_POINT = 0x000900A4 -local IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003 - ---- Resolves a path to an absolute path using Win32 GetFullPathNameA. ----@param p string ----@return string? -local function getFullPath(p) - local buf = ffi.new("char[?]", 1024) - local len = kernel32.GetFullPathNameA(p, 1024, buf, nil) - if len == 0 or len >= 1024 then - return nil - end - return ffi.string(buf, len) -end - ---- Creates an NTFS junction point (directory only). ---- Junctions do not require elevated privileges, unlike symlinks. ----@param src string # Target directory (must be absolute or will be resolved) ----@param dest string # Junction path to create ----@return boolean -local function createJunction(src, dest) - -- Junctions require an absolute target path - local absTarget = getFullPath(src) - if not absTarget then - return false - end - - -- Create the junction directory - if kernel32.CreateDirectoryA(dest, nil) == 0 then - return false - end - - -- Build the NT path: \??\C:\path\to\target - local ntTarget = "\\??\\" .. absTarget - - -- Encode the target as UTF-16LE - local ntTargetW = {} ---@type string[] - for i = 1, #ntTarget do - ntTargetW[#ntTargetW + 1] = string.sub(ntTarget, i, i) .. "\0" - end - local targetBytes = table.concat(ntTargetW) - local targetByteLen = #targetBytes - - -- Build REPARSE_DATA_BUFFER for mount point (junction) - -- Layout: - -- DWORD ReparseTag - -- WORD ReparseDataLength - -- WORD Reserved - -- WORD SubstituteNameOffset - -- WORD SubstituteNameLength - -- WORD PrintNameOffset - -- WORD PrintNameLength - -- WCHAR PathBuffer[...] (SubstituteName + NUL + PrintName + NUL) - local pathBufSize = targetByteLen + 2 + 2 -- substitute name + NUL + print name (empty) + NUL - local reparseDataLen = 8 + pathBufSize -- 4 WORDs (8 bytes) + path buffer - local totalSize = 8 + reparseDataLen -- header (tag + length + reserved) + data - - local buf = ffi.new("uint8_t[?]", totalSize) - local ptr = ffi.cast("uint8_t*", buf) - - -- ReparseTag (DWORD) - ffi.cast("uint32_t*", ptr)[0] = IO_REPARSE_TAG_MOUNT_POINT - -- ReparseDataLength (WORD) - ffi.cast("uint16_t*", ptr + 4)[0] = reparseDataLen - -- Reserved (WORD) - ffi.cast("uint16_t*", ptr + 6)[0] = 0 - -- SubstituteNameOffset (WORD) - ffi.cast("uint16_t*", ptr + 8)[0] = 0 - -- SubstituteNameLength (WORD) - without null terminator - ffi.cast("uint16_t*", ptr + 10)[0] = targetByteLen - -- PrintNameOffset (WORD) - after substitute name + null terminator - ffi.cast("uint16_t*", ptr + 12)[0] = targetByteLen + 2 - -- PrintNameLength (WORD) - empty print name - ffi.cast("uint16_t*", ptr + 14)[0] = 0 - - -- PathBuffer: substitute name - ffi.copy(ptr + 16, targetBytes, targetByteLen) - -- Null terminator for substitute name (2 bytes) - ffi.cast("uint16_t*", ptr + 16 + targetByteLen)[0] = 0 - -- Null terminator for print name (2 bytes) - ffi.cast("uint16_t*", ptr + 16 + targetByteLen + 2)[0] = 0 - - -- Open the junction directory with reparse point access - local handle = kernel32.CreateFileA( - dest, - GENERIC_WRITE, - 0, - nil, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS + FILE_FLAG_OPEN_REPARSE_POINT, - nil - ) - - if handle == INVALID_HANDLE_VALUE then - kernel32.RemoveDirectoryA(dest) - return false - end - - local bytesReturned = ffi.new("DWORD[1]") - local ok = kernel32.DeviceIoControl( - handle, - FSCTL_SET_REPARSE_POINT, - buf, - totalSize, - nil, - 0, - bytesReturned, - nil - ) - - kernel32.CloseHandle(handle) - - if ok == 0 then - kernel32.RemoveDirectoryA(dest) - return false - end - - return true -end - ---- Removes a symlink or junction without following it. ----@param p string ----@return boolean -function fs.rmlink(p) - local attrs = getFileAttrs(p) - if attrs ~= nil and bit.band(attrs, FILE_ATTRIBUTE_DIRECTORY) ~= 0 then - return kernel32.RemoveDirectoryA(p) ~= 0 - end - return kernel32.DeleteFileA(p) ~= 0 -end - ----@param src string ----@param dest string -function fs.mklink(src, dest) - if fs.isdir(src) then - return createJunction(src, dest) - end - - if kernel32.CreateSymbolicLinkA(dest, src, 0x2) ~= 0 then - return true - end - return kernel32.CreateHardLinkA(dest, src, nil) ~= 0 -end - ----@param p string -function fs.islink(p) - local attrs = getFileAttrs(p) - if attrs == nil then - return false - end - - return bit.band(attrs, FILE_ATTRIBUTE_REPARSE_POINT) ~= 0 -end - ----@param p string -function fs.isfile(p) - local attrs = getFileAttrs(p) - if attrs == nil then - return false - end - - return bit.band(attrs, FILE_ATTRIBUTE_DIRECTORY) == 0 and bit.band(attrs, FILE_ATTRIBUTE_REPARSE_POINT) == 0 -end - --- FILETIME is 100ns intervals since 1601-01-01. Unix epoch is 1970-01-01. --- Difference: 11644473600 seconds = 116444736000000000 in 100ns units. -local EPOCH_DIFF = 116444736000000000ULL - ----@param ft { dwLowDateTime: number, dwHighDateTime: number } -local function filetimeToUnix(ft) - local ticks = ffi.cast("uint64_t", ft.dwHighDateTime) * 0x100000000ULL + ft.dwLowDateTime - return tonumber((ticks - EPOCH_DIFF) / 10000000ULL) -end - ----@param attrs number ----@return fs.Stat.Type -local function attrsToType(attrs) - if bit.band(attrs, FILE_ATTRIBUTE_REPARSE_POINT) ~= 0 then - return "symlink" - elseif bit.band(attrs, FILE_ATTRIBUTE_DIRECTORY) ~= 0 then - return "dir" - else - return "file" - end -end - ----@class fs.raw.windows.Stat ----@field dwFileAttributes number ----@field ftLastAccessTime { dwLowDateTime: number, dwHighDateTime: number } ----@field ftLastWriteTime { dwLowDateTime: number, dwHighDateTime: number } ----@field nFileSizeHigh number ----@field nFileSizeLow number - ----@type fun(): fs.raw.windows.Stat ----@diagnostic disable-next-line: assign-type-mismatch -local newFileAttrData = ffi.typeof("WIN32_FILE_ATTRIBUTE_DATA") - ----@param s fs.raw.windows.Stat -local function fileSize(s) - return tonumber(s.nFileSizeHigh) * 0x100000000 + tonumber(s.nFileSizeLow) -end - ----@param s fs.raw.windows.Stat ----@param type fs.Stat.Type ----@return fs.Stat -local function rawToCrossStat(s, type) - return { - size = fileSize(s), - accessTime = filetimeToUnix(s.ftLastAccessTime), - modifyTime = filetimeToUnix(s.ftLastWriteTime), - type = type - } -end - ----@param p string ----@return fs.Stat? -function fs.stat(p) - local data = newFileAttrData() - if kernel32.GetFileAttributesExA(p, 0, data) == 0 then - return nil - end - - local type = bit.band(data.dwFileAttributes, FILE_ATTRIBUTE_DIRECTORY) ~= 0 and "dir" or "file" - return rawToCrossStat(data, type) -end - ----@param p string ----@return fs.Stat? -function fs.lstat(p) - local data = newFileAttrData() - if kernel32.GetFileAttributesExA(p, 0, data) == 0 then - return nil - end - - return rawToCrossStat(data, attrsToType(data.dwFileAttributes)) -end - ----@alias fs.WatchEvent "create" | "modify" | "delete" | "rename" - ----@class fs.Watcher ----@field close fun() ----@field poll fun() - -local FILE_LIST_DIRECTORY = 0x0001 -local FILE_SHARE_READ = 0x00000001 -local FILE_SHARE_WRITE = 0x00000002 -local FILE_SHARE_DELETE = 0x00000004 -local OPEN_EXISTING_W = 3 -local FILE_FLAG_BACKUP_SEMANTICS_W = 0x02000000 -local FILE_FLAG_OVERLAPPED = 0x40000000 - -local FILE_NOTIFY_CHANGE_FILE_NAME = 0x00000001 -local FILE_NOTIFY_CHANGE_DIR_NAME = 0x00000002 -local FILE_NOTIFY_CHANGE_LAST_WRITE = 0x00000010 -local FILE_NOTIFY_CHANGE_SIZE = 0x00000008 - -local FILE_ACTION_ADDED = 1 -local FILE_ACTION_REMOVED = 2 -local FILE_ACTION_MODIFIED = 3 -local FILE_ACTION_RENAMED_OLD = 4 -local FILE_ACTION_RENAMED_NEW = 5 - -ffi.cdef([[ - typedef struct { - uintptr_t Internal; - uintptr_t InternalHigh; - DWORD Offset; - DWORD OffsetHigh; - HANDLE hEvent; - } OVERLAPPED_W; - - HANDLE CreateEventA(void* lpEventAttributes, BOOL bManualReset, BOOL bInitialState, const char* lpName); - DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds); -]]) - -local WAIT_OBJECT_0 = 0 -local WAIT_TIMEOUT = 0x102 - ---- Watch a directory for changes. Calls callback(event, name) for each change. ---- Returns a watcher with :poll() (non-blocking), :wait() (blocking), and :close(). ----@param p string ----@param callback fun(event: fs.WatchEvent, name: string) ----@param opts { recursive: boolean? }? ----@return fs.Watcher? -function fs.watch(p, callback, opts) - local recursive = opts and opts.recursive or false - - local handle = kernel32.CreateFileA( - p, - FILE_LIST_DIRECTORY, - bit.bor(FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE), - nil, - OPEN_EXISTING_W, - bit.bor(FILE_FLAG_BACKUP_SEMANTICS_W, FILE_FLAG_OVERLAPPED), - nil - ) - if handle == INVALID_HANDLE_VALUE then return nil end - - local event = kernel32.CreateEventA(nil, 1, 0, nil) -- manual reset, initially unsignaled - if event == nil then - kernel32.CloseHandle(handle); return nil - end - - local bufSize = 4096 - local buf = ffi.new("uint8_t[?]", bufSize) - local overlapped = ffi.new("OVERLAPPED_W[1]") - overlapped[0].hEvent = event - local bytesReturned = ffi.new("DWORD[1]") - - local notifyFilter = bit.bor( - FILE_NOTIFY_CHANGE_FILE_NAME, - FILE_NOTIFY_CHANGE_DIR_NAME, - FILE_NOTIFY_CHANGE_LAST_WRITE, - FILE_NOTIFY_CHANGE_SIZE - ) - - local function issueRead() - ffi.fill(overlapped, ffi.sizeof("OVERLAPPED_W")) - overlapped[0].hEvent = event - kernel32.ReadDirectoryChangesW(handle, buf, bufSize, recursive and 1 or 0, notifyFilter, bytesReturned, - overlapped, nil) - end - - issueRead() - - local INFINITE = 0xFFFFFFFF - - local function drain() - local transferred = ffi.new("DWORD[1]") - if kernel32.GetOverlappedResult(handle, overlapped, transferred, 0) == 0 then - issueRead(); return - end - - local n = tonumber(transferred[0]) - if not n or n == 0 then - issueRead(); return - end - - -- FILE_NOTIFY_INFORMATION: NextEntryOffset(4), Action(4), FileNameLength(4), FileName[...] - local i = 0 - while i < n do - local ptr = buf + i - local nextOff = ffi.cast("uint32_t*", ptr)[0] - local action = ffi.cast("uint32_t*", ptr + 4)[0] - local nameLen = ffi.cast("uint32_t*", ptr + 8)[0] - local name = "" - for j = 0, nameLen / 2 - 1 do - local ch = ffi.cast("uint16_t*", ptr + 12)[j] - name = name .. string.char(ch < 128 and ch or 63) - end - - local ev ---@type fs.WatchEvent - if action == FILE_ACTION_ADDED then - ev = "create" - elseif action == FILE_ACTION_REMOVED then - ev = "delete" - elseif action == FILE_ACTION_MODIFIED then - ev = "modify" - elseif action == FILE_ACTION_RENAMED_OLD or action == FILE_ACTION_RENAMED_NEW then - ev = "rename" - end - - if ev then callback(ev, name) end - if nextOff == 0 then break end - i = i + nextOff - end - - issueRead() - end - - ---@type fs.Watcher - local watcher = {} - - function watcher.poll() - if kernel32.WaitForSingleObject(event, 0) ~= WAIT_OBJECT_0 then return end - drain() - end - - function watcher.wait() - kernel32.WaitForSingleObject(event, INFINITE) - drain() - end - - function watcher.close() - kernel32.CloseHandle(event) - kernel32.CloseHandle(handle) - end - - return watcher -end - -return fs diff --git a/packages/fs/tests/fs.test.lua b/packages/fs/tests/fs.test.lua deleted file mode 100644 index 3996b7b3..00000000 --- a/packages/fs/tests/fs.test.lua +++ /dev/null @@ -1,699 +0,0 @@ -local test = require("lde-test") -local fs = require("fs") -local env = require("env") -local path = require("path") - -local tmpBase = path.join(env.tmpdir(), "lde-fs-pkg-tests") -fs.rmdir(tmpBase) -fs.mkdir(tmpBase) - --- helpers -local function tmp(name) - return path.join(tmpBase, name) -end - --- --- exists / isfile / isdir --- - -test.it("exists returns false for missing path", function() - test.falsy(fs.exists(tmp("no-such-file"))) -end) - -test.it("write creates a file and exists returns true", function() - local p = tmp("hello.txt") - test.truthy(fs.write(p, "hello")) - test.truthy(fs.exists(p)) - test.truthy(fs.isfile(p)) - test.falsy(fs.isdir(p)) -end) - -test.it("read returns written content", function() - local p = tmp("read-test.txt") - fs.write(p, "content123") - test.equal(fs.read(p), "content123") -end) - -test.it("read returns nil for missing file", function() - test.falsy(fs.read(tmp("missing.txt"))) -end) - --- --- mkdir / isdir --- - -test.it("mkdir creates a directory", function() - local d = tmp("mydir") - test.truthy(fs.mkdir(d)) - test.truthy(fs.isdir(d)) - test.falsy(fs.isfile(d)) -end) - --- --- stat --- - -test.it("stat returns size and modifyTime for a file", function() - local p = tmp("stat-test.txt") - fs.write(p, "abcde") - local s = fs.stat(p) - test.truthy(s) - test.equal(s.size, 5) - test.truthy(s.modifyTime) - test.equal(s.type, "file") -end) - -test.it("stat returns type=dir for a directory", function() - local d = tmp("stat-dir") - fs.mkdir(d) - local s = fs.stat(d) - test.truthy(s) - test.equal(s.type, "dir") -end) - -test.it("stat returns nil for missing path", function() - test.falsy(fs.stat(tmp("nope"))) -end) - --- --- delete --- - -test.it("delete removes a file", function() - local p = tmp("del-me.txt") - fs.write(p, "bye") - test.truthy(fs.delete(p)) - test.falsy(fs.exists(p)) -end) - --- --- rmdir --- - -test.it("rmdir removes a directory recursively", function() - local d = tmp("rmdir-test") - fs.mkdir(d) - fs.write(path.join(d, "a.txt"), "a") - fs.mkdir(path.join(d, "sub")) - fs.write(path.join(d, "sub", "b.txt"), "b") - test.truthy(fs.rmdir(d)) - test.falsy(fs.exists(d)) -end) - --- --- copy --- - -test.it("copy copies a file", function() - local src = tmp("copy-src.txt") - local dst = tmp("copy-dst.txt") - fs.write(src, "copied!") - test.truthy(fs.copy(src, dst)) - test.equal(fs.read(dst), "copied!") -end) - -test.it("copy copies a directory recursively", function() - local src = tmp("copy-dir-src") - local dst = tmp("copy-dir-dst") - fs.mkdir(src) - fs.write(path.join(src, "f.txt"), "hi") - fs.mkdir(path.join(src, "sub")) - fs.write(path.join(src, "sub", "g.txt"), "there") - test.truthy(fs.copy(src, dst)) - test.equal(fs.read(path.join(dst, "f.txt")), "hi") - test.equal(fs.read(path.join(dst, "sub", "g.txt")), "there") -end) - --- --- move --- - -test.it("move renames a file", function() - local src = tmp("move-src.txt") - local dst = tmp("move-dst.txt") - fs.write(src, "moved") - test.truthy(fs.move(src, dst)) - test.falsy(fs.exists(src)) - test.equal(fs.read(dst), "moved") -end) - -test.it("move removes source directory after moving", function() - local src = tmp("move-dir-src") - local dst = tmp("move-dir-dst") - fs.mkdir(src) - fs.write(path.join(src, "file.txt"), "content") - test.truthy(fs.move(src, dst)) - test.falsy(fs.exists(src)) - test.equal(fs.read(path.join(dst, "file.txt")), "content") -end) - --- --- readdir --- - -test.it("readdir iterates directory entries", function() - local d = tmp("readdir-test") - fs.mkdir(d) - fs.write(path.join(d, "one.txt"), "") - fs.write(path.join(d, "two.txt"), "") - - local names = {} - for entry in fs.readdir(d) do - names[#names + 1] = entry.name - end - table.sort(names) - test.equal(#names, 2) - test.equal(names[1], "one.txt") - test.equal(names[2], "two.txt") -end) - -test.it("readdir returns nil for missing directory", function() - test.falsy(fs.readdir(tmp("no-dir"))) -end) - --- --- symlinks --- - -test.it("mklink creates a symlink and islink detects it", function() - local target = tmp("link-target.txt") - local link = tmp("link-itself") - fs.write(target, "target") - test.truthy(fs.mklink(target, link)) - -- On Windows, file symlinks fall back to hard links when Developer Mode is - -- disabled. Hard links are not reparse points, so islink returns false. - if jit.os ~= "Windows" then - test.truthy(fs.islink(link)) - else - test.truthy(fs.exists(link)) - end -end) - -test.it("rmlink removes a symlink", function() - local target = tmp("rmlink-target.txt") - local link = tmp("rmlink-link") - fs.write(target, "t") - fs.mklink(target, link) - test.truthy(fs.rmlink(link)) - test.falsy(fs.exists(link)) -end) - --- --- scan --- - -test.it("scan finds files matching glob", function() - local d = tmp("scan-test") - fs.mkdir(d) - fs.mkdir(path.join(d, "sub")) - fs.write(path.join(d, "a.lua"), "") - fs.write(path.join(d, "b.txt"), "") - fs.write(path.join(d, "sub", "c.lua"), "") - - local results = fs.scan(d, "**.lua") - test.equal(#results, 2) -end) - --- --- watch --- - -test.it("watch returns a watcher for an existing directory", function() - local d = tmp("watch-test") - fs.mkdir(d) - local w = fs.watch(d, function() end) - test.truthy(w) - w.close() -end) - -test.it("watch detects file creation via poll", function() - local d = tmp("watch-create") - fs.mkdir(d) - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - fs.write(path.join(d, "new.txt"), "hello") - - -- Give the OS a moment to register the event, then poll - local deadline = os.clock() + 1 - while #events == 0 and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > 0) - test.equal(events[1].event, "create") -end) - -test.it("watch detects file modification via poll", function() - local d = tmp("watch-modify") - fs.mkdir(d) - local p = path.join(d, "mod.txt") - fs.write(p, "v1") - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - fs.write(p, "v2") - - local deadline = os.clock() + 1 - while #events == 0 and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > 0) -end) - -test.it("watch detects file deletion via poll", function() - local d = tmp("watch-delete") - fs.mkdir(d) - local p = path.join(d, "gone.txt") - fs.write(p, "bye") - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - fs.delete(p) - - local deadline = os.clock() + 1 - while #events == 0 and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > 0) - test.equal(events[1].event, "delete") -end) - -test.it("wait blocks until file creation", function() - local d = tmp("wait-create") - fs.mkdir(d) - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - -- Write in a coroutine so wait() can block the main thread - local co = coroutine.create(function() - fs.write(path.join(d, "new.txt"), "hello") - end) - - -- Issue the write before wait() so the event is queued - coroutine.resume(co) - w.wait() - - w.close() - test.truthy(#events > 0) - test.equal(events[1].event, "create") -end) - -test.it("wait blocks until file modification", function() - local d = tmp("wait-modify") - fs.mkdir(d) - local p = path.join(d, "mod.txt") - fs.write(p, "v1") - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - fs.write(p, "v2") - w.wait() - - w.close() - test.truthy(#events > 0) -end) - -test.it("wait blocks until file deletion", function() - local d = tmp("wait-delete") - fs.mkdir(d) - local p = path.join(d, "gone.txt") - fs.write(p, "bye") - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end) - test.truthy(w) - - fs.delete(p) - w.wait() - - w.close() - test.truthy(#events > 0) - test.equal(events[1].event, "delete") -end) - --- --- watch recursive --- - -test.it("watch recursive detects file creation in subdirectory via poll", function() - local d = tmp("watch-rec-create") - local sub = path.join(d, "sub") - fs.mkdir(d) - fs.mkdir(sub) - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end, { recursive = true }) - test.truthy(w) - - fs.write(path.join(sub, "deep.txt"), "hello") - - local deadline = os.clock() + 1 - while #events == 0 and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > 0) - test.equal(events[1].event, "create") -end) - -test.it("watch recursive detects file modification in subdirectory via poll", function() - local d = tmp("watch-rec-modify") - local sub = path.join(d, "sub") - fs.mkdir(d) - fs.mkdir(sub) - local p = path.join(sub, "mod.txt") - fs.write(p, "v1") - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end, { recursive = true }) - test.truthy(w) - - fs.write(p, "v2") - - local deadline = os.clock() + 1 - while #events == 0 and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > 0) -end) - -test.it("watch recursive detects creation in newly created subdirectory", function() - local d = tmp("watch-rec-newdir") - fs.mkdir(d) - - local events = {} - local w = fs.watch(d, function(event, name) - events[#events + 1] = { event = event, name = name } - end, { recursive = true }) - test.truthy(w) - - local sub = path.join(d, "newdir") - fs.mkdir(sub) - - -- Block on the mkdir event so the watcher can register the new subdir - w.wait() - - local before = #events - fs.write(path.join(sub, "file.txt"), "hi") - - local deadline = os.clock() + 1 - while #events == before and os.clock() < deadline do - w.poll() - end - - w.close() - test.truthy(#events > before) -end) - - --- --- rmdir edge cases --- - -test.it("rmdir returns false for non-existent directory", function() - test.falsy(fs.rmdir(tmp("rmdir-missing"))) -end) - -test.it("rmdir removes an empty directory", function() - local d = tmp("rmdir-empty") - fs.mkdir(d) - test.truthy(fs.rmdir(d)) - test.falsy(fs.exists(d)) -end) - -test.it("rmdir removes deeply nested directories", function() - local d = tmp("rmdir-deep") - local deep = path.join(d, "a", "b", "c") - -- mkdir only creates one level, so build manually - fs.mkdir(d) - fs.mkdir(path.join(d, "a")) - fs.mkdir(path.join(d, "a", "b")) - fs.mkdir(deep) - fs.write(path.join(deep, "leaf.txt"), "x") - test.truthy(fs.rmdir(d)) - test.falsy(fs.exists(d)) -end) - -test.it("rmdir on a symlink to a directory removes only the link", function() - local target = tmp("rmdir-link-target") - local link = tmp("rmdir-link-itself") - fs.mkdir(target) - fs.mklink(target, link) - test.truthy(fs.rmdir(link)) - test.falsy(fs.exists(link)) - test.truthy(fs.exists(target)) -- target must survive - fs.rmdir(target) -end) - --- --- delete edge cases --- - -test.it("delete returns false for non-existent file", function() - test.falsy(fs.delete(tmp("delete-missing.txt"))) -end) - --- --- mkdirAll --- - -test.it("mkdirAll creates nested directories", function() - local d = tmp("mkdirall-nested/a/b/c") - fs.mkdirAll(d) - test.truthy(fs.isdir(d)) -end) - -test.it("mkdirAll is idempotent on an existing directory", function() - local d = tmp("mkdirall-exist") - fs.mkdir(d) - fs.mkdirAll(d) -- should not error - test.truthy(fs.isdir(d)) -end) - -test.it("mkdirAll creates a single missing directory", function() - local d = tmp("mkdirall-single") - fs.mkdirAll(d) - test.truthy(fs.isdir(d)) -end) - --- --- mkdir edge cases --- - -test.it("mkdir is idempotent on an existing directory", function() - local d = tmp("mkdir-idempotent") - fs.mkdir(d) - -- second call should not error and directory still exists - fs.mkdir(d) - test.truthy(fs.isdir(d)) -end) - --- --- write / read edge cases --- - -test.it("write overwrites existing file content", function() - local p = tmp("overwrite.txt") - fs.write(p, "first") - fs.write(p, "second") - test.equal(fs.read(p), "second") -end) - -test.it("write handles empty string content", function() - local p = tmp("empty-write.txt") - test.truthy(fs.write(p, "")) - test.equal(fs.read(p), "") -end) - -test.it("write handles binary / multi-line content", function() - local p = tmp("binary.txt") - local content = "line1\nline2\nline3" - fs.write(p, content) - test.equal(fs.read(p), content) -end) - --- --- copy edge cases --- - -test.it("copy returns false for missing source", function() - test.falsy(fs.copy(tmp("copy-no-src.txt"), tmp("copy-no-dst.txt"))) -end) - -test.it("copy overwrites an existing destination file", function() - local src = tmp("copy-over-src.txt") - local dst = tmp("copy-over-dst.txt") - fs.write(src, "new") - fs.write(dst, "old") - test.truthy(fs.copy(src, dst)) - test.equal(fs.read(dst), "new") -end) - --- --- move edge cases --- - -test.it("move overwrites an existing destination file", function() - local src = tmp("move-over-src.txt") - local dst = tmp("move-over-dst.txt") - fs.write(src, "winner") - fs.write(dst, "loser") - test.truthy(fs.move(src, dst)) - test.falsy(fs.exists(src)) - test.equal(fs.read(dst), "winner") -end) - --- --- stat / lstat edge cases --- - -test.it("lstat on a symlink returns type=symlink", function() - local target = tmp("lstat-target.txt") - local link = tmp("lstat-link") - fs.write(target, "t") - fs.mklink(target, link) - local s = fs.lstat(link) - test.truthy(s) - test.equal(s.type, "symlink") -end) - -test.it("stat on a symlink follows it and returns type=file", function() - local target = tmp("stat-link-target.txt") - local link = tmp("stat-link-itself") - fs.write(target, "t") - fs.mklink(target, link) - local s = fs.stat(link) - test.truthy(s) - test.equal(s.type, "file") -end) - --- --- readdir entry types --- - -test.it("readdir reports correct entry types", function() - local d = tmp("readdir-types") - local sub = path.join(d, "subdir") - local file = path.join(d, "file.txt") - local target = path.join(d, "link-target.txt") - local link = path.join(d, "link") - fs.mkdir(d) - fs.mkdir(sub) - fs.write(file, "x") - fs.write(target, "t") - fs.mklink(target, link) - - local types = {} - for entry in fs.readdir(d) do - types[entry.name] = entry.type - end - - test.equal(types["subdir"], "dir") - test.equal(types["file.txt"], "file") - -- symlink type may be "symlink" or resolved depending on OS; just check it exists - test.truthy(types["link"]) -end) - --- --- scan edge cases --- - -test.it("scan returns empty table when no files match", function() - local d = tmp("scan-nomatch") - fs.mkdir(d) - fs.write(path.join(d, "a.txt"), "") - local results = fs.scan(d, "**.lua") - test.equal(#results, 0) -end) - -test.it("scan with absolute option returns absolute paths", function() - local d = tmp("scan-absolute") - fs.mkdir(d) - fs.write(path.join(d, "x.lua"), "") - local results = fs.scan(d, "**.lua", { absolute = true }) - test.equal(#results, 1) - -- absolute path must start with the base dir - test.truthy(results[1]:sub(1, #d) == d) -end) - -test.it("scan finds files in nested directories with ** glob", function() - local d = tmp("scan-nested") - fs.mkdir(d) - fs.mkdir(path.join(d, "a")) - fs.mkdir(path.join(d, "a", "b")) - fs.write(path.join(d, "root.lua"), "") - fs.write(path.join(d, "a", "mid.lua"), "") - fs.write(path.join(d, "a", "b", "deep.lua"), "") - local results = fs.scan(d, "**.lua") - test.equal(#results, 3) -end) - -test.it("scan errors on a non-directory path", function() - local p = tmp("scan-notdir.txt") - fs.write(p, "x") - local ok = pcall(fs.scan, p, "**") - test.falsy(ok) -end) - --- --- globToPattern --- - -test.it("globToPattern matches exact filename", function() - local pat = fs.globToPattern("foo.lua") - test.truthy(string.find("foo.lua", pat)) - test.falsy(string.find("bar.lua", pat)) -end) - -test.it("globToPattern * does not cross path separators", function() - local pat = fs.globToPattern("*.lua") - test.truthy(string.find("hello.lua", pat)) - test.falsy(string.find("a/hello.lua", pat)) -end) - -test.it("globToPattern ** crosses path separators", function() - local pat = fs.globToPattern("**.lua") - test.truthy(string.find("hello.lua", pat)) - test.truthy(string.find("a/b/hello.lua", pat)) -end) - -test.it("globToPattern ? matches single non-separator character", function() - local pat = fs.globToPattern("fo?.lua") - test.truthy(string.find("foo.lua", pat)) - test.falsy(string.find("fo.lua", pat)) -end) diff --git a/packages/json/benchmarks/lde.json b/packages/json/benchmarks/lde.json deleted file mode 100644 index ef7dee91..00000000 --- a/packages/json/benchmarks/lde.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "json-benchmarks", - "version": "0.1.0", - "dependencies": { - "json": { "path": "../" }, - "ansi": { "path": "../../ansi" }, - "process2": { "path": "../../process2" }, - "lua-cjson": { "luarocks": "lua-cjson" } - } -} diff --git a/packages/json/benchmarks/src/init.lua b/packages/json/benchmarks/src/init.lua deleted file mode 100644 index 792a5578..00000000 --- a/packages/json/benchmarks/src/init.lua +++ /dev/null @@ -1,117 +0,0 @@ -local ffi = require("ffi") -local ansi = require("ansi") -local json = require("json") -local cjson = require("cjson") - --- ── timer ───────────────────────────────────────────────────────────────────── - -local now -if ffi.os == "Windows" then - ffi.cdef [[ - typedef union { struct { uint32_t lo, hi; }; uint64_t val; } LARGE_INTEGER; - int QueryPerformanceCounter(LARGE_INTEGER *lpPerformanceCount); - int QueryPerformanceFrequency(LARGE_INTEGER *lpFrequency); - ]] - local freq = ffi.new("LARGE_INTEGER") - ffi.C.QueryPerformanceFrequency(freq) - local f = tonumber(freq.val) - now = function() - local t = ffi.new("LARGE_INTEGER") - ffi.C.QueryPerformanceCounter(t) - return tonumber(t.val) * 1e9 / f - end -else - ffi.cdef [[ typedef struct { long tv_sec; long tv_nsec; } timespec; - int clock_gettime(int clk_id, timespec *tp); ]] - now = function() - local t = ffi.new("timespec") - ffi.C.clock_gettime(1, t) - return tonumber(t.tv_sec) * 1e9 + tonumber(t.tv_nsec) - end -end - --- ── bench helper ────────────────────────────────────────────────────────────── - -local function bench(label, fn, iters) - iters = iters or 1000 - -- warmup - for _ = 1, math.max(1, math.floor(iters / 10)) do fn() end - local t0 = now() - for _ = 1, iters do fn() end - local ns = (now() - t0) / iters - ansi.printf(" {gray}%-40s{reset} {bold}%8.2f ns/op{reset} {gray}(%d iters){reset}", - label, ns, iters) -end - --- ── fixtures ────────────────────────────────────────────────────────────────── - -local SMALL = '{"name":"Alice","age":30,"active":true}' - -local MEDIUM = json.encode({ - users = (function() - local t = {} - for i = 1, 20 do - t[i] = { id = i, name = "user" .. i, score = i * 1.5, active = i % 2 == 0 } - end - return t - end)() -}) - -local LARGE = json.encode((function() - local t = {} - for i = 1, 500 do - t[i] = { id = i, name = "item" .. i, value = i * 3.14, tags = { "a", "b", "c" } } - end - return t -end)()) - -local JSON5_SRC = [[{ - // application config - name: 'myapp', - version: '1.0.0', - /* feature flags */ - features: { - darkMode: true, - beta: false, - }, - ports: [8080, 8443,], -}]] - -local SMALL_T = json.decode(SMALL) -local MEDIUM_T = json.decode(MEDIUM) -local LARGE_T = json.decode(LARGE) -local JSON5_T = json.decode(JSON5_SRC) - --- ── run ─────────────────────────────────────────────────────────────────────── - -ansi.printf("\n{bold}json decode{reset}") -bench("small object (~40 B)", function() json.decode(SMALL) end, 5000) -bench("medium array (~20 objs)", function() json.decode(MEDIUM) end, 500) -bench("large array (~500 objs)", function() json.decode(LARGE) end, 20) -bench("json5 with comments", function() json.decode(JSON5_SRC) end, 2000) - -ansi.printf("\n{bold}json encode{reset}") -bench("small object", function() json.encode(SMALL_T) end, 5000) -bench("medium array", function() json.encode(MEDIUM_T) end, 500) -bench("large array", function() json.encode(LARGE_T) end, 20) -bench("json5 round-trip", function() json.encode(JSON5_T) end, 2000) - -ansi.printf("\n{bold}json round-trip (decode + encode){reset}") -bench("small", function() json.encode(json.decode(SMALL)) end, 5000) -bench("medium", function() json.encode(json.decode(MEDIUM)) end, 500) -bench("large", function() json.encode(json.decode(LARGE)) end, 20) - -ansi.printf("\n{bold}json decodeDocument only (zero-alloc){reset}") -bench("small", function() json.decodeDocument(SMALL) end, 5000) -bench("medium", function() json.decodeDocument(MEDIUM) end, 500) -bench("large", function() json.decodeDocument(LARGE) end, 20) - -ansi.printf("\n{bold}cjson decode{reset}") -bench("small object (~40 B)", function() cjson.decode(SMALL) end, 5000) -bench("medium array (~20 objs)", function() cjson.decode(MEDIUM) end, 500) -bench("large array (~500 objs)", function() cjson.decode(LARGE) end, 20) - -ansi.printf("\n{bold}cjson encode{reset}") -bench("small object", function() cjson.encode(cjson.decode(SMALL)) end, 5000) -bench("medium array", function() cjson.encode(cjson.decode(MEDIUM)) end, 500) -bench("large array", function() cjson.encode(cjson.decode(LARGE)) end, 20) diff --git a/packages/json/lde.json b/packages/json/lde.json deleted file mode 100644 index e159f0bd..00000000 --- a/packages/json/lde.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "json", - "version": "0.1.0" -} diff --git a/packages/json/src/init.lua b/packages/json/src/init.lua deleted file mode 100644 index 2968bdda..00000000 --- a/packages/json/src/init.lua +++ /dev/null @@ -1,922 +0,0 @@ -local json = {} -local ffi = require("ffi") -local strbuf = require("string.buffer") - -ffi.cdef [[ - void* memchr(const void* s, int c, size_t n); - - /* 16-byte token. All decoded JSON lives in a flat json_tok array. - No Lua table allocations for the parsed document. */ - typedef struct { - uint8_t type; /* TY_* constants below */ - uint8_t flags; /* string: 1=has_escapes */ - uint16_t pad; - uint32_t next; /* next sibling index (0 = none) */ - union { - struct { uint32_t str_off; uint32_t str_len; }; - double num; - struct { uint32_t child; uint32_t count; }; - }; - } json_tok; - - typedef struct { uint32_t start; uint32_t count; } json_keyslice; -]] - -local C = ffi.C -local cast = ffi.cast -local u8p = "const uint8_t*" - --- token type constants -local TY_NULL = 0 -local TY_FALSE = 1 -local TY_TRUE = 2 -local TY_INT = 3 -local TY_FLOAT = 4 -local TY_STRING = 5 -local TY_ARRAY = 6 -local TY_OBJECT = 7 - --- ── LuaCATS types ───────────────────────────────────────────────────────────── - ----@alias json.Primitive string | number | boolean | nil ----@alias json.Value json.Primitive | json.Object | json.Array | table ----@alias json.Object table ----@alias json.Array json.Value[] ----@alias json.KeyStyle "ident" | "single" | "double" ----@alias json.StringStyle "single" | "double" - ----@class json.KeyMeta ----@field keyStyle json.KeyStyle ----@field before string | nil ----@field between string | nil ----@field afterColon string | nil ----@field afterValue string | nil ----@field valueStyle json.StringStyle | nil - ----@class json.TableMeta ----@field __trailingComma boolean ----@field __closingTrivia string | nil ----@field [string] json.KeyMeta ----@field [integer] json.KeyMeta - ----@class json.Token : ffi.cdata* ----@field type integer ----@field flags integer ----@field next integer ----@field str_off integer ----@field str_len integer ----@field num number ----@field child integer ----@field count integer - ----@class json.KeySlice : ffi.cdata* ----@field start integer ----@field count integer - ----@class json.Doc ----@field toks json.Token[] token arena ----@field src string original source string ----@field ntoks integer number of tokens used - --- ── token arena (per-decode, grown as needed) ───────────────────────────────── - -local TOK_INIT = 4096 -local tok_ct = ffi.typeof("json_tok") -local tok_arr_ct = ffi.typeof("json_tok[?]") -local ks_ct = ffi.typeof("json_keyslice") - ----@type ffi.cdata* json_tok[?] -local tok_arena = ffi.new(tok_arr_ct, TOK_INIT) -local tok_cap = TOK_INIT -local tok_top = 0 -- next free slot - -local function tok_alloc() - local idx = tok_top - tok_top = idx + 1 - if tok_top > tok_cap then - local newcap = tok_cap * 2 - local newarr = ffi.new(tok_arr_ct, newcap) - ffi.copy(newarr, tok_arena, tok_cap * ffi.sizeof(tok_ct)) - tok_arena = newarr - tok_cap = newcap - end - return idx -end - --- ── key arena (append-only) ─────────────────────────────────────────────────── --- Stores ordered key strings for decoded objects (replaces per-object Lua table). --- Never reset: slices from old decodes remain valid as long as the decoded object lives. - ----@type string[] -local key_arena = {} -local key_arena_top = 0 - -local ks_ct = ffi.typeof("json_keyslice") - -local function newKeySlice() - local s = ks_ct() - s.start = key_arena_top - s.count = 0 - return s -end - -local function pushKey(slice, key) - local idx = key_arena_top + 1 - key_arena[idx] = key - key_arena_top = idx - slice.count = slice.count + 1 -end - --- ── weak stores (for encode-side metadata) ──────────────────────────────────── - ----@type table -local keyStore = setmetatable({}, { __mode = "k" }) ----@type table -local metaStore = setmetatable({}, { __mode = "k" }) - --- ── encode ──────────────────────────────────────────────────────────────────── - ----@type table -local dq_esc = { - ['"'] = '\\"', - ['\\'] = '\\\\', - ['\b'] = '\\b', - ['\f'] = '\\f', - ['\n'] = '\\n', - ['\r'] = '\\r', - ['\t'] = '\\t' -} ----@type table -local sq_esc = { - ["'"] = "\\'", - ['\\'] = '\\\\', - ['\b'] = '\\b', - ['\f'] = '\\f', - ['\n'] = '\\n', - ['\r'] = '\\r', - ['\t'] = '\\t' -} -local function dq_replace(c) return dq_esc[c] or string.format("\\u%04x", string.byte(c)) end -local function sq_replace(c) return sq_esc[c] or string.format("\\u%04x", string.byte(c)) end - -local dq_needs = ffi.new("uint8_t[256]") -for i = 0, 31 do dq_needs[i] = 1 end -dq_needs[34] = 1; dq_needs[92] = 1 - -local sq_needs = ffi.new("uint8_t[256]") -for i = 0, 31 do sq_needs[i] = 1 end -sq_needs[39] = 1; sq_needs[92] = 1 - ----@param tape string.buffer ----@param s string ----@param style json.StringStyle | nil -local function putString(tape, s, style) - local len = #s - local p = cast(u8p, s) - if style == "single" then - tape:put("'") - local clean = true - for i = 0, len - 1 do - if sq_needs[p[i]] == 1 then - clean = false; break - end - end - if clean then tape:put(s) else tape:put((string.gsub(s, "[%z\1-\31'\\]", sq_replace))) end - tape:put("'") - else - tape:put('"') - local clean = true - for i = 0, len - 1 do - if dq_needs[p[i]] == 1 then - clean = false; break - end - end - if clean then tape:put(s) else tape:put((string.gsub(s, '[%z\1-\31"\\]', dq_replace))) end - tape:put('"') - end -end - ----@type fun(tape: string.buffer, v: json.Value, indent: string, level: integer, valueStyle: json.StringStyle | nil) -local putValue -- forward decl - ----@param t table ----@return boolean -local function isArray(t) - if keyStore[t] then return false end - local i = 0 - for _ in pairs(t) do - i = i + 1 - if t[i] == nil then return false end - end - return true -end - ----@param tape string.buffer ----@param t json.Array ----@param indent string ----@param level integer -local function putArray(tape, t, indent, level) - local n = #t - if n == 0 then - tape:put("[]"); return - end - local meta = metaStore[t] - local nextIndent = string.rep(indent, level + 1) - local defaultBefore = "\n" .. nextIndent - local closing = (meta and meta.__closingTrivia) or ("\n" .. string.rep(indent, level)) - tape:put("[") - for i = 1, n do - if i > 1 then tape:put(",") end - local km = meta and meta[i] - tape:put((km and km.before) or defaultBefore) - putValue(tape, t[i], indent, level + 1, km and km.valueStyle) - local av = km and km.afterValue - if av and av ~= "" then tape:put(av) end - end - if meta and meta.__trailingComma then tape:put(",") end - tape:put(closing); tape:put("]") -end - ----@param tape string.buffer ----@param t json.Object ----@param indent string ----@param level integer -local function putObject(tape, t, indent, level) - local ks = keyStore[t] - local meta = metaStore[t] - local nextIndent = string.rep(indent, level + 1) - - if not ks then - local keys = {} - for k in pairs(t) do keys[#keys + 1] = k end - table.sort(keys) - local n = #keys - if n == 0 then - tape:put("{}"); return - end - tape:put("{\n") - for i = 1, n do - if i > 1 then tape:put(",\n") end - tape:put(nextIndent); putString(tape, tostring(keys[i]), nil) - tape:put(": "); putValue(tape, t[keys[i]], indent, level + 1, nil) - end - tape:put("\n"); tape:put(string.rep(indent, level)); tape:put("}") - return - end - - local isSlice = ffi.istype(ks_ct, ks) - local nkeys = isSlice and ks.count or #ks - if nkeys == 0 then - tape:put("{}"); return - end - - if not meta then - tape:put("{\n") - if isSlice then - local base = ks.start - for i = 1, nkeys do - if i > 1 then tape:put(",\n") end - local k = key_arena[base + i] - tape:put(nextIndent); putString(tape, k, nil) - tape:put(": "); putValue(tape, t[k], indent, level + 1, nil) - end - else - for i = 1, nkeys do - if i > 1 then tape:put(",\n") end - local k = ks[i] - tape:put(nextIndent); putString(tape, k, nil) - tape:put(": "); putValue(tape, t[k], indent, level + 1, nil) - end - end - tape:put("\n"); tape:put(string.rep(indent, level)); tape:put("}") - return - end - - tape:put("{") - if isSlice then - local base = ks.start - for i = 1, nkeys do - if i > 1 then tape:put(",") end - local k = key_arena[base + i]; local km = meta[k] - tape:put((km and km.before) or " ") - local ks2 = km and km.keyStyle - if ks2 == "ident" then tape:put(k) else putString(tape, k, ks2) end - tape:put((km and km.between) or ""); tape:put(":") - tape:put((km and km.afterColon) or " ") - putValue(tape, t[k], indent, level + 1, km and km.valueStyle) - local av = km and km.afterValue; if av and av ~= "" then tape:put(av) end - end - else - for i = 1, nkeys do - if i > 1 then tape:put(",") end - local k = ks[i]; local km = meta[k] - tape:put((km and km.before) or " ") - local ks2 = km and km.keyStyle - if ks2 == "ident" then tape:put(k) else putString(tape, k, ks2) end - tape:put((km and km.between) or ""); tape:put(":") - tape:put((km and km.afterColon) or " ") - putValue(tape, t[k], indent, level + 1, km and km.valueStyle) - local av = km and km.afterValue; if av and av ~= "" then tape:put(av) end - end - end - if meta.__trailingComma then tape:put(",") end - tape:put(meta.__closingTrivia or " "); tape:put("}") -end - -local floor = math.floor -local huge = math.huge - -putValue = function(tape, v, indent, level, valueStyle) - local t = type(v) - if t == "nil" or v == json.null then - tape:put("null") - elseif t == "boolean" then - tape:put(v and "true" or "false") - elseif t == "number" then - if v ~= v then - tape:put("NaN") - elseif v == huge then - tape:put("Infinity") - elseif v == -huge then - tape:put("-Infinity") - else - tape:put(tostring(v)) - end - elseif t == "string" then - putString(tape, v, valueStyle) - elseif t == "table" then - if isArray(v) then - putArray(tape, v, indent, level) - else - putObject(tape, v, indent, level) - end - else - error("unsupported type: " .. t) - end -end - ----@param t table ----@param key string ----@param value json.Value -function json.addField(t, key, value) - t[key] = value - local ks = keyStore[t] - if not ks or ffi.istype(ks_ct, ks) then - local arr = {} - if ks then for i = 1, ks.count do arr[i] = key_arena[ks.start + i] end end - keyStore[t] = arr; ks = arr - end - ks[#ks + 1] = key -end - ----@param t table ----@param key string -function json.removeField(t, key) - t[key] = nil - local ks = keyStore[t] - if not ks or ffi.istype(ks_ct, ks) then return end - for i, k in ipairs(ks) do - if k == key then - table.remove(ks, i); return - end - end -end - ----@param value json.Value ----@return string -function json.encode(value) - local tape = strbuf.new() - putValue(tape, value, "\t", 0, nil) - tape:put("\n") - return tape:tostring() -end - --- ── decoder ─────────────────────────────────────────────────────────────────── - ----@type ffi.cdata* -local src_ptr ----@type integer -local src_len ----@type string -local src_s - -local ws_tab = ffi.new("uint8_t[256]") -ws_tab[32] = 1; ws_tab[9] = 1; ws_tab[10] = 1; ws_tab[13] = 1 - -local ident_tab = ffi.new("uint8_t[256]") -for i = 48, 57 do ident_tab[i] = 1 end -for i = 65, 90 do ident_tab[i] = 1 end -for i = 97, 122 do ident_tab[i] = 1 end -ident_tab[95] = 1; ident_tab[36] = 1 - ----@param pos integer 1-based ----@return integer 1-based -local function skipWS(pos) - local i = pos - 1 - while i < src_len and ws_tab[src_ptr[i]] == 1 do i = i + 1 end - return i + 1 -end - ----@param pos integer 1-based, sitting on '/' ----@param ts integer trivia start ----@return string trivia ----@return integer 1-based after trivia -local function collectComments(pos, ts) - while pos <= src_len do - if src_ptr[pos - 1] ~= 47 then break end - local b1 = src_ptr[pos] - if b1 == 47 then - local nl = C.memchr(src_ptr + pos + 1, 10, src_len - pos - 1) - pos = nl ~= nil and (cast(u8p, nl) - src_ptr + 2) or (src_len + 1) - elseif b1 == 42 then - local p = src_ptr + pos + 1; local rem = src_len - pos - 1; local found = false - while rem > 0 do - local star = C.memchr(p, 42, rem); if star == nil then error("unterminated block comment") end - local sp = cast(u8p, star); local off = sp - src_ptr - if off + 1 < src_len and src_ptr[off + 1] == 47 then - pos = off + 3; found = true; break - end - p = sp + 1; rem = src_len - (off + 1) - end - if not found then error("unterminated block comment") end - else - break - end - pos = skipWS(pos) - end - return string.sub(src_s, ts, pos - 1), pos -end - ----@param pos integer 1-based ----@return string|nil trivia ----@return integer 1-based after trivia -local function collectTrivia(pos) - local npos = skipWS(pos) - if npos <= src_len and src_ptr[npos - 1] == 47 then return collectComments(npos, pos) end - if npos == pos then return nil, npos end - return string.sub(src_s, pos, npos - 1), npos -end - ----@type fun(pos:integer): integer, integer -- returns (tok_idx, new_pos) -local parseValue -- forward decl - ----@type table -local escapeMap = { - [34] = '"', - [39] = "'", - [92] = '\\', - [47] = '/', - [98] = '\b', - [102] = '\f', - [110] = '\n', - [114] = '\r', - [116] = '\t' -} - --- Parse a quoted string into a token. Returns (tok_idx, new_pos). ----@param pos integer 1-based, pointing at opening quote ----@return integer tok_idx ----@return integer new_pos -local function parseString(pos) - local quote = src_ptr[pos - 1] - local i = pos + 1 - local pq = C.memchr(src_ptr + i - 1, quote, src_len - i + 1) - if pq == nil then error("unterminated string") end - local q_off = cast(u8p, pq) - src_ptr - local has_esc = C.memchr(src_ptr + i - 1, 92, q_off - (i - 1)) ~= nil - - local idx = tok_alloc() - local tok = tok_arena[idx] - tok.type = TY_STRING - tok.flags = has_esc and 1 or 0 - tok.next = 0 - tok.str_off = i - 1 -- 0-based offset into src_s - tok.str_len = q_off - (i - 1) - - if not has_esc then - return idx, q_off + 2 - end - - -- advance past the full escaped string - local j = i - while j <= src_len do - local rem = src_len - j + 1; local base = src_ptr + j - 1 - local pbs = C.memchr(base, 92, rem); local pq2 = C.memchr(base, quote, rem) - local bs_off = pbs ~= nil and (cast(u8p, pbs) - src_ptr) or src_len - local q_off2 = pq2 ~= nil and (cast(u8p, pq2) - src_ptr) or src_len - if q_off2 <= bs_off then - tok.str_len = q_off2 - (i - 1) -- store full span including escapes - return idx, q_off2 + 2 - end - local esc = src_ptr[bs_off + 1] - j = esc == 117 and bs_off + 7 or bs_off + 3 - end - error("unterminated string") -end - --- Materialise a string token into a Lua string (allocates only when called). ----@param tok json.Token ----@return string -local function tokToString(tok) - local off = tok.str_off + 1 -- 1-based start of content - if tok.flags == 0 then - return string.sub(src_s, off, off + tok.str_len - 1) - end - -- unescape: walk the raw span, replacing escape sequences - local buf = {} - local i = off - local lim = off + tok.str_len -- exclusive end (past last content byte) - while i < lim do - local rem = lim - i - local base = src_ptr + i - 1 - local pbs = C.memchr(base, 92, rem) -- backslash - local bs_off = pbs ~= nil and (cast(u8p, pbs) - src_ptr) or (lim - 1) - if bs_off >= lim - 1 then - -- no more backslashes, copy remainder - if i <= lim - 1 then buf[#buf + 1] = string.sub(src_s, i, lim - 1) end - break - end - if bs_off > i - 1 then buf[#buf + 1] = string.sub(src_s, i, bs_off) end - local esc = src_ptr[bs_off + 1] - if esc == 117 then - buf[#buf + 1] = string.char(tonumber(string.sub(src_s, bs_off + 2, bs_off + 5), 16)) - i = bs_off + 7 - elseif esc == 10 or esc == 13 then - i = bs_off + 3 - else - buf[#buf + 1] = escapeMap[esc] or string.char(esc); i = bs_off + 3 - end - end - return table.concat(buf) -end - ----@param pos integer 1-based ----@return integer tok_idx ----@return integer new_pos -local function parseIdentifier(pos) - local i = pos - 1 - local b = src_ptr[i] - if not ((b >= 65 and b <= 90) or (b >= 97 and b <= 122) or b == 95 or b == 36) then - error("invalid identifier at pos " .. pos) - end - i = i + 1 - while i < src_len and ident_tab[src_ptr[i]] == 1 do i = i + 1 end - local idx = tok_alloc() - local tok = tok_arena[idx] - tok.type = TY_STRING - tok.flags = 0 - tok.next = 0 - tok.str_off = pos - 1 -- 0-based - tok.str_len = i - (pos - 1) - return idx, i + 1 -end - ----@param pos integer 1-based ----@return integer tok_idx ----@return integer new_pos -local function parseNumber(pos) - local i = pos - 1; local neg = src_ptr[i] == 45 - if neg then i = i + 1 end - local b = src_ptr[i] - local idx = tok_alloc() - local tok = tok_arena[idx] - tok.next = 0 - if b >= 48 and b <= 57 then - if b == 48 then - local b2 = src_ptr[i + 1]; if b2 == 120 or b2 == 88 then goto slow end - end - local n = 0 - while i < src_len do - b = src_ptr[i]; if b < 48 or b > 57 then break end; n = n * 10 + (b - 48); i = i + 1 - end - if b ~= 46 and b ~= 101 and b ~= 69 then - tok.type = TY_INT; tok.num = neg and -n or n; return idx, i + 1 - end - end - ::slow:: - local numStr = string.match(src_s, "^-?0[xX]%x+", pos) - or string.match(src_s, "^[+-]?%d+%.?%d*[eE]?[+-]?%d*", pos) - local sub = string.sub(src_s, pos, pos + 8) - local v - if sub:sub(1, 8) == "Infinity" then - v = huge; numStr = sub:sub(1, 8) - elseif sub:sub(1, 9) == "+Infinity" then - v = huge; numStr = sub:sub(1, 9) - elseif sub:sub(1, 9) == "-Infinity" then - v = -huge; numStr = sub:sub(1, 9) - elseif sub:sub(1, 3) == "NaN" then - v = 0 / 0; numStr = sub:sub(1, 3) - else - v = tonumber(numStr) - end - tok.type = TY_FLOAT; tok.num = v - return idx, pos + #numStr -end - ----@param pos integer 1-based, pointing at '[' ----@return integer tok_idx ----@return integer new_pos -local function parseArray(pos) - local idx = tok_alloc() - local tok = tok_arena[idx] - tok.type = TY_ARRAY - tok.next = 0 - tok.child = 0 - tok.count = 0 - - local trivia, npos = collectTrivia(pos + 1); pos = npos - if src_ptr[pos - 1] == 93 then return idx, pos + 1 end - - local first_child = 0 - local prev_idx = 0 - local count = 0 - - while true do - local ci, npos2 = parseValue(pos); pos = npos2 - count = count + 1 - if first_child == 0 then first_child = ci end - if prev_idx ~= 0 then tok_arena[prev_idx].next = ci end - prev_idx = ci - - local _, npos3 = collectTrivia(pos); pos = npos3 - local c = src_ptr[pos - 1] - if c == 93 then break end - if c ~= 44 then error("expected ',' or ']'") end - pos = pos + 1 - local _, npos4 = collectTrivia(pos); pos = npos4 - if src_ptr[pos - 1] == 93 then break end - end - - tok_arena[idx].child = first_child - tok_arena[idx].count = count - return idx, pos + 1 -end - ----@param pos integer 1-based, pointing at '{' ----@return integer tok_idx ----@return integer new_pos -local function parseObject(pos) - local idx = tok_alloc() - local tok = tok_arena[idx] - tok.type = TY_OBJECT - tok.next = 0 - tok.child = 0 - tok.count = 0 - - local trivia, npos = collectTrivia(pos + 1); pos = npos - if src_ptr[pos - 1] == 125 then return idx, pos + 1 end - - local first_child = 0 - local prev_idx = 0 - local count = 0 - - while true do - -- key - local c = src_ptr[pos - 1] - local ki - if c == 34 or c == 39 then - ki, pos = parseString(pos) - else - ki, pos = parseIdentifier(pos) - end - - local _, npos2 = collectTrivia(pos); pos = npos2 - if src_ptr[pos - 1] ~= 58 then error("expected ':'") end - pos = pos + 1 - local _, npos3 = collectTrivia(pos); pos = npos3 - - -- value - local vi, npos4 = parseValue(pos); pos = npos4 - - -- link: key.next -> value, value.next -> next key (set later) - tok_arena[ki].next = vi - count = count + 1 - if first_child == 0 then first_child = ki end - if prev_idx ~= 0 then tok_arena[prev_idx].next = ki end - prev_idx = vi -- next sibling chain continues from value - - local _, npos5 = collectTrivia(pos); pos = npos5 - c = src_ptr[pos - 1] - if c == 125 then break end - if c ~= 44 then error("expected ',' or '}'") end - pos = pos + 1 - local _, npos6 = collectTrivia(pos); pos = npos6 - if src_ptr[pos - 1] == 125 then break end - end - - tok_arena[idx].child = first_child - tok_arena[idx].count = count - return idx, pos + 1 -end - -parseValue = function(pos) - local _, npos = collectTrivia(pos); pos = npos - local c = src_ptr[pos - 1] - if c == 34 or c == 39 then - return parseString(pos) - elseif c == 123 then - return parseObject(pos) - elseif c == 91 then - return parseArray(pos) - elseif c == 116 then - local idx = tok_alloc(); tok_arena[idx].type = TY_TRUE; tok_arena[idx].next = 0 - return idx, pos + 4 - elseif c == 102 then - local idx = tok_alloc(); tok_arena[idx].type = TY_FALSE; tok_arena[idx].next = 0 - return idx, pos + 5 - elseif c == 110 then - local idx = tok_alloc(); tok_arena[idx].type = TY_NULL; tok_arena[idx].next = 0 - return idx, pos + 4 - else - return parseNumber(pos) - end -end - --- ── public API ──────────────────────────────────────────────────────────────── - -json.null = setmetatable({}, { __tostring = function() return "null" end }) - --- Returns a doc table: { toks=tok_arena, src=src_s, root=root_idx } --- The token arena is reused on the next decodeDocument call, so copy if you need persistence. ----@param s string ----@return json.Doc -function json.decodeDocument(s) - tok_top = 0 - src_s = s - src_len = #s - src_ptr = cast(u8p, s) - local root, _ = parseValue(1) - return { toks = tok_arena, src = s, root = root } -end - --- Materialise a token into a plain Lua value (allocates strings/tables). --- For hot paths prefer json.iter / json.get / json.str / json.num. ----@param doc json.Doc ----@param idx integer token index ----@return json.Value -local function materialise(doc, idx) - local tok = doc.toks[idx] - local ty = tok.type - if ty == TY_NULL then - return json.null - elseif ty == TY_FALSE then - return false - elseif ty == TY_TRUE then - return true - elseif ty == TY_INT or ty == TY_FLOAT then - return tok.num - elseif ty == TY_STRING then - -- re-bind src for tokToString - src_s = doc.src - src_ptr = cast(u8p, src_s) - src_len = #src_s - return tokToString(tok) - elseif ty == TY_ARRAY then - local arr = {} - local ci = tok.child - local i = 0 - while ci ~= 0 do - i = i + 1 - arr[i] = materialise(doc, ci) - ci = doc.toks[ci].next - end - return arr - elseif ty == TY_OBJECT then - local obj = {} - local keys = {} - keyStore[obj] = keys - local ki = tok.child - while ki ~= 0 do - src_s = doc.src; src_ptr = cast(u8p, src_s); src_len = #src_s - local k = tokToString(doc.toks[ki]) - local vi = doc.toks[ki].next - obj[k] = materialise(doc, vi) - keys[#keys + 1] = k - ki = doc.toks[vi].next - end - return obj - end - error("unknown token type " .. ty) -end - --- Iterate children of an array or object token. --- For arrays: yields (index, child_tok_idx) --- For objects: yields (key_string, value_tok_idx) ----@param doc json.Doc ----@param idx integer token index of array or object ----@return fun(): (string|integer|nil), integer|nil -function json.iter(doc, idx) - local tok = doc.toks[idx] - local ty = tok.type - if ty == TY_ARRAY then - local ci = tok.child - local i = 0 - return function() - if ci == 0 then return nil end - i = i + 1 - local cur = ci - ci = doc.toks[ci].next - return i, cur - end - elseif ty == TY_OBJECT then - local ki = tok.child - src_s = doc.src; src_ptr = cast(u8p, src_s); src_len = #src_s - return function() - if ki == 0 then return nil end - local k = tokToString(doc.toks[ki]) - local vi = doc.toks[ki].next - ki = doc.toks[vi].next - return k, vi - end - end - return function() return nil end -end - --- Get a child token by key (object) or index (array). Returns token index or nil. ----@param doc json.Doc ----@param idx integer ----@param key string | integer ----@return integer | nil -function json.get(doc, idx, key) - local tok = doc.toks[idx] - local ty = tok.type - if ty == TY_ARRAY then - local ci = tok.child; local i = 0 - while ci ~= 0 do - i = i + 1 - if i == key then return ci end - ci = doc.toks[ci].next - end - elseif ty == TY_OBJECT then - src_s = doc.src; src_ptr = cast(u8p, src_s); src_len = #src_s - local ki = tok.child - while ki ~= 0 do - if tokToString(doc.toks[ki]) == key then return doc.toks[ki].next end - ki = doc.toks[doc.toks[ki].next].next - end - end - return nil -end - --- Get the Lua string value of a string token. ----@param doc json.Doc ----@param idx integer ----@return string -function json.str(doc, idx) - src_s = doc.src; src_ptr = cast(u8p, src_s); src_len = #src_s - return tokToString(doc.toks[idx]) -end - --- Get the numeric value of a number token. ----@param doc json.Doc ----@param idx integer ----@return number -function json.num(doc, idx) - return doc.toks[idx].num -end - --- Get the type name of a token. ----@param doc json.Doc ----@param idx integer ----@return "null"|"boolean"|"number"|"string"|"array"|"object" -function json.type(doc, idx) - local ty = doc.toks[idx].type - if ty == TY_NULL then - return "null" - elseif ty == TY_FALSE or ty == TY_TRUE then - return "boolean" - elseif ty == TY_INT or ty == TY_FLOAT then - return "number" - elseif ty == TY_STRING then - return "string" - elseif ty == TY_ARRAY then - return "array" - else - return "object" - end -end - --- Materialise the full document into Lua tables (old behaviour, allocates). ----@param doc json.Doc ----@param doc json.Doc ----@return json.Value -function json.materialise(doc) - return materialise(doc, doc.root) -end - --- Decode JSON string into plain Lua tables (allocating). ----@param s string ----@return json.Value -function json.decode(s) - local doc = json.decodeDocument(s) - return materialise(doc, doc.root) -end - --- Encode a json.Doc back to a JSON string (materialises then encodes). ----@param doc json.Doc ----@return string -function json.encodeDocument(doc) - return json.encode(materialise(doc, doc.root)) -end - -return json diff --git a/packages/json/tests/json.test.lua b/packages/json/tests/json.test.lua deleted file mode 100644 index 2ad28a64..00000000 --- a/packages/json/tests/json.test.lua +++ /dev/null @@ -1,219 +0,0 @@ -local test = require("lde-test") -local json = require("json") - --- encode - -test.it("encodes primitives", function() - test.equal(json.encode(nil):gsub("%s", ""), "null") - test.equal(json.encode(true):gsub("%s", ""), "true") - test.equal(json.encode(42):gsub("%s", ""), "42") - test.equal(json.encode("hi"):gsub("%s", ""), '"hi"') -end) - -test.it("encodes array", function() - local s = json.encode({ 1, 2, 3 }) - local t = json.decode(s) - test.equal(t[1], 1); test.equal(t[2], 2); test.equal(t[3], 3) -end) - -test.it("encodes object", function() - local s = json.encode({ a = 1 }) - local t = json.decode(s) - test.equal(t.a, 1) -end) - --- decode – standard JSON - -test.it("decodes null", function() - test.equal(tostring(json.decode("null")), "null") -end) - -test.it("decodes booleans", function() - test.equal(json.decode("true"), true) - test.equal(json.decode("false"), false) -end) - -test.it("decodes numbers", function() - test.equal(json.decode("42"), 42) - test.equal(json.decode("-3.14"), -3.14) - test.equal(json.decode("1e2"), 100) -end) - -test.it("decodes strings", function() - test.equal(json.decode('"hello"'), "hello") - test.equal(json.decode('"line\\nbreak"'), "line\nbreak") -end) - -test.it("decodes nested objects and arrays", function() - local t = json.decode('{"a":[1,2],"b":{"c":true}}') - test.equal(t.a[1], 1); test.equal(t.a[2], 2); test.equal(t.b.c, true) -end) - --- decode – JSON5 - -test.it("json5: single-line comment", function() - test.equal(json.decode('{\n// comment\n"a":1}').a, 1) -end) - -test.it("json5: block comment", function() - test.equal(json.decode('{"a": /* comment */ 1}').a, 1) -end) - -test.it("json5: single-quoted string value", function() - test.equal(json.decode("'hello'"), "hello") -end) - -test.it("json5: single-quoted string key", function() - test.equal(json.decode("{'key': 1}").key, 1) -end) - -test.it("json5: unquoted key", function() - test.equal(json.decode("{foo: 1}").foo, 1) -end) - -test.it("json5: trailing comma in object", function() - test.equal(json.decode('{"a":1,}').a, 1) -end) - -test.it("json5: trailing comma in array", function() - test.equal(#json.decode('[1,2,3,]'), 3) -end) - -test.it("json5: hex number", function() - test.equal(json.decode("0xFF"), 255) -end) - -test.it("json5: Infinity", function() - test.equal(json.decode("Infinity"), math.huge) - test.equal(json.decode("+Infinity"), math.huge) - test.equal(json.decode("-Infinity"), -math.huge) -end) - -test.it("json5: NaN", function() - local n = json.decode("NaN") - test.truthy(n ~= n) -end) - --- order preservation - -test.it("addField preserves insertion order on encode", function() - local t = {} - json.addField(t, "z", 1); json.addField(t, "a", 2); json.addField(t, "m", 3) - local s = json.encode(t) - test.truthy(s:find('"z"') < s:find('"a"') and s:find('"a"') < s:find('"m"')) -end) - -test.it("decode preserves key insertion order", function() - local t = json.decode('{"z":1,"a":2,"m":3}') - local s = json.encode(t) - test.truthy(s:find('"z"') < s:find('"a"') and s:find('"a"') < s:find('"m"')) -end) - -test.it("removeField removes key and preserves order of remaining keys", function() - local t = {} - json.addField(t, "a", 1); json.addField(t, "b", 2); json.addField(t, "c", 3) - json.removeField(t, "b") - local s = json.encode(t) - test.truthy(not s:find('"b"')) - test.truthy(s:find('"a"') < s:find('"c"')) -end) - --- comment/style preservation (only via addField/encode, not materialise) - -test.it("preserves unquoted key style on re-encode via addField", function() - local t = {} - json.addField(t, "foo", 1) - -- addField uses plain string[], encode uses double-quote by default - local out = json.encode(t) - test.truthy(out:find('"foo"')) -end) - -test.it("preserves double-quoted key style on re-encode", function() - test.truthy(json.encode(json.decode('{"baz": 3}')):find('"baz"')) -end) - -test.it("preserves double-quoted string value on re-encode", function() - test.truthy(json.encode(json.decode('{"key": "world"}')):find('"world"')) -end) - --- zero-alloc API - -test.it("json.iter over array yields indices and token indices", function() - local doc = json.decodeDocument('[10,20,30]') - local keys, vals = {}, {} - for i, vi in json.iter(doc, doc.root) do - keys[#keys+1] = i - vals[#vals+1] = json.num(doc, vi) - end - test.equal(#keys, 3) - test.equal(vals[1], 10); test.equal(vals[2], 20); test.equal(vals[3], 30) -end) - -test.it("json.iter over object yields key strings and token indices", function() - local doc = json.decodeDocument('{"x":1,"y":2}') - local keys, vals = {}, {} - for k, vi in json.iter(doc, doc.root) do - keys[#keys+1] = k - vals[k] = json.num(doc, vi) - end - test.equal(vals.x, 1); test.equal(vals.y, 2) -end) - -test.it("json.get retrieves array element by index", function() - local doc = json.decodeDocument('[10,20,30]') - test.equal(json.num(doc, json.get(doc, doc.root, 2)), 20) -end) - -test.it("json.get retrieves object value by key", function() - local doc = json.decodeDocument('{"name":"alice","age":30}') - test.equal(json.str(doc, json.get(doc, doc.root, "name")), "alice") - test.equal(json.num(doc, json.get(doc, doc.root, "age")), 30) -end) - -test.it("json.type returns correct type names", function() - local doc = json.decodeDocument('[null,true,false,42,3.14,"hi",[],{}]') - local types = {} - for _, vi in json.iter(doc, doc.root) do types[#types+1] = json.type(doc, vi) end - test.equal(types[1], "null"); test.equal(types[2], "boolean") - test.equal(types[3], "boolean"); test.equal(types[4], "number") - test.equal(types[5], "number"); test.equal(types[6], "string") - test.equal(types[7], "array"); test.equal(types[8], "object") -end) - -test.it("json.str handles escaped strings", function() - local doc = json.decodeDocument('"hello\\nworld"') - test.equal(json.str(doc, doc.root), "hello\nworld") -end) - --- regression: keys from a decoded object must survive any number of subsequent decodes --- (key_arena was being reset to 0 on each decodeDocument, corrupting prior slices) -test.it("encode preserves keys after a subsequent decode clobbers the key arena", function() - local config = json.decode('{"name":"myproject","version":"1.0.0","dependencies":{}}') - -- A second decode used to reset key_arena_top to 0, overwriting arena slots with new keys - json.decode('{"arch":null,"url":null,"luarocks":null}') - json.decode('{"x":1,"y":2,"z":3}') - local out = json.encode(config) - local roundtrip = json.decode(out) - test.equal(roundtrip.name, "myproject") - test.equal(roundtrip.version, "1.0.0") - test.truthy(roundtrip.dependencies) -end) - -test.it("decodeDocument+materialise key slices survive subsequent decodeDocument calls", function() - local doc1 = json.decodeDocument('{"a":1,"b":2}') - local obj1 = json.materialise(doc1) - -- second decodeDocument used to reset key_arena_top, clobbering doc1's slice - local doc2 = json.decodeDocument('{"x":10,"y":20,"z":30}') - local obj2 = json.materialise(doc2) - -- obj1's key order must still be intact - local out1 = json.encode(obj1) - local r1 = json.decode(out1) - test.equal(r1.a, 1) - test.equal(r1.b, 2) - -- obj2 must also be correct - local out2 = json.encode(obj2) - local r2 = json.decode(out2) - test.equal(r2.x, 10) - test.equal(r2.y, 20) - test.equal(r2.z, 30) -end) diff --git a/packages/lde-core/lde.json b/packages/lde-core/lde.json index 165b714e..7aeca0c1 100644 --- a/packages/lde-core/lde.json +++ b/packages/lde-core/lde.json @@ -2,14 +2,14 @@ "name": "lde-core", "version": "0.1.0", "dependencies": { - "json": { "path": "../json" }, + "json": { "git": "https://github.com/lde-org/json" }, "ansi": { "path": "../ansi" }, "sea": { "path": "../sea" }, - "path": { "path": "../path" }, - "process2": { "path": "../process2" }, + "path": { "git": "https://github.com/lde-org/path" }, + "process": { "git": "https://github.com/lde-org/process" }, "clap": { "path": "../clap" }, - "fs": { "path": "../fs" }, - "env": { "path": "../env" }, + "fs": { "git": "https://github.com/lde-org/fs" }, + "env": { "git": "https://github.com/lde-org/env" }, "util": { "path": "../util" }, "curl-sys": { "git": "https://github.com/lde-org/curl-sys" }, "semver": { "path": "../semver" }, @@ -17,6 +17,6 @@ "git": { "path": "../git" }, "rocked": { "path": "../rocked" }, "luarocks": { "path": "../luarocks" }, - "archive": { "path": "../archive" } + "archive": { "git": "https://github.com/lde-org/archive" } } } diff --git a/packages/lde-core/src/global/init.lua b/packages/lde-core/src/global/init.lua index 65022d8f..a2934217 100644 --- a/packages/lde-core/src/global/init.lua +++ b/packages/lde-core/src/global/init.lua @@ -2,7 +2,7 @@ local fs = require("fs") local git = require("git") local json = require("json") local path = require("path") -local process = require("process2") +local process = require("process") local semver = require("semver") local lde = require("lde-core") local ansi = require("ansi") diff --git a/packages/lde-core/src/package/init.lua b/packages/lde-core/src/package/init.lua index aebfb7ea..c7bf8120 100644 --- a/packages/lde-core/src/package/init.lua +++ b/packages/lde-core/src/package/init.lua @@ -6,7 +6,7 @@ local fs = require("fs") local env = require("env") local json = require("json") local path = require("path") -local process = require("process2") +local process = require("process") ---@class lde.Package ---@field dir string diff --git a/packages/lde-core/src/package/rockspec.lua b/packages/lde-core/src/package/rockspec.lua index 9cb895aa..f432836a 100644 --- a/packages/lde-core/src/package/rockspec.lua +++ b/packages/lde-core/src/package/rockspec.lua @@ -5,7 +5,7 @@ local lde = require("lde-core") local fs = require("fs") local env = require("env") local path = require("path") -local process = require("process2") +local process = require("process") local util = require("util") local curl = require("curl-sys") diff --git a/packages/lde-core/src/package/run.lua b/packages/lde-core/src/package/run.lua index 18e17718..2cd2fd34 100644 --- a/packages/lde-core/src/package/run.lua +++ b/packages/lde-core/src/package/run.lua @@ -1,7 +1,7 @@ local fs = require("fs") local path = require("path") local ffi = require("ffi") -local process = require("process2") +local process = require("process") local runtime = require("lde-core.runtime") ---@param package lde.Package diff --git a/packages/lde-core/tests/commonrocks.test.lua b/packages/lde-core/tests/commonrocks.test.lua index 5decb6b6..0a11050f 100644 --- a/packages/lde-core/tests/commonrocks.test.lua +++ b/packages/lde-core/tests/commonrocks.test.lua @@ -4,7 +4,6 @@ local fs = require("fs") local env = require("env") local path = require("path") local json = require("json") -local process = require("process2") local tmpBase = path.join(env.tmpdir(), "lde-commonrocks-tests") fs.rmdir(tmpBase) diff --git a/packages/lde-core/tests/compile.test.lua b/packages/lde-core/tests/compile.test.lua index aea5bf85..9dd2ba8b 100644 --- a/packages/lde-core/tests/compile.test.lua +++ b/packages/lde-core/tests/compile.test.lua @@ -5,7 +5,7 @@ local fs = require("fs") local env = require("env") local path = require("path") local json = require("json") -local process = require("process2") +local process = require("process") local tmpBase = path.join(env.tmpdir(), "lde-compile-tests") fs.rmdir(tmpBase) diff --git a/packages/lde/lde.json b/packages/lde/lde.json index b3bf638f..c4f3d225 100644 --- a/packages/lde/lde.json +++ b/packages/lde/lde.json @@ -2,13 +2,13 @@ "name": "lde", "version": "0.1.0", "dependencies": { - "json": { "path": "../json" }, + "json": { "git": "https://github.com/lde-org/json" }, "ansi": { "path": "../ansi" }, - "path": { "path": "../path" }, - "process2": { "path": "../process2" }, + "path": { "git": "https://github.com/lde-org/path" }, + "process": { "git": "https://github.com/lde-org/process" }, "clap": { "path": "../clap" }, - "fs": { "path": "../fs" }, - "env": { "path": "../env" }, + "fs": { "git": "https://github.com/lde-org/fs" }, + "env": { "git": "https://github.com/lde-org/env" }, "curl-sys": { "git": "https://github.com/lde-org/curl-sys" }, "semver": { "path": "../semver" }, "lde-core": { "path": "../lde-core" }, diff --git a/packages/lde/src/commands/compile.lua b/packages/lde/src/commands/compile.lua index d523d8d1..8efa808c 100644 --- a/packages/lde/src/commands/compile.lua +++ b/packages/lde/src/commands/compile.lua @@ -1,6 +1,5 @@ local ansi = require("ansi") local fs = require("fs") -local process = require("process2") local path = require("path") local lde = require("lde-core") diff --git a/packages/lde/src/commands/publish.lua b/packages/lde/src/commands/publish.lua index 8f2c2af2..a06b8a4a 100644 --- a/packages/lde/src/commands/publish.lua +++ b/packages/lde/src/commands/publish.lua @@ -1,7 +1,7 @@ local ansi = require("ansi") local git = require("git") local json = require("json") -local process = require("process2") +local process = require("process") local lde = require("lde-core") diff --git a/packages/lde/src/commands/run.lua b/packages/lde/src/commands/run.lua index b983fdec..e37da927 100644 --- a/packages/lde/src/commands/run.lua +++ b/packages/lde/src/commands/run.lua @@ -1,7 +1,7 @@ local env = require("env") local fs = require("fs") local ansi = require("ansi") -local process = require("process2") +local process = require("process") local lde = require("lde-core") diff --git a/packages/lde/src/commands/uninstall.lua b/packages/lde/src/commands/uninstall.lua index 41d016dd..f2a79dd2 100644 --- a/packages/lde/src/commands/uninstall.lua +++ b/packages/lde/src/commands/uninstall.lua @@ -1,7 +1,6 @@ local ansi = require("ansi") local fs = require("fs") local path = require("path") -local process = require("process2") local lde = require("lde-core") diff --git a/packages/lde/src/init.lua b/packages/lde/src/init.lua index bae234ef..39026da3 100755 --- a/packages/lde/src/init.lua +++ b/packages/lde/src/init.lua @@ -16,61 +16,106 @@ if os.getenv("BOOTSTRAP") then end local isWindows = separator == '\\' + + if not baseDir:match("^/") and not baseDir:match("^%a:[/\\]") then + local cwd = isWindows and io.popen("cd"):read("*l") or io.popen("pwd"):read("*l") + baseDir = cwd .. separator .. baseDir + end + local ldeModulesDir = join(baseDir, "target") local function exists(path) local ok, _, code = os.rename(path, path) - if not ok then - if code == 13 then -- permission denied but exists - return true - end - - return false + return code == 13 -- permission denied means it exists end - return true end - if not exists(ldeModulesDir) then - if isWindows then - os.execute('mkdir "' .. ldeModulesDir .. '"') - else - os.execute('mkdir -p "' .. ldeModulesDir .. '"') + local function mkdir(dir) + if not exists(dir) then + if isWindows then + os.execute('mkdir "' .. dir .. '"') + else + os.execute('mkdir -p "' .. dir .. '"') + end end end + -- Semantics of src differ between Windows and Unix symlinks: Windows needs + -- an absolute path for junction points, Unix prefers relative for portability. + local function mklink(src, dest, absSrc) + if not exists(dest) then + if isWindows then + os.execute('mklink /J "' .. dest .. '" "' .. absSrc .. '"') + else + os.execute("ln -sf '" .. src .. "' '" .. dest .. "'") + end + end + end + + mkdir(ldeModulesDir) + local pathPackages = { - "ansi", "clap", "fs", "env", "path", "json", "git", "luarocks", - "process2", "sea", "semver", "util", "lde-core", "lde-test", "rocked", "archive" + "ansi", "clap", "git", "luarocks", "readline", + "sea", "semver", "util", "lde-core", "lde-test", "rocked" } for _, pkg in ipairs(pathPackages) do - -- Semantics of the 'src' differ between windows and linux symlinks - local relSrcPath = join("..", "..", pkg, "src") - local absSrcPath = join(baseDir, "..", pkg, "src") + mklink( + join("..", "..", pkg, "src"), + join(ldeModulesDir, pkg), + join(baseDir, "..", pkg, "src") + ) + end + + local tmpBase = os.getenv("TEMP") or os.getenv("TMPDIR") or "/tmp" + local tmpLDEDir = join(tmpBase, "lde") + + ---@type { name: string, url: string }[] + local gitPackages = { + { name = "fs", url = "https://github.com/lde-org/fs" }, + { name = "env", url = "https://github.com/lde-org/env" }, + { name = "process", url = "https://github.com/lde-org/process" }, + { name = "path", url = "https://github.com/lde-org/path" }, + { name = "archive", url = "https://github.com/lde-org/archive" }, + { name = "git", url = "https://github.com/lde-org/git" }, + { name = "json", url = "https://github.com/lde-org/json" }, + { name = "ffix", url = "https://github.com/lde-org/ffix" }, + { name = "curl-sys", url = "https://github.com/lde-org/curl-sys" }, + { name = "git2-sys", url = "https://github.com/lde-org/git2-sys" }, + { name = "deflate-sys", url = "https://github.com/lde-org/deflate-sys" } + } + + mkdir(tmpLDEDir) - local moduleDistPath = join(ldeModulesDir, pkg) + for _, pkg in ipairs(gitPackages) do + local moduleDistPath = join(ldeModulesDir, pkg.name) if not exists(moduleDistPath) then - if isWindows then - os.execute('mklink /J "' .. moduleDistPath .. '" "' .. absSrcPath .. '"') + local cloneDir = join(tmpLDEDir, pkg.name) + if not exists(cloneDir) then + os.execute('git clone --depth 1 --recurse-submodules --shallow-submodules "' .. + pkg.url .. '" "' .. cloneDir .. '"') + end + + local buildScript = join(cloneDir, "build.lua") + if exists(buildScript) then + if isWindows then + os.execute('xcopy /E /I /Y "' .. join(cloneDir, "src") .. '" "' .. moduleDistPath .. '"') + os.execute('cd /d "' .. + cloneDir .. '" && set LDE_OUTPUT_DIR=' .. moduleDistPath .. ' && luajit "' .. buildScript .. '"') + else + os.execute('cp -r "' .. join(cloneDir, "src") .. '/." "' .. moduleDistPath .. '"') + os.execute('cd "' .. + cloneDir .. '" && LDE_OUTPUT_DIR="' .. moduleDistPath .. '" luajit "' .. buildScript .. '"') + end else - os.execute("ln -sf '" .. relSrcPath .. "' '" .. moduleDistPath .. "'") + mklink(join(cloneDir, "src"), moduleDistPath, join(cloneDir, "src")) end end end - local moduleDistPath = join(ldeModulesDir, "lde") - if not exists(moduleDistPath) then - local relSrcPath = join("..", "src") - local absSrcPath = join(baseDir, "src") - - if isWindows then - os.execute('mklink /J "' .. moduleDistPath .. '" "' .. absSrcPath .. '"') - else - os.execute("ln -sf '" .. relSrcPath .. "' '" .. moduleDistPath .. "'") - end - end + mklink(join("..", "src"), join(ldeModulesDir, "lde"), join(baseDir, "src")) end local ansi = require("ansi") diff --git a/packages/lde/src/setup.lua b/packages/lde/src/setup.lua index 4fa879f4..4b03c050 100644 --- a/packages/lde/src/setup.lua +++ b/packages/lde/src/setup.lua @@ -1,7 +1,7 @@ local ansi = require("ansi") local fs = require("fs") local path = require("path") -local process = require("process2") +local process = require("process") local lde = require("lde-core") diff --git a/packages/lde/tests/lib/ldecli.lua b/packages/lde/tests/lib/ldecli.lua index 2f0f8719..8b8d3f04 100644 --- a/packages/lde/tests/lib/ldecli.lua +++ b/packages/lde/tests/lib/ldecli.lua @@ -1,4 +1,4 @@ -local process = require("process2") +local process = require("process") local env = require("env") local ldePath = assert(env.execPath()) diff --git a/packages/path/lde.json b/packages/path/lde.json deleted file mode 100644 index f8a8584d..00000000 --- a/packages/path/lde.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "path", - "version": "0.1.0" -} diff --git a/packages/path/src/init.lua b/packages/path/src/init.lua deleted file mode 100644 index 7f62076d..00000000 --- a/packages/path/src/init.lua +++ /dev/null @@ -1,131 +0,0 @@ -local path = {} - -path.separator = string.sub(package.config, 1, 1) - -local isWindows = path.separator == "\\" - ----@param p string -function path.basename(p) - return string.match(p, "([^/\\]+)$") or "" -end - ----@param p string -function path.extension(p) - return path.basename(p):match("%.([^%.]+)$") or "" -end - ----@param p string -function path.dirname(p) - return p:match("^(.*)[/\\]") or "." -end - -local windowsDriveLetter = "^%a:\\" - ----@param p string -function path.isAbsolute(p) - if string.sub(p, 1, 1) == path.separator then - return true - end - - if isWindows then - return string.match(p, windowsDriveLetter) ~= nil - end -end - ----@param p string -function path.parts(p) - return string.gmatch(p, "[^/\\]+") -end - ----@param p string -function path.root(p) - local root = string.sub(p, 1, 1) - if root == path.separator then - return root - end - - if isWindows then - root = string.match(p, windowsDriveLetter) - if root then - return root - end - end -end - -function path.normalize(p) - local root = path.root(p) -- Root if absolute - local isRelative = root == nil - local parts = {} - - for part in path.parts(p) do - if part == ".." then - if #parts > 0 and parts[#parts] ~= ".." then - table.remove(parts) - elseif isRelative then - parts[#parts + 1] = ".." - end - elseif part ~= "." and part ~= "" then - parts[#parts + 1] = part - end - end - - if #parts == 0 then - return root or "." - else - local result = table.concat(parts, path.separator) - if root and root == path.separator then - return root .. result - else - return result - end - end -end - ----@param base string ----@param relative string -function path.resolve(base, relative) - if path.isAbsolute(relative) then - return path.normalize(relative) - end - - return path.normalize(base .. path.separator .. relative) -end - ----@param ... string -function path.join(...) - return table.concat({ ... }, path.separator) -end - ----@param from string ----@param to string -function path.relative(from, to) - from = path.normalize(from) - to = path.normalize(to) - - local fromParts = {} - for part in path.parts(from) do fromParts[#fromParts + 1] = part end - - local toParts = {} - for part in path.parts(to) do toParts[#toParts + 1] = part end - - local commonLength = 0 - for i = 1, math.min(#fromParts, #toParts) do - if fromParts[i] == toParts[i] then - commonLength = i - else - break - end - end - - local relativeParts = {} - for _ = commonLength + 1, #fromParts do relativeParts[#relativeParts + 1] = ".." end - for i = commonLength + 1, #toParts do relativeParts[#relativeParts + 1] = toParts[i] end - - if #relativeParts == 0 then - return "." - end - - return table.concat(relativeParts, path.separator) -end - -return path diff --git a/packages/process2/.gitignore b/packages/process2/.gitignore deleted file mode 100644 index 8a3d2033..00000000 --- a/packages/process2/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target/ -/lde.lock \ No newline at end of file diff --git a/packages/process2/lde.json b/packages/process2/lde.json deleted file mode 100644 index 66138bca..00000000 --- a/packages/process2/lde.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "name": "process2", - "version": "0.1.0", - "dependencies": {}, - "devDependencies": { - "lde-test": { "path": "../lde-test" } - } -} diff --git a/packages/process2/src/init.lua b/packages/process2/src/init.lua deleted file mode 100644 index 5f6e4cc2..00000000 --- a/packages/process2/src/init.lua +++ /dev/null @@ -1,120 +0,0 @@ -local isWindows = jit.os == "Windows" - ----@class process2.raw -local raw = isWindows - and require("process2.raw.windows") - or require("process2.raw.posix") - ----@alias process2.Stdio "pipe" | "inherit" | "null" - ----@class process2.Options ----@field cwd string? ----@field env table? ----@field stdin string? ----@field stdout process2.Stdio? ----@field stderr process2.Stdio? - ----@class process2.Child ----@field pid number ----@field kill fun(self: process2.Child, force: boolean?) ----@field wait fun(self: process2.Child): number?, string?, string? ----@field poll fun(self: process2.Child): number? - ----@class process2 -local process2 = {} - -if jit.os == "Windows" then - process2.platform = "win32" -elseif jit.os == "Linux" then - process2.platform = "linux" -elseif jit.os == "OSX" then - process2.platform = "darwin" -else - process2.platform = "unix" -end - -local function readOut(r) - if isWindows then - local stdout = r.stdoutHandle and raw.readHandle(r.stdoutHandle) or nil - local stderr = r.stderrHandle and raw.readHandle(r.stderrHandle) or nil - return stdout, stderr - else - if r.stdoutFd and r.stderrFd then - return raw.readFds(r.stdoutFd, r.stderrFd) - end - - local stdout = r.stdoutFd and raw.readFd(r.stdoutFd) or nil - local stderr = r.stderrFd and raw.readFd(r.stderrFd) or nil - return stdout, stderr - end -end - -local function waitHandle(r) - if isWindows then return raw.wait(r.handle) else return raw.wait(r.pid) end -end - -local function pollHandle(r) - if isWindows then return raw.poll(r.handle) else return raw.poll(r.pid) end -end - -local function killHandle(r, force) - if isWindows then raw.kill(r.handle) else raw.kill(r.pid, force) end -end - ---- Spawn a process asynchronously. Returns a Child handle. ----@param name string ----@param args string[]? ----@param opts process2.Options? ----@return process2.Child?, string? -function process2.spawn(name, args, opts) - opts = opts or {} - local result, err = raw.spawn(name, args or {}, { - cwd = opts.cwd, - env = opts.env, - stdin = opts.stdin, - stdout = opts.stdout or "null", - stderr = opts.stderr or "null" - }) - if not result then return nil, err end - - local r = result - ---@type process2.Child - local child = { pid = result.pid } - - function child:kill(force) killHandle(r, force) end - - function child:wait() - local stdout, stderr = readOut(r) - local code = waitHandle(r) - return code, stdout, stderr - end - - function child:poll() return pollHandle(r) end - - return child -end - ---- Execute a process and block until it exits. ----@param name string ----@param args string[]? ----@param opts process2.Options? ----@return number? exitCode ----@return string? stdout ----@return string? stderr -function process2.exec(name, args, opts) - opts = opts or {} - local result, err = raw.spawn(name, args or {}, { - cwd = opts.cwd, - env = opts.env, - stdin = opts.stdin, - stdout = opts.stdout or "pipe", - stderr = opts.stderr or "pipe" - }) - if not result then return nil, nil, err end - - local stdout, stderr = readOut(result) - local code = waitHandle(result) - return code, stdout, stderr -end - -return process2 diff --git a/packages/process2/src/raw/posix.lua b/packages/process2/src/raw/posix.lua deleted file mode 100644 index b0361d2b..00000000 --- a/packages/process2/src/raw/posix.lua +++ /dev/null @@ -1,214 +0,0 @@ -local ffi = require("ffi") -local sb = require("string.buffer") - -ffi.cdef([[ - typedef int pid_t; - pid_t fork(void); - int execvp(const char* file, const char* const argv[]); - pid_t waitpid(pid_t pid, int* status, int options); - int kill(pid_t pid, int sig); - int pipe(int pipefd[2]); - long read(int fd, void* buf, size_t count); - long write(int fd, const void* buf, size_t count); - int close(int fd); - int dup2(int oldfd, int newfd); - int open(const char* path, int flags, ...); - int setenv(const char* name, const char* value, int overwrite); - int chdir(const char* path); - void _exit(int status); - struct pollfd { int fd; short events; short revents; }; - int poll(struct pollfd* fds, unsigned long nfds, int timeout); -]]) - -local WNOHANG = 1 -local SIGTERM = 15 -local SIGKILL = 9 -local O_WRONLY = 1 -local POLLIN = 1 -local POLLHUP = 16 - ----@diagnostic disable: assign-type-mismatch # Ignore incessant ffi type cast annoyance - ----@class process2.ffi.IntBox: ffi.cdata* ----@field [0] number - ----@type fun(): process2.ffi.IntBox -local IntBox = ffi.typeof("int[1]") - ----@class process2.ffi.PipeFds: ffi.cdata* ----@field [0] number ----@field [1] number - ----@type fun(): process2.ffi.PipeFds -local PipeFds = ffi.typeof("int[2]") - ----@type fun(size: number): ffi.cdata* -local PollFds = ffi.typeof("struct pollfd[?]") - ----@class process2.ffi.Argv: ffi.cdata* ----@field [0] string? - ----@type fun(size: number): process2.ffi.Argv -local Argv = ffi.typeof("const char*[?]") - ----@class process2.raw -local M = {} - ----@param status number ----@return number? -local function decodeExit(status) - if bit.band(status, 0x7f) == 0 then - return bit.rshift(bit.band(status, 0xff00), 8) - end - return nil -end - ----@param name string ----@param args string[] ----@return process2.ffi.Argv -local function makeArgv(name, args) - local argv = Argv(#args + 2) - argv[0] = name - for i, a in ipairs(args) do argv[i] = a end - argv[#args + 1] = nil - return argv -end - ---- Spawn a child process. ----@param name string ----@param args string[] ----@param opts { cwd: string?, env: table?, stdin: string?, stdout: "pipe"|"inherit"|"null"?, stderr: "pipe"|"inherit"|"null"? }? ----@return { pid: number, stdoutFd: number?, stderrFd: number? }?, string? -function M.spawn(name, args, opts) - opts = opts or {} - local stdoutMode = opts.stdout or "pipe" - local stderrMode = opts.stderr or "pipe" - local hasStdin = opts.stdin ~= nil - - local pIn = PipeFds() - local pOut = PipeFds() - local pErr = PipeFds() - - if hasStdin and ffi.C.pipe(pIn) ~= 0 then return nil, "pipe() failed" end - if stdoutMode == "pipe" and ffi.C.pipe(pOut) ~= 0 then return nil, "pipe() failed" end - if stderrMode == "pipe" and ffi.C.pipe(pErr) ~= 0 then return nil, "pipe() failed" end - - local pid = ffi.C.fork() - if pid < 0 then return nil, "fork() failed" end - - if pid == 0 then - if hasStdin then - ffi.C.dup2(pIn[0], 0); ffi.C.close(pIn[0]); ffi.C.close(pIn[1]) - end - if stdoutMode == "pipe" then - ffi.C.dup2(pOut[1], 1); ffi.C.close(pOut[0]); ffi.C.close(pOut[1]) - elseif stdoutMode == "null" then - local fd = ffi.C.open("/dev/null", O_WRONLY); ffi.C.dup2(fd, 1); ffi.C.close(fd) - end - if stderrMode == "pipe" then - ffi.C.dup2(pErr[1], 2); ffi.C.close(pErr[0]); ffi.C.close(pErr[1]) - elseif stderrMode == "null" then - local fd = ffi.C.open("/dev/null", O_WRONLY); ffi.C.dup2(fd, 2); ffi.C.close(fd) - end - if opts.cwd then ffi.C.chdir(opts.cwd) end - if opts.env then for k, v in pairs(opts.env) do ffi.C.setenv(k, v, 1) end end - ffi.C.execvp(name, makeArgv(name, args)) - ffi.C._exit(1) - end - - if hasStdin then ffi.C.close(pIn[0]) end - if stdoutMode == "pipe" then ffi.C.close(pOut[1]) end - if stderrMode == "pipe" then ffi.C.close(pErr[1]) end - - if hasStdin then - ffi.C.write(pIn[1], opts.stdin, #opts.stdin) - ffi.C.close(pIn[1]) - end - - return { - pid = tonumber(pid), - stdoutFd = stdoutMode == "pipe" and tonumber(pOut[0]) or nil, - stderrFd = stderrMode == "pipe" and tonumber(pErr[0]) or nil - } -end - ----@param fd number ----@return string -function M.readFd(fd) - local out = sb.new() - while true do - local ptr, len = out:reserve(4096) - local n = ffi.C.read(fd, ptr, len) - if n > 0 then out:commit(n) - else out:commit(0); break - end - end - ffi.C.close(fd) - return out:tostring() -end - ---- Drain two fds concurrently using poll() to avoid deadlock. ----@param outFd number ----@param errFd number ----@return string, string -function M.readFds(outFd, errFd) - local outBuf, errBuf = sb.new(), sb.new() - local fds = PollFds(2) - local outDone, errDone = false, false - while not outDone or not errDone do - fds[0].fd = outDone and -1 or outFd - fds[0].events = POLLIN - fds[1].fd = errDone and -1 or errFd - fds[1].events = POLLIN - ffi.C.poll(fds, 2, -1) - if not outDone then - if bit.band(fds[0].revents, POLLIN) ~= 0 then - local ptr, len = outBuf:reserve(4096) - local n = ffi.C.read(outFd, ptr, len) - if n > 0 then outBuf:commit(n) - else outBuf:commit(0); outDone = true - end - elseif fds[0].revents ~= 0 then - outDone = true - end - end - if not errDone then - if bit.band(fds[1].revents, POLLIN) ~= 0 then - local ptr, len = errBuf:reserve(4096) - local n = ffi.C.read(errFd, ptr, len) - if n > 0 then errBuf:commit(n) - else errBuf:commit(0); errDone = true - end - elseif fds[1].revents ~= 0 then - errDone = true - end - end - end - ffi.C.close(outFd) - ffi.C.close(errFd) - return outBuf:tostring(), errBuf:tostring() -end - ----@param pid number ----@return number? -function M.wait(pid) - local st = IntBox() - ffi.C.waitpid(pid, st, 0) - return decodeExit(st[0]) -end - ----@param pid number ----@return number? -function M.poll(pid) - local st = IntBox() - if ffi.C.waitpid(pid, st, WNOHANG) == 0 then return nil end - return decodeExit(st[0]) -end - ----@param pid number ----@param force boolean? -function M.kill(pid, force) - ffi.C.kill(pid, force and SIGKILL or SIGTERM) -end - -return M diff --git a/packages/process2/src/raw/windows.lua b/packages/process2/src/raw/windows.lua deleted file mode 100644 index 6d8ee047..00000000 --- a/packages/process2/src/raw/windows.lua +++ /dev/null @@ -1,341 +0,0 @@ -local ffi = require("ffi") -local buffer = require("string.buffer") - -ffi.cdef([[ - typedef void* HANDLE; - typedef uint32_t DWORD; - typedef int BOOL; - typedef uint16_t WORD; - typedef char* LPSTR; - - typedef struct { - DWORD nLength; - void* lpSecurityDescriptor; - BOOL bInheritHandle; - } SECURITY_ATTRIBUTES; - - typedef struct { - DWORD cb; - LPSTR lpReserved; - LPSTR lpDesktop; - LPSTR lpTitle; - DWORD dwX, dwY, dwXSize, dwYSize; - DWORD dwXCountChars, dwYCountChars; - DWORD dwFillAttribute; - DWORD dwFlags; - WORD wShowWindow; - WORD cbReserved2; - void* lpReserved2; - HANDLE hStdInput; - HANDLE hStdOutput; - HANDLE hStdError; - } STARTUPINFOA; - - typedef struct { - HANDLE hProcess; - HANDLE hThread; - DWORD dwProcessId; - DWORD dwThreadId; - } PROCESS_INFORMATION; - - BOOL CreateProcessA( - const char* lpApplicationName, - char* lpCommandLine, - void* lpProcessAttributes, - void* lpThreadAttributes, - BOOL bInheritHandles, - DWORD dwCreationFlags, - void* lpEnvironment, - const char* lpCurrentDirectory, - STARTUPINFOA* lpStartupInfo, - PROCESS_INFORMATION* lpProcessInformation - ); - - BOOL CreatePipe(HANDLE* hReadPipe, HANDLE* hWritePipe, SECURITY_ATTRIBUTES* lpPipeAttributes, DWORD nSize); - BOOL SetHandleInformation(HANDLE hObject, DWORD dwMask, DWORD dwFlags); - BOOL ReadFile(HANDLE hFile, void* lpBuffer, DWORD nNumberOfBytesToRead, DWORD* lpNumberOfBytesRead, void* lpOverlapped); - BOOL WriteFile(HANDLE hFile, const void* lpBuffer, DWORD nNumberOfBytesToWrite, DWORD* lpNumberOfBytesWritten, void* lpOverlapped); - DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds); - BOOL GetExitCodeProcess(HANDLE hProcess, DWORD* lpExitCode); - BOOL TerminateProcess(HANDLE hProcess, DWORD uExitCode); - BOOL CloseHandle(HANDLE hObject); - HANDLE GetStdHandle(DWORD nStdHandle); - HANDLE CreateFileA(const char*, DWORD, DWORD, void*, DWORD, DWORD, HANDLE); - char* GetEnvironmentStringsA(void); - BOOL FreeEnvironmentStringsA(char* penv); -]]) - -local kernel32 = ffi.load("kernel32") - -local STARTF_USESTDHANDLES = 0x00000100 -local HANDLE_FLAG_INHERIT = 0x00000001 -local INFINITE = 0xFFFFFFFF -local STILL_ACTIVE = 259 -local CREATE_NO_WINDOW = 0x08000000 -local STD_INPUT_HANDLE = ffi.cast("DWORD", -10) -local STD_OUTPUT_HANDLE = ffi.cast("DWORD", -11) -local STD_ERROR_HANDLE = ffi.cast("DWORD", -12) -local INVALID_HANDLE_VALUE = ffi.cast("HANDLE", -1) -local GENERIC_READ = 0x80000000 -local GENERIC_WRITE = 0x40000000 -local OPEN_EXISTING = 3 -local FILE_ATTRIBUTE_NORMAL = 0x80 - ----@class process2.ffi.SecurityAttributes: ffi.cdata* ----@field nLength number ----@field lpSecurityDescriptor ffi.cdata* ----@field bInheritHandle number - ----@diagnostic disable: assign-type-mismatch # Ignore incessant ffi type cast annoyance - ----@type fun(): process2.ffi.SecurityAttributes -local SecurityAttributes = ffi.typeof("SECURITY_ATTRIBUTES") - ----@type number -local SecurityAttributesSize = ffi.sizeof("SECURITY_ATTRIBUTES") - ----@class process2.ffi.StartupInfoA: ffi.cdata* ----@field cb number ----@field dwFlags number ----@field hStdInput ffi.cdata* ----@field hStdOutput ffi.cdata* ----@field hStdError ffi.cdata* - ----@type fun(): process2.ffi.StartupInfoA -local StartupInfoA = ffi.typeof("STARTUPINFOA") - ----@type number -local StartupInfoASize = ffi.sizeof("STARTUPINFOA") - ----@class process2.ffi.ProcessInformation: ffi.cdata* ----@field hProcess ffi.cdata* ----@field hThread ffi.cdata* ----@field dwProcessId number ----@field dwThreadId number - ----@type fun(): process2.ffi.ProcessInformation -local ProcessInformation = ffi.typeof("PROCESS_INFORMATION") - ----@class process2.ffi.HandleBox: ffi.cdata* ----@field [0] ffi.cdata* - ----@type fun(): process2.ffi.HandleBox -local HandleBox = ffi.typeof("HANDLE[1]") - ----@class process2.ffi.DwordBox: ffi.cdata* ----@field [0] number - ----@type fun(): process2.ffi.DwordBox -local DwordBox = ffi.typeof("DWORD[1]") - ----@class process2.ffi.CharBuf: ffi.cdata* - ----@type fun(size: number, s: string?): process2.ffi.CharBuf -local CharBuf = ffi.typeof("char[?]") - ----@class process2.raw -local M = {} - ----@param s string ----@return string -local function escapeArg(s) - if not s:find('[ \t\n\v"\\]') and s ~= "" then return s end - local out, i = { '"' }, 1 - while i <= #s do - local c = s:sub(i, i) - if c == "\\" then - local j = i - while j <= #s and s:sub(j, j) == "\\" do j = j + 1 end - local nbs = j - i - if j > #s or s:sub(j, j) == '"' then nbs = nbs * 2 end - out[#out + 1] = string.rep("\\", nbs) - i = j - elseif c == '"' then - out[#out + 1] = '\\"'; i = i + 1 - else - out[#out + 1] = c; i = i + 1 - end - end - out[#out + 1] = '"' - return table.concat(out) -end - ----@param name string ----@param args string[] ----@return string -local function buildCmdLine(name, args) - local parts = { escapeArg(name) } - for _, a in ipairs(args) do parts[#parts + 1] = escapeArg(a) end - return table.concat(parts, " ") -end - ----@param inheritRead boolean ----@param inheritWrite boolean ----@return ffi.cdata*?, ffi.cdata*? -local function makePipe(inheritRead, inheritWrite) - local sa = SecurityAttributes() - sa.nLength = SecurityAttributesSize - sa.bInheritHandle = 1 - - local r, w = HandleBox(), HandleBox() - if kernel32.CreatePipe(r, w, sa, 0) == 0 then return nil, nil end - if not inheritRead then kernel32.SetHandleInformation(r[0], HANDLE_FLAG_INHERIT, 0) end - if not inheritWrite then kernel32.SetHandleInformation(w[0], HANDLE_FLAG_INHERIT, 0) end - - return r[0], w[0] -end - ----@param write boolean ----@return ffi.cdata*? -local function nullHandle(write) - local h = kernel32.CreateFileA("nul", write and GENERIC_WRITE or GENERIC_READ, 0, nil, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nil) - return h ~= INVALID_HANDLE_VALUE and h or nil -end - ----@param overrides table ----@return string -local function buildEnvBlock(overrides) - -- Start with current environment - local env = {} - local block = kernel32.GetEnvironmentStringsA() - if block ~= nil then - local i = 0 - while true do - local s = ffi.string(block + i) - if #s == 0 then break end - local k, v = s:match("^([^=]+)=(.*)") - if k then env[k:upper()] = { key = k, val = v } end - i = i + #s + 1 - end - kernel32.FreeEnvironmentStringsA(block) - end - -- Apply overrides - for k, v in pairs(overrides) do env[k:upper()] = { key = k, val = v } end - local buf = buffer.new() - for _, entry in pairs(env) do buf:put(entry.key, "=", entry.val, "\0") end - buf:put("\0") - return buf:tostring() -end - ----@param name string ----@param args string[] ----@param opts { cwd: string?, env: table?, stdin: string?, stdout: "pipe"|"inherit"|"null"?, stderr: "pipe"|"inherit"|"null"? }? ----@return { handle: ffi.cdata*, pid: number, stdoutHandle: ffi.cdata*?, stderrHandle: ffi.cdata*? }?, string? -function M.spawn(name, args, opts) - opts = opts or {} - local stdoutMode = opts.stdout or "pipe" - local stderrMode = opts.stderr or "pipe" - local hasStdin = opts.stdin ~= nil - - local si = StartupInfoA() - si.cb = StartupInfoASize - si.dwFlags = STARTF_USESTDHANDLES - - local stdinR, stdinW, stdoutR, stdoutW, stderrR, stderrW - local nullOut, nullErr - - if hasStdin then - stdinR, stdinW = makePipe(true, false) - if not stdinR then return nil, "CreatePipe failed" end - si.hStdInput = stdinR - else - si.hStdInput = kernel32.GetStdHandle(STD_INPUT_HANDLE) - end - - if stdoutMode == "pipe" then - stdoutR, stdoutW = makePipe(false, true) - if not stdoutR then return nil, "CreatePipe failed" end - si.hStdOutput = stdoutW - elseif stdoutMode == "null" then - nullOut = nullHandle(true) - si.hStdOutput = nullOut - else - si.hStdOutput = kernel32.GetStdHandle(STD_OUTPUT_HANDLE) - end - - if stderrMode == "pipe" then - stderrR, stderrW = makePipe(false, true) - if not stderrR then return nil, "CreatePipe failed" end - si.hStdError = stderrW - elseif stderrMode == "null" then - nullErr = nullHandle(true) - si.hStdError = nullErr - else - si.hStdError = kernel32.GetStdHandle(STD_ERROR_HANDLE) - end - - local cmdStr = buildCmdLine(name, args) - local cmdLine = CharBuf(#cmdStr + 1, cmdStr) - local envStr = opts.env and buildEnvBlock(opts.env) or nil - local envBlock = envStr and ffi.cast("void*", envStr) or nil - local pi = ProcessInformation() - - local ok = kernel32.CreateProcessA( - nil, cmdLine, nil, nil, 1, - CREATE_NO_WINDOW, envBlock, opts.cwd or nil, si, pi - ) - - if stdinR then kernel32.CloseHandle(stdinR) end - if stdoutW then kernel32.CloseHandle(stdoutW) end - if stderrW then kernel32.CloseHandle(stderrW) end - if nullOut then kernel32.CloseHandle(nullOut) end - if nullErr then kernel32.CloseHandle(nullErr) end - - if ok == 0 then return nil, "CreateProcess failed" end - - kernel32.CloseHandle(pi.hThread) - - if hasStdin then - local written = DwordBox() - kernel32.WriteFile(stdinW, opts.stdin, #opts.stdin, written, nil) - kernel32.CloseHandle(stdinW) - end - - return { - handle = pi.hProcess, - pid = tonumber(pi.dwProcessId), - stdoutHandle = stdoutR, - stderrHandle = stderrR - } -end - ----@param handle ffi.cdata* ----@return string -function M.readHandle(handle) - local buf = CharBuf(4096) - local read = DwordBox() - local chunks = {} - while kernel32.ReadFile(handle, buf, 4096, read, nil) ~= 0 and read[0] > 0 do - chunks[#chunks + 1] = ffi.string(buf, read[0]) - end - kernel32.CloseHandle(handle) - return table.concat(chunks) -end - ----@param handle ffi.cdata* ----@return number? -function M.wait(handle) - kernel32.WaitForSingleObject(handle, INFINITE) - local code = DwordBox() - kernel32.GetExitCodeProcess(handle, code) - kernel32.CloseHandle(handle) - return tonumber(code[0]) -end - ----@param handle ffi.cdata* ----@return number? -function M.poll(handle) - local code = DwordBox() - kernel32.GetExitCodeProcess(handle, code) - if tonumber(code[0]) == STILL_ACTIVE then return nil end - kernel32.CloseHandle(handle) - return tonumber(code[0]) -end - ----@param handle ffi.cdata* -function M.kill(handle) - kernel32.TerminateProcess(handle, 1) -end - -return M diff --git a/packages/process2/tests/process2.test.lua b/packages/process2/tests/process2.test.lua deleted file mode 100644 index bd35925a..00000000 --- a/packages/process2/tests/process2.test.lua +++ /dev/null @@ -1,182 +0,0 @@ -local test = require("lde-test") -local process = require("process2") - -local isWindows = jit.os == "Windows" -local sh = isWindows and "cmd" or "sh" -local shc = isWindows and "/c" or "-c" - --- --- exec --- - -test.it("exec returns exit code 0 on success", function() - local code = process.exec(sh, { shc, "exit 0" }) - test.equal(code, 0) -end) - -test.it("exec returns non-zero exit code on failure", function() - local code = process.exec(sh, { shc, "exit 1" }) - test.equal(code, 1) -end) - -test.it("exec captures stdout", function() - local cmd = isWindows and "echo hello" or "printf hello" - local code, stdout = process.exec(sh, { shc, cmd }) - test.equal(code, 0) - test.truthy(stdout and stdout:find("hello")) -end) - -test.it("exec captures stderr (merged into stdout on posix)", function() - local cmd = isWindows and "echo err 1>&2" or "printf err >&2" - local code, stdout, stderr = process.exec(sh, { shc, cmd }) - test.equal(code, 0) - -- stderr is captured separately on all platforms - test.truthy(stderr and stderr:find("err")) -end) - -test.it("exec passes stdin", function() - local cmd = isWindows and "more" or "cat" - local code, stdout = process.exec(sh, { shc, cmd }, { stdin = "hello" }) - test.equal(code, 0) - test.truthy(stdout and stdout:find("hello")) -end) - -test.it("exec passes env vars", function() - local cmd = isWindows and "echo %MY_VAR%" or "printf $MY_VAR" - local code, stdout = process.exec(sh, { shc, cmd }, { env = { MY_VAR = "testval" } }) - test.equal(code, 0) - test.truthy(stdout and stdout:find("testval")) -end) - -test.it("exec inherits existing env vars when passing custom env", function() - -- PATH must always be inherited or nothing works - local cmd = isWindows and "echo %PATH%" or "printf $PATH" - local code, stdout = process.exec(sh, { shc, cmd }, { env = { MY_EXTRA = "extra" } }) - test.equal(code, 0) - test.truthy(stdout and #stdout > 0) -end) - -test.it("exec env override replaces specific var without dropping others", function() - local cmd = isWindows and "echo %MY_VAR% %PATH%" or "printf '%s %s' $MY_VAR $PATH" - local code, stdout = process.exec(sh, { shc, cmd }, { env = { MY_VAR = "overridden" } }) - test.equal(code, 0) - -- both the override and inherited PATH should be present - test.truthy(stdout and stdout:find("overridden")) - test.truthy(stdout and #stdout > #"overridden") -- PATH adds more content -end) - -test.it("exec respects cwd", function() - local cmd = isWindows and "cd" or "pwd" - local tmpdir = isWindows and (os.getenv("TEMP") or "C:\\Temp") or "/tmp" - local code, stdout = process.exec(sh, { shc, cmd }, { cwd = tmpdir }) - test.equal(code, 0) - test.truthy(stdout and #stdout > 0) -end) - -test.it("exec handles args with spaces and special chars", function() - -- pass a quoted string through echo; just verify it doesn't crash and exits 0 - local code = process.exec(sh, { shc, isWindows and 'echo "hello world"' or "printf '%s' 'hello world'" }) - test.equal(code, 0) -end) - --- --- spawn (async Child) --- - -test.it("spawn returns a Child with a pid", function() - local child, err = process.spawn(sh, { shc, "exit 0" }) - test.truthy(child, err) - test.truthy(child.pid > 0) - child:wait() -end) - -test.it("spawn Child:wait returns exit code", function() - local child = process.spawn(sh, { shc, "exit 42" }) - test.truthy(child) - local code = child:wait() - test.equal(code, 42) -end) - -test.it("spawn Child:wait captures stdout when piped", function() - local cmd = isWindows and "echo hi" or "printf hi" - local child = process.spawn(sh, { shc, cmd }, { stdout = "pipe" }) - test.truthy(child) - local code, stdout = child:wait() - test.equal(code, 0) - test.truthy(stdout and stdout:find("hi")) -end) - -test.it("spawn Child:kill terminates the process", function() - local cmd = isWindows and "timeout /t 30 /nobreak >nul" or "sleep 30" - local child = process.spawn(sh, { shc, cmd }) - test.truthy(child) - child:kill(true) - child:wait() -- must not hang - test.truthy(true) -end) - -test.it("spawn Child:poll returns nil while running", function() - local cmd = isWindows and "timeout /t 30 /nobreak >nul" or "sleep 30" - local child = process.spawn(sh, { shc, cmd }) - test.truthy(child) - local code = child:poll() - test.falsy(code) -- still running - child:kill(true) - child:wait() -end) - --- --- stdio modes --- - -test.it("exec with stdout=null discards output", function() - local cmd = isWindows and "echo hello" or "printf hello" - local code, stdout = process.exec(sh, { shc, cmd }, { stdout = "null" }) - test.equal(code, 0) - test.falsy(stdout) -end) - -test.it("exec with stderr=null discards stderr", function() - local code, stdout, stderr = process.exec(sh, { shc, "exit 0" }, { stderr = "null" }) - test.equal(code, 0) - test.falsy(stderr) -end) - -test.it("exec with stdout=inherit does not capture stdout", function() - local code, stdout = process.exec(sh, { shc, "exit 0" }, { stdout = "inherit", stderr = "null" }) - test.equal(code, 0) - test.falsy(stdout) -end) - -test.it("exec with stderr=inherit does not capture stderr", function() - local code, stdout, stderr = process.exec(sh, { shc, "exit 0" }, { stdout = "null", stderr = "inherit" }) - test.equal(code, 0) - test.falsy(stderr) -end) - -test.it("spawn with stderr=pipe captures stderr separately", function() - local cmd = isWindows and "echo hello" or "printf hello" - local child = process.spawn(sh, { shc, cmd }, { stdout = "pipe", stderr = "pipe" }) - test.truthy(child) - local code, stdout, stderr = child:wait() - test.equal(code, 0) - test.truthy(stdout and stdout:find("hello")) -end) - --- --- platform --- - -test.it("platform is set to a known value", function() - local known = { win32 = true, linux = true, darwin = true, unix = true } - test.truthy(known[process.platform]) -end) - --- --- exec errors on bad binary --- - -test.it("exec returns non-zero code when binary does not exist", function() - local code = process.exec("__no_such_binary__", {}) - test.truthy(code ~= 0) -end) diff --git a/packages/sea/lde.json b/packages/sea/lde.json index 8cccadf0..1b928067 100644 --- a/packages/sea/lde.json +++ b/packages/sea/lde.json @@ -2,12 +2,12 @@ "name": "sea", "version": "0.1.0", "dependencies": { - "process2": { "path": "../process2" }, - "fs": { "path": "../fs" }, - "path": { "path": "../path" }, - "env": { "path": "../env" }, + "process": { "git": "https://github.com/lde-org/process" }, + "fs": { "git": "https://github.com/lde-org/fs" }, + "path": { "git": "https://github.com/lde-org/path" }, + "env": { "git": "https://github.com/lde-org/env" }, "util": { "path": "../util" }, - "archive": { "path": "../archive" }, + "archive": { "git": "https://github.com/lde-org/archive" }, "curl-sys": { "git": "https://github.com/lde-org/curl-sys" } } } diff --git a/packages/sea/src/init.lua b/packages/sea/src/init.lua index 4cac976d..e3638708 100644 --- a/packages/sea/src/init.lua +++ b/packages/sea/src/init.lua @@ -1,6 +1,6 @@ local sea = {} -local process = require("process2") +local process = require("process") local path = require("path") local env = require("env") local fs = require("fs") @@ -289,7 +289,7 @@ char lde_tmpdir[4096]; ) } - local stdintInclude = hasLibs and "#include \n#include \n#include " or "" + local stdintInclude = (hasLibs and "#include \n#include \n#include \n" or "") .. "#ifdef __ANDROID__\n#include \n#endif\n" -- lde_loadlib_loader: a C closure that calls package.loadlib(upvalue1, "*"). -- Only emitted when there are shared libs to avoid dead-code warnings. @@ -362,11 +362,21 @@ int main(int argc, char** argv) { if (result != LUA_OK) { fprintf(stderr, "%s\n", lua_tostring(L, -1)); lua_close(L); +#ifdef __ANDROID__ + fflush(NULL); + _exit(1); +#else return 1; +#endif } lua_close(L); +#ifdef __ANDROID__ + fflush(NULL); + _exit(0); +#else return 0; +#endif } ]]