Unit test before release, move src/init to cli form
This commit is contained in:
parent
7d7aa8de33
commit
b52882ef2d
15 changed files with 956 additions and 3008 deletions
|
@ -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,7 +20,13 @@ jobs:
|
|||
with:
|
||||
token: ${{ secrets.githubtoken }}
|
||||
|
||||
- name: Install fj
|
||||
- name: Install Luau
|
||||
uses: https://github.com/EncodedVenom/install-luau@v4
|
||||
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
|
||||
|
@ -36,8 +42,19 @@ jobs:
|
|||
wally login --token "${{ secrets.wally_auth_token }}"
|
||||
rm wally.toml
|
||||
|
||||
- name: Sync & Release
|
||||
run: lune run src/init
|
||||
- name: Synchronize
|
||||
run: lune run src/init -- sync jecs
|
||||
|
||||
- name: Run Unit Tests
|
||||
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
|
||||
|
@ -45,6 +62,22 @@ jobs:
|
|||
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-${{ steps.read_jecs_version.outputs.JECS_VERSION }}
|
||||
path: jecs_nightly.rbxm
|
||||
|
||||
- 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
|
||||
uses: https://git.devmarked.win/actions/create-pull-request@7174d368c2e4450dea17b297819eb28ae93ee645
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"aliases": {
|
||||
"jecs": "jecs",
|
||||
"testkit": "test/testkit",
|
||||
"mirror": "mirror"
|
||||
},
|
||||
"languageMode": "strict"
|
||||
}
|
|
@ -1,205 +0,0 @@
|
|||
# Jecs Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog][kac], and this project adheres to
|
||||
[Semantic Versioning][semver].
|
||||
|
||||
[kac]: https://keepachangelog.com/en/1.1.0/
|
||||
[semver]: https://semver.org/spec/v2.0.0.html
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- `[world]`:
|
||||
- 16% faster `world:get`
|
||||
- `world:has` no longer typechecks components after the 8th one.
|
||||
- `[typescript]`
|
||||
|
||||
- Fixed Entity type to default to `undefined | unknown` instead of just `undefined`
|
||||
|
||||
- `[query]`:
|
||||
- Fixed bug where `world:clear` did not invoke `jecs.OnRemove` hooks
|
||||
- Changed `query.__iter` to drain on iteration
|
||||
- It will initialize once wherever you left iteration off at last time
|
||||
- Changed `query:iter` to restart the iterator
|
||||
- Removed `query:drain` and `query:next`
|
||||
- If you want to get individual results outside of a for-loop, you need to call `query:iter` to initialize the iterator and then call the iterator function manually
|
||||
```lua
|
||||
local it = world:query(A, B, C):iter()
|
||||
local entity, a, b, c = it()
|
||||
entity, a, b, c = it() -- get next results
|
||||
```
|
||||
- `[world`
|
||||
- Fixed a bug with `world:clear` not invoking `jecs.OnRemove` hooks
|
||||
- `[typescript]`:
|
||||
- Changed pair to accept generics
|
||||
- Improved handling of Tags
|
||||
|
||||
## [0.3.2] - 2024-10-01
|
||||
|
||||
- `[world]`:
|
||||
- Changed `world:cleanup` to traverse a header type for graph edges. (Edit)
|
||||
- Fixed a regression that occurred when you call `world:set` following a `world:remove` using the same component
|
||||
- Remove explicit error in JECS_DEBUG for `world:target` when not applying an index parameter
|
||||
- `[typescript]` :
|
||||
- Fixed `world.set` with NoInfer<T>
|
||||
|
||||
## [0.3.1] - 2024-10-01
|
||||
|
||||
- `[world]`:
|
||||
- Added an index parameter to `world:target`
|
||||
- Added a way to change the components limit via `_G.JECS_HI_COMPONENT_ID`
|
||||
- Set it to whatever number you want but try to make it as close to the number of components you will use as possible
|
||||
- Make sure to set this before importing jecs or else it will not work
|
||||
- Added debug mode, enable via setting `_G.JECS_DEBUG` to true
|
||||
- Make sure to set this before importing jecs or else it will not work
|
||||
- Added `world:cleanup` which is called to cleanup empty archetypes manually
|
||||
- Changed `world:delete` to delete archetypes that are dependent on the passed entity
|
||||
- Changed `world:delete` to delete entity's children before the entity to prevent cycles
|
||||
- `[query]`:
|
||||
- Fixed the iterator to not drain by default
|
||||
- `[typescript]`
|
||||
- Fixed entry point of the package.json file to be `src` rather than `src/init`
|
||||
- Fixed `query.next` returning a query object whereas it would be expected to return a tuple containing the entity and the corresponding component values
|
||||
- Exported `query.archetypes`
|
||||
- Changed `pair` to return a number instead of an entity
|
||||
- Preventing direct usage of a pair as an entity while still allowing it to be used as a component
|
||||
- Exported built-in components `ChildOf` and `Name`
|
||||
- Exported `world.parent`
|
||||
|
||||
## [0.2.10] - 2024-09-07
|
||||
|
||||
- `[world]`:
|
||||
- Improved performance for hooks
|
||||
- Changed `world:set` to be idempotent when setting tags
|
||||
- `[traits]`:
|
||||
- Added cleanup condition `jecs.OnDelete` for when the entity or component is deleted
|
||||
- Added cleanup action `jecs.Remove` which removes instances of the specified (component) id from all entities
|
||||
- This is the default cleanup action
|
||||
- Added component trait `jecs.Tag` which allows for zero-cost components used as tags
|
||||
- Setting data to a component with this trait will do nothing
|
||||
- `[luau]`:
|
||||
- Exported `world:contains()`
|
||||
- Exported `query:drain()`
|
||||
- Exported `Query`
|
||||
- Improved types for the hook `OnAdd`, `OnSet`, `OnRemove`
|
||||
- Changed functions to accept any ID including pairs in type parameters
|
||||
- Applies to `world:add()`, `world:set()`, `world:remove()`, `world:get()`, `world:has()` and `world:query()`
|
||||
- New exported type `Id<T = nil> = Entity<T> | Pair`
|
||||
- Changed `world:contains()` to return a `boolean` instead of an entity which may or may not exist
|
||||
- Fixed `world:has()` to take the correct parameters
|
||||
|
||||
## [0.2.2] - 2024-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added `query:replace(function(...T) return ...U end)` for replacing components in place
|
||||
- Method is fast pathed to replace the data to the components for each corresponding entity
|
||||
|
||||
### Changed
|
||||
|
||||
- Iterator now goes backwards instead to prevent common cases of iterator invalidation
|
||||
|
||||
## [0.2.1] - 2024-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added `jecs.Component` built-in component which will be added to ids created with `world:component()`.
|
||||
- Used to find every component id with `query(jecs.Component)
|
||||
|
||||
## [0.2.0] - 2024-07-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added `world:parent(entity)` and `jecs.ChildOf` respectively as first class citizen for building parent-child relationships.
|
||||
- Give a parent to an entity with `world:add($source, pair(ChildOf, $target))`
|
||||
- Use `world:parent(entity)` to find the target of the relationship
|
||||
- Added user-facing Luau types
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved iteration speeds 20-40% by manually indexing rather than using `next()` :scream:
|
||||
|
||||
## [0.1.1] - 2024-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added `world:clear(entity)` for removing the components to the corresponding entity
|
||||
- Added Typescript Types
|
||||
|
||||
## [0.1.0] - 2024-05-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized iterator
|
||||
|
||||
## [0.1.0-rc.6] - 2024-05-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added a `jecs.Wildcard` term
|
||||
- it lets you query any partially matched pairs
|
||||
|
||||
## [0.1.0-rc.5] - 2024-05-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added Entity relationships for creating logical connections between entities
|
||||
- Added `world:__iter method` which allows for iteration over the whole world to get every entity
|
||||
- used for reconciling whole worlds such as via replication, saving/loading, etc
|
||||
- Added `world:add(entity, component)` which adds a component to the entity
|
||||
- it is an idempotent function, so calling it twice and in any order should be fine
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed component overriding when in disorder
|
||||
- Previously setting the components in different order results in it overriding component data because it incorrectly mapped the index of the column. So it took the index from the source archetype rather than the destination archetype
|
||||
|
||||
## [0.0.0-prototype.rc.3] - 2024-05-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added observers
|
||||
- Added an arm to query `query:without()` for chaining invariants.
|
||||
|
||||
### Changed
|
||||
|
||||
- Separates ranges for components and entity IDs.
|
||||
|
||||
- IDs created with `world:component()` will promote array lookups rather than map lookups in the `component_index` which is a significant boost
|
||||
|
||||
- No longer caches the column pointers directly and instead the column indices which stay persistent even when data is reallocated during swap-removals
|
||||
- This was an issue with the iterator being invalidated when you move an entity to a different archetype.
|
||||
|
||||
### Fixedhttps://github.com/Ukendio/jecs/releases/tag/v0.0.0-prototype.rc.3
|
||||
|
||||
- Fixed a bug where changing an existing component would be slow because it was always appending changing the row of the entity record
|
||||
- The fix dramatically improves times where it is basically down to just the speed of setting a field in a table
|
||||
|
||||
## [0.0.0-prototype.rc.2] - 2024-04-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the creation of the query
|
||||
- It will now finds the smallest archetype map to iterate over
|
||||
- Optimized the query iterator
|
||||
|
||||
- It will now populates iterator with columns for faster indexing
|
||||
|
||||
- Renamed the insertion method from world:add to world:set to better reflect what it does.
|
||||
|
||||
## [0.0.0-prototype.rc.2] - 2024-04-23
|
||||
|
||||
- Initial release
|
||||
|
||||
[unreleased]: https://github.com/ukendio/jecs/compare/v0.0.0.0-prototype.rc.2...HEAD
|
||||
[0.2.2]: https://github.com/ukendio/jecs/releases/tag/v0.2.2
|
||||
[0.2.1]: https://github.com/ukendio/jecs/releases/tag/v0.2.1
|
||||
[0.2.0]: https://github.com/ukendio/jecs/releases/tag/v0.2.0
|
||||
[0.1.1]: https://github.com/ukendio/jecs/releases/tag/v0.1.1
|
||||
[0.1.0]: https://github.com/ukendio/jecs/releases/tag/v0.1.0
|
||||
[0.1.0-rc.6]: https://github.com/ukendio/jecs/releases/tag/v0.1.0-rc.6
|
||||
[0.1.0-rc.5]: https://github.com/ukendio/jecs/releases/tag/v0.1.0-rc.5
|
||||
[0.0.0-prototype-rc.3]: https://github.com/ukendio/jecs/releases/tag/v0.0.0-prototype.rc.3
|
||||
[0.0.0-prototype.rc.2]: https://github.com/ukendio/jecs/releases/tag/v0.0.0-prototype.rc.2
|
||||
[0.0.0-prototype-rc.1]: https://github.com/ukendio/jecs/releases/tag/v0.0.0-prototype.rc.1
|
21
jecs/LICENSE
21
jecs/LICENSE
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 jecs authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
|
@ -1,64 +0,0 @@
|
|||
<p align="center">
|
||||
<img src="assets/image-5.png" width=35%/>
|
||||
</p>
|
||||
|
||||
[](LICENSE) [](https://wally.run/package/ukendio/jecs)
|
||||
|
||||
Just a stupidly fast Entity Component System
|
||||
|
||||
- [Entity Relationships](https://ajmmertens.medium.com/building-games-in-ecs-with-entity-relationships-657275ba2c6c) as first class citizens
|
||||
- Iterate 800,000 entities at 60 frames per second
|
||||
- Type-safe [Luau](https://luau-lang.org/) API
|
||||
- Zero-dependency package
|
||||
- Optimized for column-major operations
|
||||
- Cache friendly [archetype/SoA](https://ajmmertens.medium.com/building-an-ecs-2-archetypes-and-vectorization-fe21690805f9) storage
|
||||
- Rigorously [unit tested](https://github.com/Ukendio/jecs/actions/workflows/ci.yaml) for stability
|
||||
|
||||
### Example
|
||||
|
||||
```lua
|
||||
local world = jecs.World.new()
|
||||
local pair = jecs.pair
|
||||
|
||||
-- These components and functions are actually already builtin
|
||||
-- but have been illustrated for demonstration purposes
|
||||
local ChildOf = world:component()
|
||||
local Name = world:component()
|
||||
|
||||
local function parent(entity)
|
||||
return world:target(entity, ChildOf)
|
||||
end
|
||||
local function getName(entity)
|
||||
return world:get(entity, Name)
|
||||
end
|
||||
|
||||
local alice = world:entity()
|
||||
world:set(alice, Name, "alice")
|
||||
|
||||
local bob = world:entity()
|
||||
world:add(bob, pair(ChildOf, alice))
|
||||
world:set(bob, Name, "bob")
|
||||
|
||||
local sara = world:entity()
|
||||
world:add(sara, pair(ChildOf, alice))
|
||||
world:set(sara, Name, "sara")
|
||||
|
||||
print(getName(parent(sara)))
|
||||
|
||||
for e in world:query(pair(ChildOf, alice)) do
|
||||
print(getName(e), "is the child of alice")
|
||||
end
|
||||
|
||||
-- Output
|
||||
-- "alice"
|
||||
-- bob is the child of alice
|
||||
-- sara is the child of alice
|
||||
```
|
||||
|
||||
21,000 entities 125 archetypes 4 random components queried.
|
||||

|
||||
Can be found under /benches/visual/query.luau
|
||||
|
||||
Inserting 8 components to an entity and updating them over 50 times.
|
||||

|
||||
Can be found under /benches/visual/insertions.luau
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "jecs",
|
||||
"tree": {
|
||||
"$path": "jecs.luau"
|
||||
}
|
||||
}
|
2564
jecs/jecs.luau
2564
jecs/jecs.luau
File diff suppressed because it is too large
Load diff
|
@ -1,13 +0,0 @@
|
|||
authors = ["jecs authors"]
|
||||
includes = ["init.luau", "pesde.toml", "README.md", "CHANGELOG.md", "LICENSE", ".luaurc"]
|
||||
license = "MIT"
|
||||
name = "marked/jecs_nightly"
|
||||
repository = "https://git.devmarked.win/jecs-nightly"
|
||||
version = "0.5.5-nightly.20250302T042604Z"
|
||||
|
||||
[indices]
|
||||
default = "https://github.com/daimond113/pesde-index"
|
||||
|
||||
[target]
|
||||
environment = "luau"
|
||||
lib = "jecs.luau"
|
|
@ -1,8 +0,0 @@
|
|||
[package]
|
||||
exclude = ["**"]
|
||||
include = ["default.project.json", "jecs.luau", "wally.toml", "README.md", "CHANGELOG.md", "LICENSE"]
|
||||
license = "MIT"
|
||||
name = "mark-marks/jecs-nightly"
|
||||
realm = "shared"
|
||||
registry = "https://github.com/UpliftGames/wally-index"
|
||||
version = "0.5.5-nightly.20250302T042604Z"
|
|
@ -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(`Syncing failed, aborting.`)
|
||||
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.to)
|
||||
if not metadata.exists or metadata.kind ~= "dir" then
|
||||
error(`The path {values.to} doesn't exist or isn't a valid directory.`)
|
||||
end
|
||||
|
||||
local fpath = process.cwd
|
||||
if values.to ~= process.cwd then
|
||||
fpath ..= values.to
|
||||
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.to ~= process.cwd then
|
||||
fpath ..= values["in"]
|
||||
end
|
||||
|
||||
test(fpath)
|
||||
end
|
||||
|
||||
release("jecs", { pesde = "marked/jecs_nightly", wally = "mark-marks/jecs-nightly" }, false)
|
||||
|
|
130
src/release.luau
130
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<string>
|
||||
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<nil>
|
||||
local function release(origin: string, scopes: { wally: string?, pesde: string? }, dry: boolean?): result.Identity<nil>
|
||||
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,79 +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
|
||||
|
||||
local res_pesde
|
||||
if not dry then
|
||||
res_pesde = process.spawn("pesde", { "publish", "-y" }, { cwd = cwd })
|
||||
else
|
||||
res_pesde = process.spawn("pesde", { "publish", "-d", "-y" }, { cwd = cwd })
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
progress:nextStage()
|
||||
progress:nextStage() -- release (wally)
|
||||
|
||||
local res_wally
|
||||
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 })
|
||||
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
|
||||
|
||||
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}`)
|
||||
progress:stop() -- finish
|
||||
|
||||
if res_pesde then
|
||||
print(`-- Pesde out:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`)
|
||||
end
|
||||
|
||||
progress:stop()
|
||||
if res_wally then
|
||||
print(`-- Wally out:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`)
|
||||
end
|
||||
|
||||
print(`-- Pesde out:\nstdout\n{res_pesde.stdout}\nstderr\n{res_pesde.stderr}`)
|
||||
print(`-- Wally out:\nstdout\n{res_wally.stdout}\nstderr\n{res_wally.stderr}`)
|
||||
|
||||
local took = round_to((os.clock() - begin) * 1_000, 2)
|
||||
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
|
||||
|
|
47
src/shared.luau
Normal file
47
src/shared.luau
Normal file
|
@ -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<string>
|
||||
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<nil>
|
||||
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,
|
||||
}
|
|
@ -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<string>
|
||||
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<nil>
|
||||
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<boolean>
|
||||
local now = datetime.now()
|
||||
local begin = os.clock()
|
||||
|
||||
local progress = progress_bar
|
||||
|
@ -44,13 +19,15 @@ local function sync(to: string): result.Identity<boolean>
|
|||
: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<boolean>
|
|||
"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<boolean>
|
|||
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<boolean>
|
|||
|
||||
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<boolean>
|
|||
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)
|
||||
|
|
105
src/test.luau
Normal file
105
src/test.luau
Normal file
|
@ -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<boolean>
|
||||
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
|
586
src/util/frkcli.luau
Normal file
586
src/util/frkcli.luau
Normal file
|
@ -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<T>(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} <command>` })
|
||||
|
||||
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
|
Loading…
Reference in a new issue