diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 0e33a44..51c8d73 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -6,7 +6,7 @@ on: - cron: "10 0 * * *" # Runs at 00:10 UTC every day jobs: - sync: + release: name: Sync runs-on: docker container: @@ -20,13 +20,53 @@ jobs: with: token: ${{ secrets.githubtoken }} - - name: Install fj + - name: Install Luau + uses: https://github.com/EncodedVenom/install-luau@v4.3 + with: + version: "latest" + verbose: "true" + + - name: Install forgejo-cli run: | wget https://codeberg.org/Cyborus/forgejo-cli/releases/download/v0.2.0/forgejo-cli-linux.gz -O fj.gz gunzip fj.gz mv fj /usr/local/bin/fj chmod +x /usr/local/bin/fj + - name: Synchronize + run: lune run src/init -- sync jecs + + - name: Run Unit Tests + shell: bash + run: | + output=$(lune run src/init -- test jecs) + echo "$output" + if [[ "$output" == *"0 fails"* ]]; then + echo "Unit Tests Passed" + else + echo "Error: One or More Unit Tests Failed" + exit 1 + fi + + - name: Read Jecs Version + id: read_jecs_version + run: | + version=$(lune run src/read_version | tr '\n' ' ') + echo "JECS_VERSION=$version" >> $GITHUB_OUTPUT + + - name: Build With Rojo + run: | + cd jecs + rojo build default.project.json -o build.rbxm + mv build.rbxm ../jecs_nightly.rbxm + cd .. + + - name: Upload Build Artifact + uses: https://git.devmarked.win/actions/upload-artifact@v4 + with: + name: build + path: jecs_nightly.rbxm + - name: Authorize Pesde run: pesde auth login --token "${{ secrets.pesde_auth_token }}" @@ -36,14 +76,8 @@ jobs: wally login --token "${{ secrets.wally_auth_token }}" rm wally.toml - - name: Sync & Release - run: lune run src/init - - - name: Read Jecs Version - id: read_jecs_version - run: | - version=$(lune run src/read_version | tr '\n' ' ') - echo "JECS_VERSION=$version" >> $GITHUB_OUTPUT + - name: Release + run: lune run src/init -- release jecs --pesde-scope marked/jecs_nightly --wally-scope mark-marks/jecs-nightly - name: Create Pull Request id: create_pull_request diff --git a/jecs/build.txt b/jecs/build.txt new file mode 100644 index 0000000..ff6901d --- /dev/null +++ b/jecs/build.txt @@ -0,0 +1,2 @@ +modified = ["README.md", "LICENSE", ".luaurc", "CHANGELOG.md", "jecs.luau"] +version = "0.5.5-nightly.20250302T175658Z" diff --git a/jecs/default.project.json b/jecs/default.project.json index d4531a0..5e66bad 100644 --- a/jecs/default.project.json +++ b/jecs/default.project.json @@ -1,6 +1 @@ -{ - "name": "jecs", - "tree": { - "$path": "jecs.luau" - } -} +{"name":"jecs-nightly","tree":{"$path":"jecs.luau"}} \ No newline at end of file diff --git a/jecs/pesde.toml b/jecs/pesde.toml index 909f63c..e33a380 100644 --- a/jecs/pesde.toml +++ b/jecs/pesde.toml @@ -3,7 +3,7 @@ includes = ["init.luau", "pesde.toml", "README.md", "CHANGELOG.md", "LICENSE", " license = "MIT" name = "marked/jecs_nightly" repository = "https://git.devmarked.win/jecs-nightly" -version = "0.5.5-nightly.20250302T042604Z" +version = "0.5.5-nightly.20250302T175658Z" [indices] default = "https://github.com/daimond113/pesde-index" diff --git a/jecs/test.txt b/jecs/test.txt new file mode 100644 index 0000000..3669390 --- /dev/null +++ b/jecs/test.txt @@ -0,0 +1,2 @@ +passed = true +timestamp = "20250302T175659Z" diff --git a/jecs/test_fulllog.txt b/jecs/test_fulllog.txt new file mode 100644 index 0000000..1872afc --- /dev/null +++ b/jecs/test_fulllog.txt @@ -0,0 +1,114 @@ +8.2 us  3 kBโ”‚ delete children of entity +9.4 us  2 kBโ”‚ remove friends of entity +328 ns  0  Bโ”‚ simple deletion of entity +removing +archetype +PASSโ”‚  + +world:cleanup() +PASSโ”‚  + +world:entity() +PASSโ”‚ unique IDs +PASSโ”‚ generations +PASSโ”‚ pairs +PASSโ”‚ Recycling +PASSโ”‚ Recycling max generation + +world:set() +PASSโ”‚ archetype move +PASSโ”‚ pairs + +world:remove() +PASSโ”‚ should allow remove a component that doesn't exist on entity + +world:add() +PASSโ”‚ idempotent +PASSโ”‚ archetype move + +world:query() +PASSโ”‚ cached +PASSโ”‚ multiple iter +PASSโ”‚ tag +PASSโ”‚ pairs +PASSโ”‚ query single component +PASSโ”‚ query missing component +PASSโ”‚ query more than 8 components +PASSโ”‚ should be able to get next results +PASSโ”‚ should query all matching entities when irrelevant component is removed +PASSโ”‚ should query all entities without B +PASSโ”‚ should allow querying for relations +PASSโ”‚ should allow wildcards in queries +PASSโ”‚ should match against multiple pairs +PASSโ”‚ should only relate alive entities +NONEโ”‚ should error when setting invalid pair +PASSโ”‚ should find target for ChildOf +PASSโ”‚ despawning while iterating +NONEโ”‚ iterator invalidation +SKIPโ”‚ adding +PASSโ”‚ spawning +PASSโ”‚ should not find any entities +PASSโ”‚ without + +world:each +PASSโ”‚  + +world:children +PASSโ”‚  + +world:clear() +PASSโ”‚ should remove its components +PASSโ”‚ should move last record + +world:has() +PASSโ”‚ should find Tag on entity +PASSโ”‚ should return false when missing one tag + +world:component() +PASSโ”‚ only components should have EcsComponent trait +PASSโ”‚ tag + +world:delete +PASSโ”‚ invoke OnRemove hooks +PASSโ”‚ delete recycled entity id used as component +PASSโ”‚ bug: Empty entity does not respect cleanup policy +PASSโ”‚ should allow deleting components +PASSโ”‚ delete entities using another Entity as component with Delete cleanup action +PASSโ”‚ delete children +PASSโ”‚ fast delete +PASSโ”‚ cycle + +world:target +PASSโ”‚ nth index +PASSโ”‚ infer index when unspecified +PASSโ”‚ loop until no target + +world:contains +PASSโ”‚  +PASSโ”‚ should not exist after delete + +Hooks +PASSโ”‚ OnAdd +PASSโ”‚ OnSet +PASSโ”‚ OnRemove + +change tracking +PASSโ”‚ #1 +PASSโ”‚ #2 + +repro +PASSโ”‚ #1 +PASSโ”‚ #2 + +wildcard query +PASSโ”‚ #1 +PASSโ”‚ #2 +PASSโ”‚ #3 + +world:delete() invokes OnRemove hook +PASSโ”‚ #1 +PASSโ”‚ #2 +PASSโ”‚ #3 + +68/68 test cases passed in 31.100 ms. +0 fails diff --git a/jecs/wally.toml b/jecs/wally.toml index 0f8c345..6151420 100644 --- a/jecs/wally.toml +++ b/jecs/wally.toml @@ -5,4 +5,4 @@ license = "MIT" name = "mark-marks/jecs-nightly" realm = "shared" registry = "https://github.com/UpliftGames/wally-index" -version = "0.5.5-nightly.20250302T042604Z" +version = "0.5.5-nightly.20250302T175658Z" diff --git a/jecs_nightly.rbxm b/jecs_nightly.rbxm new file mode 100644 index 0000000..6c0f21e Binary files /dev/null and b/jecs_nightly.rbxm differ diff --git a/luau-install/binary.zip b/luau-install/binary.zip new file mode 100755 index 0000000..2390e23 Binary files /dev/null and b/luau-install/binary.zip differ diff --git a/luau-install/luau b/luau-install/luau new file mode 100755 index 0000000..fa4fb72 Binary files /dev/null and b/luau-install/luau differ diff --git a/luau-install/luau-analyze b/luau-install/luau-analyze new file mode 100755 index 0000000..60e8fb3 Binary files /dev/null and b/luau-install/luau-analyze differ diff --git a/luau-install/luau-ast b/luau-install/luau-ast new file mode 100755 index 0000000..af6dd09 Binary files /dev/null and b/luau-install/luau-ast differ diff --git a/luau-install/luau-compile b/luau-install/luau-compile new file mode 100755 index 0000000..fd4ed9f Binary files /dev/null and b/luau-install/luau-compile differ diff --git a/rokit.toml b/rokit.toml index 1cc79c6..cf02dbc 100644 --- a/rokit.toml +++ b/rokit.toml @@ -9,3 +9,4 @@ luau-lsp = "johnnymorganz/luau-lsp@1.37.0" # https://discord.com/channels/385151 stylua = "johnnymorganz/stylua@2.0.2" wally = "upliftgames/wally@0.3.2" pesde = "pesde-pkg/pesde@0.6.0+registry.0.2.0" +rojo = "rojo-rbx/rojo@7.4.4" diff --git a/src/init.luau b/src/init.luau index 2cd1046..299242a 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,16 +1,80 @@ --!strict +local fs = require("@lune/fs") +local process = require("@lune/process") + +local frkcli = require("./util/frkcli") local release = require("./release") local sync = require("./sync") +local test = require("./test") -local res = sync("jecs") -if not res.ok then - print(`Can't continue: {res.err}`) - return +local cli = frkcli.new_subcommands("nightly_cli", "A CLI to sync & release jecs nightly") + +local command_sync = cli:add_subcommand("sync", "Synchronize files from the jecs repository") +command_sync:add_positional( + "to", + { help = "Directory to synchronize the files in, defaults to current working directory", default = process.cwd } +) + +local command_release = cli:add_subcommand("release", "Publish a release from the given directory") +command_release:add_positional( + "from", + { help = "Directory to release from, defaults to current working directory", default = process.cwd } +) +command_release:add_flag("dry", { help = "Dry run the publishes" }) +command_release:add_option( + "pesde-scope", + { help = "The pesde scope to publish under (eg. marked/jecs_nightly)", default = "nil" } +) +command_release:add_option( + "wally-scope", + { help = "The wally scope to publish under (eg. mark-marks/jecs-nightly)", default = "nil" } +) + +local command_test = cli:add_subcommand("test", "Run unit tests on the stored jecs source") +command_test:add_positional("in", { + help = "Directory with the jecs source to run tests in, defaults to current working directory", + default = process.cwd, +}) + +local parsed, err = cli:parse(process.args) +if err ~= nil then + error(err) end +assert(parsed ~= nil) -if res.val == false then - print(`No changes made. Aborting.`) - return +local values = parsed.result.values +local flags = parsed.result.flags +if parsed.command == "sync" then + sync(values.to) +elseif parsed.command == "release" then + local scopes = {} + if values["pesde-scope"] ~= "nil" then + scopes.pesde = values["pesde-scope"] + end + if values["wally-scope"] ~= "nil" then + scopes.wally = values["wally-scope"] + end + + local metadata = fs.metadata(values.from) + if not metadata.exists or metadata.kind ~= "dir" then + error(`The path {values.from} doesn't exist or isn't a valid directory.`) + end + + local fpath = process.cwd + if values.from ~= process.cwd then + fpath ..= values.from + end + + if flags.dry then + release(fpath, scopes, true) + else + release(fpath, scopes, false) + end +elseif parsed.command == "test" then + local fpath = process.cwd + if values["in"] ~= process.cwd then + fpath ..= values["in"] + end + + test(fpath) end - -release("jecs", { pesde = "marked/jecs_nightly", wally = "mark-marks/jecs-nightly" }, false) diff --git a/src/release.luau b/src/release.luau index 7499d5d..7fa6190 100644 --- a/src/release.luau +++ b/src/release.luau @@ -1,20 +1,14 @@ --!strict -local datetime = require("@lune/datetime") local fs = require("@lune/fs") -local net = require("@lune/net") local process = require("@lune/process") local serde = require("@lune/serde") local stdio = require("@lune/stdio") local progress_bar = require("./util/progress") local result = require("./util/result") +local shared = require("./shared") local types = require("./types") --- Returns an ISO 8601 date (YYYYmmddThhmmssZ) -local function iso_date_light(now: datetime.DateTime): string - return now:formatUniversalTime("%Y%m%dT%H%M%SZ") -end - local function make_pesde_manifest(version: string, scope: string): types.PesdeManifest return { name = scope, @@ -63,32 +57,17 @@ local function make_wally_manifest(version: string, scope: string): types.WallyM } end -local function round_to(n: number, places: number) - local x = 10 ^ (places or 0) - return math.round(n * x) / x -end - ---- Fetches the given file raw from the jecs github -local function fetch_raw(file: string): result.Identity - local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`) - if not res.ok then - return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`) - end - return result(true, res.body) -end - -local function release(origin: string, scopes: { wally: string, pesde: string }, dry: boolean?): result.Identity +local function release(origin: string, scopes: { wally: string?, pesde: string? }, dry: boolean?): result.Identity local begin = os.clock() - local now = datetime.now() local progress = progress_bar .new() :withStage("init", "Initializing") - :withStage("pull", "Pull latest version") + :withStage("version", "Pulling version from build metadata") :withStage("prepare", "Preparing manifests") :withStage("release (pesde)", "Releasing on pesde") :withStage("release (wally)", "Releasing on wally") - progress:start() + progress:start() -- init if dry == nil then dry = true @@ -100,62 +79,96 @@ local function release(origin: string, scopes: { wally: string, pesde: string }, return result(false, `{origin} is not a valid directory which exists.`) end - progress:nextStage() + progress:nextStage() -- version - local wally_contents = fetch_raw("wally.toml") - if not wally_contents.ok then - progress:stop() - stdio.ewrite(`๐Ÿ”ฅ Couldn't get the jecs wally manifest:\n{wally_contents.err}\n`) - return result(false, `Couldn't get the jecs wally manifest:\n{wally_contents.err}`) + local version + do + local encoded_metadata = fs.readFile(`{origin}/build.txt`) + local metadata: { version: string } = serde.decode("toml", encoded_metadata) + version = metadata.version end - local parsed: types.WallyManifest = serde.decode("toml", wally_contents.val) - local version = `{parsed.package.version}-nightly.{iso_date_light(now)}` + progress:nextStage() -- prepare - progress:nextStage() - - do + if scopes.pesde then local manifest = make_pesde_manifest(version, scopes.pesde) local encoded = serde.encode("toml", manifest) fs.writeFile(`{origin}/pesde.toml`, encoded) end - do + if scopes.wally then local manifest = make_wally_manifest(version, scopes.wally) local encoded = serde.encode("toml", manifest) fs.writeFile(`{origin}/wally.toml`, encoded) end - progress:nextStage() + progress:nextStage() -- release (pesde) - local cwd = process.cwd .. origin + local cwd = origin --process.cwd .. origin - if not dry then - process.spawn("pesde", { "publish", "-y" }, { cwd = cwd }) - else - process.spawn("pesde", { "publish", "-d", "-y" }, { cwd = cwd }) + local res_pesde + if scopes.pesde then + if not dry then + res_pesde = process.spawn("pesde", { "publish", "-y" }, { cwd = cwd }) + else + res_pesde = process.spawn("pesde", { "publish", "-d", "-y" }, { cwd = cwd }) + end + + if not res_pesde.ok then + progress:stop() + print(`-- Pesde error:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`) + return result(false, `Pesde error:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`) + end end - progress:nextStage() + progress:nextStage() -- release (wally) - if not dry then - process.spawn("wally", { "publish" }, { cwd = cwd }) - else - process.spawn("wally", { "package", "--output", "wally.tar.gz" }, { cwd = cwd }) + local res_wally + if scopes.wally then + if not dry then + res_wally = process.spawn("wally", { "publish" }, { cwd = cwd }) + else + res_wally = process.spawn("wally", { "package", "--output", "wally.tar.gz" }, { cwd = cwd }) + end + + if not res_wally.ok then + progress:stop() + print(`-- Wally error:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`) + return result(false, `Wally error:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`) + end end - progress:stop() + progress:stop() -- finish - local took = round_to((os.clock() - begin) * 1_000, 2) + if res_pesde then + print(`-- Pesde out:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`) + end + + if res_wally then + print(`-- Wally out:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`) + end + + local took = shared.round_to((os.clock() - begin) * 1_000, 2) if not dry then print(`๐Ÿš€ Published packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) else print(`๐Ÿ“ฆ Packaged packages {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) end - print({ - pesde = `{scopes.pesde}@{version}`, - wally = `{scopes.wally}@{version}`, - }) + + if scopes.pesde and scopes.wally then + print({ + pesde = `{scopes.pesde}@{version}`, + wally = `{scopes.wally}@{version}`, + }) + elseif scopes.pesde then + print({ + pesde = `{scopes.pesde}@{version}`, + }) + elseif scopes.wally then + print({ + wally = `{scopes.wally}@{version}`, + }) + end return result(true, nil) end diff --git a/src/shared.luau b/src/shared.luau new file mode 100644 index 0000000..5194e9a --- /dev/null +++ b/src/shared.luau @@ -0,0 +1,47 @@ +--!strict +local datetime = require("@lune/datetime") +local fs = require("@lune/fs") +local net = require("@lune/net") + +local result = require("./util/result") + +--- Fetches the given file raw from the jecs github +local function fetch_raw(file: string): result.Identity + local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`) + if not res.ok then + return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`) + end + return result(true, res.body) +end + +local function save_if_diff(filepath: string, contents: string): result.Identity + if not fs.metadata(filepath).exists then + fs.writeFile(filepath, contents) + return result(true, nil) + end + + local existing = fs.readFile(filepath) + if existing == contents then + return result(false, "Contents are the same.") + end + + fs.writeFile(filepath, contents) + return result(true, nil) +end + +local function round_to(n: number, places: number) + local x = 10 ^ (places or 0) + return math.round(n * x) / x +end + +-- Returns an ISO 8601 date (YYYYmmddThhmmssZ) +local function iso_date_light(date: datetime.DateTime): string + return date:formatUniversalTime("%Y%m%dT%H%M%SZ") +end + +return { + fetch_raw = fetch_raw, + save_if_diff = save_if_diff, + round_to = round_to, + iso_date_light = iso_date_light, +} diff --git a/src/sync.luau b/src/sync.luau index 1fd4252..2407cb4 100644 --- a/src/sync.luau +++ b/src/sync.luau @@ -1,42 +1,17 @@ --!strict +local datetime = require("@lune/datetime") local fs = require("@lune/fs") -local net = require("@lune/net") +local serde = require("@lune/serde") local stdio = require("@lune/stdio") local progress_bar = require("./util/progress") local result = require("./util/result") - ---- Fetches the given file raw from the jecs github -local function fetch_raw(file: string): result.Identity - local res = net.request(`https://raw.githubusercontent.com/Ukendio/jecs/refs/heads/main/{file}`) - if not res.ok then - return result(false, `Not ok: {res.statusMessage} @ {res.statusCode}`) - end - return result(true, res.body) -end - -local function save_if_diff(filepath: string, contents: string): result.Identity - if not fs.metadata(filepath).exists then - fs.writeFile(filepath, contents) - return result(true, nil) - end - - local existing = fs.readFile(filepath) - if existing == contents then - return result(false, "Contents are the same.") - end - - fs.writeFile(filepath, contents) - return result(true, nil) -end - -local function round_to(n: number, places: number) - local x = 10 ^ (places or 0) - return math.round(n * x) / x -end +local shared = require("./shared") +local types = require("./types") --- Synchronizes the required files from the jecs main branch. local function sync(to: string): result.Identity + local now = datetime.now() local begin = os.clock() local progress = progress_bar @@ -44,13 +19,15 @@ local function sync(to: string): result.Identity :withStage("init", "Initializing") :withStage("fetch", "Fetching latest files") :withStage("save", "Saving files") - progress:start() + :withStage("version", "Pulling latest jecs version") + :withStage("metadata", "Writing build metadata") + progress:start() -- init if not fs.metadata(to).exists then fs.writeDir(to) end - progress:nextStage() + progress:nextStage() -- fetch local includes = { "jecs.luau", @@ -58,13 +35,12 @@ local function sync(to: string): result.Identity "CHANGELOG.md", "LICENSE", ".luaurc", - "default.project.json", } local sources = {} for _, file in includes do - local contents = fetch_raw(file) + local contents = shared.fetch_raw(file) if not contents.ok then progress:stop() stdio.ewrite(`๐Ÿ”ฅ Couldn't get the latest source for {file}:\n{contents.err}\n`) @@ -74,12 +50,12 @@ local function sync(to: string): result.Identity sources[file] = contents.val end - progress:nextStage() + progress:nextStage() -- save local sources_modified = {} local any_changed = false for file, contents in sources do - local res = save_if_diff(`{to}/{file}`, contents) + local res = shared.save_if_diff(`{to}/{file}`, contents) if res.ok then any_changed = true table.insert(sources_modified, file) @@ -88,7 +64,7 @@ local function sync(to: string): result.Identity if not any_changed then progress:stop() - local took = round_to((os.clock() - begin) * 1_000, 2) + local took = shared.round_to((os.clock() - begin) * 1_000, 2) print( `๐Ÿ•› Finished synchronizing, no changes since latest source {stdio.style("dim")}(took {took}ms){stdio.style( "reset" @@ -97,9 +73,39 @@ local function sync(to: string): result.Identity return result(true, false) end - progress:stop() + local project_json = { + name = "jecs-nightly", + tree = { + ["$path"] = "jecs.luau", + }, + } + local encoded_project_json = serde.encode("json", project_json) + fs.writeFile(`{to}/default.project.json`, encoded_project_json) - local took = round_to((os.clock() - begin) * 1_000, 2) + progress:nextStage() -- version + + local wally_contents = shared.fetch_raw("wally.toml") + if not wally_contents.ok then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ Couldn't get the jecs wally manifest:\n{wally_contents.err}\n`) + return result(false, `Couldn't get the jecs wally manifest:\n{wally_contents.err}`) + end + + local parsed: types.WallyManifest = serde.decode("toml", wally_contents.val) + + progress:nextStage() -- metadata + + local version = `{parsed.package.version}-nightly.{shared.iso_date_light(now)}` + local metadata = { + version = version, + modified = sources_modified, + } + local encoded_metadata = serde.encode("toml", metadata) + fs.writeFile(`{to}/build.txt`, encoded_metadata) + + progress:stop() -- finish + + local took = shared.round_to((os.clock() - begin) * 1_000, 2) print(`๐Ÿชจ Finished synchronizing {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) print(`Changed files:`) print(sources_modified) diff --git a/src/test.luau b/src/test.luau new file mode 100644 index 0000000..c12686d --- /dev/null +++ b/src/test.luau @@ -0,0 +1,105 @@ +--!strict +local datetime = require("@lune/datetime") +local fs = require("@lune/fs") +local process = require("@lune/process") +local serde = require("@lune/serde") +local stdio = require("@lune/stdio") + +local progress_bar = require("./util/progress") +local result = require("./util/result") +local shared = require("./shared") + +local function test(origin: string): result.Identity + local now = datetime.now() + local begin = os.clock() + + local progress = progress_bar + .new() + :withStage("init", "Initializing") + :withStage("fetch", "Fetching latest tests") + :withStage("test", "Running tests") + :withStage("cleanup", "Cleaning up") + :withStage("metadata", "Writing test metadata") + progress:start() -- init + + if not fs.metadata(origin).exists then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ {origin} is not a valid directory which exists.\n`) + return result(false, `{origin} is not a valid directory which exists.`) + end + + if not fs.metadata(`{origin}/jecs.luau`).exists then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ {origin}/jecs.luau is not a vald file which exists.\n`) + return result(false, `{origin}/jecs.luau is not a vald file which exists.`) + end + + if fs.metadata(`{origin}/test`).exists then + fs.removeDir(`{origin}/test`) + end + fs.writeDir(`{origin}/test`) + + progress:nextStage() -- fetch + + do + local contents = shared.fetch_raw("test/testkit.luau") + if not contents.ok then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ Couldn't get the latest source for test/testkit.luau:\n{contents.err}\n`) + return result(false, "Couldn't get the latest source for test/testkit.luau.") + end + fs.writeFile(`{origin}/test/testkit.luau`, contents.val) + end + do + local contents = shared.fetch_raw("test/tests.luau") + if not contents.ok then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ Couldn't get the latest source for test/tests.luau:\n{contents.err}\n`) + return result(false, "Couldn't get the latest source for test/tests.luau.") + end + fs.writeFile(`{origin}/test/tests.luau`, contents.val) + end + + progress:nextStage() -- test + + local test_result = process.spawn("luau", { "test/tests.luau" }, { cwd = origin }) + if not test_result.ok then + progress:stop() + stdio.ewrite(`๐Ÿ”ฅ Tests failed to run:\n{test_result.stderr}\n`) + return result(false, `Tests failed to run.`) + end + + local passed = true + if not string.find(test_result.stdout, "0 fails") then + passed = false + end + + progress:nextStage() -- cleanup + + fs.removeDir(`{origin}/test`) + + progress:nextStage() -- metadata + + local metadata = { + timestamp = shared.iso_date_light(now), + passed = passed, + } + local encoded_metadata = serde.encode("toml", metadata) + fs.writeFile(`{origin}/test.txt`, encoded_metadata) + fs.writeFile(`{origin}/test_fulllog.txt`, test_result.stdout) + + progress:stop() -- finish + + print(test_result.stdout) + + local passed_txt = if passed + then `{stdio.style("bold")}{stdio.color("green")}passed{stdio.color("reset")}{stdio.style("reset")}` + else `{stdio.style("bold")}{stdio.color("red")}failed{stdio.color("reset")}{stdio.style("reset")}` + + local took = shared.round_to((os.clock() - begin) * 1_000, 2) + print(`๐Ÿงช Finished testing, {passed_txt} {stdio.style("dim")}(took {took}ms){stdio.style("reset")}`) + + return result(true, true) +end + +return test diff --git a/src/util/frkcli.luau b/src/util/frkcli.luau new file mode 100644 index 0000000..d306791 --- /dev/null +++ b/src/util/frkcli.luau @@ -0,0 +1,586 @@ +--!nolint LocalShadow +-- https://raw.githubusercontent.com/itsfrank/frkcli/refs/heads/main/src/frkcli.luau + +local process = require("@lune/process") + +local M = {} + +type ArgKind = "POSITIONAL" | "FLAG" | "OPTION" +type ArgOptions = { + help: string?, + aliases: { string }?, + default: string?, -- if this is not nil, the arg will be optional +} + +-- this is what is stored, we guarante non nullity when args are added so this types makes Luau feel better +type ArgOptionsSafe = { + help: string, + aliases: { string }, + default: string?, +} + +type ArgData = { + name: string, + kind: ArgKind, + options: ArgOptionsSafe, +} + +type ParseResult = { + values: { [string]: string }, + flags: { [string]: boolean }, + fwd_args: { string }, -- all args after `--` +} + +type SubcommandParseResult = { + command: string, + result: ParseResult, +} + +local DEFAULT_OPTIONS: ArgOptionsSafe = { + help = "", + default = nil, + aliases = {}, +} + +local DEFAULT_helpset = { + ["-h"] = true, + ["--help"] = true, +} + +local function validate_key_or_error(key: string) + if (key:sub(1, 2) == "--" or key:sub(1, 1) == "-") and not key:find(" ") then + return + end + error(`arg key {key} is invalid. Keys must start with either '-' or '--' and may not contain spaces`) +end + +local function validate_subcommand_name_or_error(name: string) + if name:sub(1, 2) == "--" or name:sub(1, 1) == "-" or name:find(" ") then + error(`subcommand name '{name}' is invalid. Name must not start with '-' or '--' and may not contain spaces`) + end +end + +type HelpSection = { title: string, lines: { { string } | string }? } +local function make_help(sections: { HelpSection }, indent: number?): string + local function align_cols(rows: { { string } }, sep: string?): { { string } } + local sep = if sep == nil then " " else sep + local max_col_lengths: { number } = {} + for _, row in rows do + for i, s in row do + if max_col_lengths[i] == nil or s:len() > max_col_lengths[i] then + max_col_lengths[i] = s:len() + end + end + end + local aligned_rows: { { string } } = {} + for _, row in rows do + local line: { string } = {} + for i, col in row do + table.insert(line, col) + if i < #row then + local spacing = sep + local diff = max_col_lengths[i] - col:len() + if diff > 0 then + spacing = string.rep(" ", diff) .. sep + end + table.insert(line, spacing) + end + end + table.insert(aligned_rows, line) + end + return aligned_rows + end + + local function append_list(dest: { T }, src: { T }) + for _, v in src do + table.insert(dest, v) + end + end + + local ind = " " + if indent ~= nil then + ind = string.rep(" ", indent) + end + + local help_lines: { { string } } = {} + + for _, s in sections do + table.insert(help_lines, { s.title }) + if s.lines ~= nil then + local section_lines: { { string } } = {} + for _, l in s.lines do + if typeof(l) == "string" then + table.insert(section_lines, { ind, l }) + else + table.insert(section_lines, { ind, table.unpack(l) }) + end + end + append_list(help_lines, align_cols(section_lines)) + end + table.insert(help_lines, {}) + end + + -- remove last empty line + table.remove(help_lines) + + local help_text = "" + for _, line in help_lines do + help_text ..= table.concat(line, " ") .. "\n" + end + return help_text +end +-- subcommand cli, must be first positional arg +function M.new_subcommands(name, description: string?, helpkeys: { string }?) + -- args that trigger print help + abort + local helpset: { [string]: boolean } = DEFAULT_helpset + if helpkeys ~= nil then + helpset = {} + for _, k in helpkeys do + helpset[k] = true + end + end + + local cli = {} + cli.name = name + cli.description = description + cli._helpkeys = helpkeys + cli._helpset = helpset + cli._subcommands = {} + + function cli:add_subcommand(name: string, description: string?) + validate_subcommand_name_or_error(name) + local sc = M.new(name, description, cli._helpkeys) + cli._subcommands[name] = sc + return sc + end + + function cli:parse(args: { string }): (SubcommandParseResult?, string?) + if #args == 0 then + return nil, "insufficient arguments, required at least 1, got 0" + end + local command = args[1] + assert(command ~= nil) + + -- asking for help? + if cli._helpset[command] ~= nil then + print(cli:help()) + process.exit(0) + end + + local subcommand = cli._subcommands[command] + if subcommand == nil then + return nil, `'{command} is not a valid subcommand` + end + + local sub_args = { table.unpack(args, 2) } + local res, err = subcommand:parse(sub_args) + if err ~= nil then + return nil, err + end + assert(res ~= nil) + + return { + command = command, + result = res, + }, nil + end + + function cli:help(indent: number?): string + local help_sections: { HelpSection } = {} + + table.insert(help_sections, { title = `usage: {cli.name} ` }) + + if cli.description ~= nil then + table.insert(help_sections, { + title = "description:", + lines = { { cli.description } }, + }) + end + + local subcommand_lines = {} + for _, c in cli._subcommands do + local desc = if c.description then c.description else "" + table.insert(subcommand_lines, { c.name, desc }) + end + + table.insert(help_sections, { + title = "commands:", + lines = subcommand_lines, + }) + + return make_help(help_sections, indent) + end + + return cli +end + +function M.new(name: string, description: string?, helpkeys: { string }?) + -- args that trigger print help + abort + local helpset: { [string]: boolean } = DEFAULT_helpset + if helpkeys ~= nil then + helpset = {} + for _, k in helpkeys do + helpset[k] = true + end + end + + -- I'm fairly certain this is not the right pattern for making objects, but it results in single definition + great LSP so I'm happy + local cli = {} + cli.name = name + cli.description = description + cli._helpset = helpset + cli._positionals = {} :: { ArgData } + cli._flags = {} :: { ArgData } + cli._options = {} :: { ArgData } + cli._argdata_set = {} :: { [string]: ArgData } + cli._required_list = {} :: { string } + cli._lookup = {} :: { [string]: ArgData } + cli._required_positional_count = 0 + cli._default_result = { values = {}, flags = {} } :: ParseResult + + local function add_arg_lookups(keys: { string }, arg_data: ArgData) + for _, k in keys do + if cli._helpset[k] ~= nil then + error(`key '{k}' is already used as a help key.`) + end + validate_key_or_error(k) + if cli._lookup[k] ~= nil then + error(`key '{k}' already exists.`) + end + cli._lookup[k] = arg_data + end + end + + local function add_name_error_on_duplicate(name: string, data: ArgData) + if cli._argdata_set[name] ~= nil then + error(`arg with name {name} already exists`) + end + cli._argdata_set[name] = data + end + + local function make_safe_options(options: ArgOptions?): ArgOptionsSafe + if options == nil then + return DEFAULT_OPTIONS + end + assert(options) + + options.aliases = if options.aliases == nil then {} else options.aliases + + return options :: ArgOptionsSafe + end + + -- return is guaranteed to start with at least one '-', if none, adds '--' + local function to_lookup_name(name: string): string + if name:sub(1, 1) == "-" then + return name + end + return "--" .. name + end + + -- return is guaranted to not start with '-' + local function to_result_name(name: string): string + while name:sub(1, 1) == "-" do + name = name:sub(2, -1) + end + return name + end + + function cli:add_positional(name: string, options: ArgOptions?) + if name:sub(1, 1) == "-" then + error(`invalid arg name '{name}', positional cannot start with dashes`) + end + + local options = make_safe_options(options) + + local arg_data: ArgData = { + name = name, + kind = "POSITIONAL", + options = options, + } + add_name_error_on_duplicate(name, arg_data) + if options.default == nil then + -- check positional required ordering, can't have a required pos after an optional one + local last = cli._positionals[#cli._positionals] + if last ~= nil and last.options.default ~= nil then + error( + `{name} is required, but {last.name} is optional. Cannot have required positional after optional positional` + ) + end + + table.insert(cli._required_list, name) + else + cli._default_result.values[name] = options.default + end + table.insert(cli._positionals, arg_data) + end + + function cli:add_flag(name: string, options: ArgOptions?) + if options and options.default ~= nil then + -- these are called constants :) + error(`flag {name} has non nil default, default value is not supported for flags`) + end + + local options = make_safe_options(options) + + local lookup_name = to_lookup_name(name) + name = to_result_name(name) + for i, v in options.aliases do + options.aliases[i] = to_lookup_name(v) + end + + local arg_data: ArgData = { + name = name, + kind = "FLAG", + options = options, + } + add_name_error_on_duplicate(name, arg_data) + add_arg_lookups({ lookup_name, table.unpack(options.aliases) }, arg_data) + + if options.default ~= nil then + error(`flag {name} has non nil default value, defaults are not supported for flag args`) + end + + cli._default_result.flags[name] = false + table.insert(cli._flags, arg_data) + end + + function cli:add_option(name: string, options: ArgOptions?) + local options = make_safe_options(options) + + local lookup_name = to_lookup_name(name) + name = to_result_name(name) + for i, v in options.aliases do + options.aliases[i] = to_lookup_name(v) + end + + local arg_data: ArgData = { + name = name, + kind = "OPTION", + options = options, + } + add_name_error_on_duplicate(name, arg_data) + add_arg_lookups({ lookup_name, table.unpack(options.aliases) }, arg_data) + if options.default == nil then + table.insert(cli._required_list, name) + else + if options.default == nil then + error(`optional arg {name} must have a default value`) + end + assert(options.default ~= nil) + cli._default_result.values[name] = options.default + end + table.insert(cli._options, arg_data) + end + + -- return: data, err - where data is a table, and err is a string + -- errors early, first encountered error ends the parse + -- if a help key is found will print help text and exit the process + function cli:parse(args: { string }): (ParseResult?, string?) + local parsed: ParseResult = { values = {}, flags = {}, fwd_args = {} } :: ParseResult + + local positional_idx = 1 + local skip_next = false -- used for options + for i, arg in args do + -- asking for help? + if cli._helpset[arg] ~= nil then + print(cli:help()) + process.exit(0) + end + + if skip_next then + skip_next = false + continue + end + + -- rest of args should be fwd args + if arg == "--" then + parsed.fwd_args = { table.unpack(args, i + 1) } + break + end + + local has_dash = arg:sub(1, 1) == "-" + local arg_data: ArgData? = nil + local value: string? = nil + + if not has_dash then -- positional + if positional_idx > #cli._positionals then + return nil, `too many positional arguments, expected {#cli._positionals}.` + end + arg_data = cli._positionals[positional_idx] + positional_idx += 1 + value = arg + else -- flag or option + arg_data = cli._lookup[arg] + end + + if arg_data == nil then + return nil, `unknown flag or option: '{arg}'` + end + assert(arg_data ~= nil) + + -- handle flags early + if arg_data.kind == "FLAG" then + parsed.flags[arg_data.name] = true + continue + end + + -- get option value + if arg_data.kind == "OPTION" then + if i < #args then + value = args[i + 1] + skip_next = true + end + end + + -- resolve positionals and options + if value == nil then + return nil, `no value provided for option '{arg}'` + end + assert(value ~= nil) + + parsed.values[arg_data.name] = value + end + + -- check all required options are provided + for _, name in cli._required_list do + if parsed.values[name] == nil and parsed.flags[name] == nil then + local kind = cli._argdata_set[name].kind:lower() + return nil, `required {kind} arg '{name}' was not found` + end + end + + -- apply defaults for missing optional + for k, v in cli._default_result.flags do + if parsed.flags[k] == nil then + parsed.flags[k] = v + end + end + for k, v in cli._default_result.values do + if parsed.values[k] == nil then + parsed.values[k] = v + end + end + + return parsed, nil + end + + function cli:help(indent: number?): string + local function align_cols(rows: { { string } }, sep: string?): { { string } } + local sep = if sep == nil then " " else sep + local max_col_lengths: { number } = {} + for _, row in rows do + for i, s in row do + if max_col_lengths[i] == nil or s:len() > max_col_lengths[i] then + max_col_lengths[i] = s:len() + end + end + end + local aligned_rows: { { string } } = {} + for _, row in rows do + local line: { string } = {} + for i, col in row do + table.insert(line, col) + if i < #row then + local spacing = sep + local diff = max_col_lengths[i] - col:len() + if diff > 0 then + spacing = string.rep(" ", diff) .. sep + end + table.insert(line, spacing) + end + end + table.insert(aligned_rows, line) + end + return aligned_rows + end + + local help_sections: { HelpSection } = {} + + -- usage + local usage = `usage: {cli.name}` + if #cli._flags > 0 or #cli._options > 0 then + usage ..= " [options]" + end + for _, arg in cli._positionals do + if arg.options.default == nil then + usage ..= ` <{arg.name}>` + else + usage ..= ` [{arg.name}]` + end + end + + table.insert(help_sections, { title = usage }) + + -- description + if cli.description ~= nil then + table.insert(help_sections, { title = "description:", lines = { cli.description } }) + end + + local function make_arg_line(arg: ArgData): { string } + local keys = arg.name + if arg.kind ~= "POSITIONAL" then + keys = `--{arg.name}` + for _, a in arg.options.aliases do + keys ..= `, {a}` + end + end + + local reqdef = "" + -- optional & default + if arg.kind ~= "FLAG" then + if arg.options.default == nil then + reqdef ..= "[required]" + else + assert(arg.options.default) + reqdef ..= `[default: '{arg.options.default}']` + end + end + + local help = "" + if arg.options.help then + if help ~= "" then + help ..= " " + end + help ..= arg.options.help + end + + return { keys, reqdef, help } + end + + -- positionals + if #cli._positionals > 0 then + local positional_lines: { { string } } = {} + for _, arg in cli._positionals do + table.insert(positional_lines, make_arg_line(arg)) + end + table.insert(help_sections, { title = "positional arguments:", lines = positional_lines }) + end + + -- flags + if #cli._flags > 0 then + local flag_lines: { { string } } = {} + for _, arg in cli._flags do + table.insert(flag_lines, make_arg_line(arg)) + end + table.insert(help_sections, { title = "flags:", lines = flag_lines }) + end + + -- options + if #cli._options > 0 then + local option_lines: { { string } } = {} + for _, arg in cli._options do + table.insert(option_lines, make_arg_line(arg)) + end + table.insert(help_sections, { title = "options:", lines = option_lines }) + end + + return make_help(help_sections, indent) + end + + return cli +end + +export type Cli = typeof(M.new("")) +export type CliSubcommands = typeof(M.new_subcommands("")) + +return M