commit a44fe0dbe03ae9de4fd3bd35ff6e130c1812b083 Author: marked Date: Sat Apr 5 18:29:25 2025 +0200 Initial commit diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..0cba26a --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + pull_request: + paths: + - "**.cs" + - "**.csproj" + workflow_dispatch: + +env: + DOTNET_VERSION: "8.0.407" + +jobs: + build: + name: Build + runs-on: docker + container: + image: ghcr.io/catthehacker/ubuntu:act-24.04 + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set Date + run: echo "DATE=$(date +'%Y%m%dT%H%M%SZ')" >> $GITHUB_ENV + + - name: Setup .NET Core + uses: https://github.com/actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install Dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Upload Build Artifact + uses: https://git.devmarked.win/actions/upload-artifact@v4 + with: + name: build-${{ env.DATE }}.zip + path: src/bin/Release/net8.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62bad64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/bin +**/obj diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..4dce889 --- /dev/null +++ b/.luaurc @@ -0,0 +1,7 @@ +{ + "languageMode": "strict", + "aliases": { + "lune": "~/.lune/.typedefs/0.8.9", + "pkg": "lune/pkg" + } +} diff --git a/CSSharpTemplate.sln b/CSSharpTemplate.sln new file mode 100644 index 0000000..6f67cb9 --- /dev/null +++ b/CSSharpTemplate.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CSSharpTemplate", "src\CSSharpTemplate.csproj", "{A50E1683-7ED3-4FE7-A752-DAFC22ABBDBD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A50E1683-7ED3-4FE7-A752-DAFC22ABBDBD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A50E1683-7ED3-4FE7-A752-DAFC22ABBDBD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A50E1683-7ED3-4FE7-A752-DAFC22ABBDBD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A50E1683-7ED3-4FE7-A752-DAFC22ABBDBD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f7ab96 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 marked + +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. diff --git a/PROJECT-SETUP.md b/PROJECT-SETUP.md new file mode 100644 index 0000000..4666499 --- /dev/null +++ b/PROJECT-SETUP.md @@ -0,0 +1,9 @@ +## PROJECT SETUP + +To set up the template: +1. Make a repository using the template +2. `git clone YOUR-REPOSITORY-URL MyPlugin` +3. `cd MyPlugin` +4. `lune run manager -- setup MyPlugin` + +This file will be deleted afterwards. diff --git a/README.md b/README.md new file mode 100644 index 0000000..af4502d --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +

CSSharpTemplate

+ +
+ + +
+
+ +Description + +# Installation + +1. Download the latest build artifact from [the build action](https://git.devmarked.win/marked/CSSharpTemplate/actions?workflow=build.yml) +2. Extract it in `addons/counterstrikesharp/plugins` + +# Development + +## Setup + +This project uses [Lune](https://github.com/lune-org/lune) to feature a cross-platform helper script.\ +To install it, fetch the latest binary from [GitHub releases](https://github.com/lune-org/lune/releases/tag/v0.8.9), or install it via cargo: +```sh +cargo install --locked lune +``` + +## Building + +For a basic debug build, run: +```sh +lune run manager -- build +``` + +To specify a configuration: +```sh +lune run manager -- build --configuration CONFIGURATION +``` + +To replicate the built plugin to a docker container: +```sh +lune run manager -- build --replicator docker --remote-output container-id:/path/to/plugin +``` + +To replicate the built plugin to a remote machine: +```sh +lune run manager -- build --replicator ssh --remote-output username@hostname:/path/to/plugin +``` + +To replicate the built plugin to a docker container on a remote machine: +```sh +lune run manager -- build --replicator ssh-docker --remote-output username@hostname->container-id:/path/to/plugin +``` + +You can also write a custom replicator in [Luau](https://github.com/luau-lang/luau) under `lune/manager/replicators`. diff --git a/lune/manager/build.luau b/lune/manager/build.luau new file mode 100644 index 0000000..7f1f76d --- /dev/null +++ b/lune/manager/build.luau @@ -0,0 +1,46 @@ +--!strict +local common = require("./common") +local progress_bar = require("@pkg/progress") +local result = require("@pkg/result") + +export type ReplicatorData = { + remote_output: string, + replicator: string, +} +local function build(configuration: string, replicator_data: ReplicatorData?) + local progress = progress_bar.new():with_stage("init", "Initializing"):with_stage("build", "Building project") + if replicator_data then + progress:with_stage("replicate", "Replicating to remote server") + end + + progress:start() -- init + + progress:next_stage() -- build + + local build_result = common.spawn(`dotnet build -c {configuration}`) + if not build_result.ok then + progress:stop() + return result(false, `Failed to build:\n{build_result.err}`) + end + + if replicator_data then + progress:next_stage() -- replicate + + progress:pause() + local replicator: common.Replicator = (require)(`./replicators/{replicator_data.replicator}`) + local replication_result = replicator(`src/bin/{configuration}/net8.0`, replicator_data.remote_output) + if not replication_result.ok then + progress:stop() + return result(false, `Failed to replicate build:\n{replication_result.err}`) + end + progress:unpause() + end + + progress:stop() -- finish + + print(build_result.val.stdout) + + return result(true, nil) +end + +return build diff --git a/lune/manager/common.luau b/lune/manager/common.luau new file mode 100644 index 0000000..60df0ad --- /dev/null +++ b/lune/manager/common.luau @@ -0,0 +1,46 @@ +--!strict +local process = require("@lune/process") +local stdio = require("@lune/stdio") + +local result = require("@pkg/result") + +local function c_print(msg: string) + stdio.write(`{stdio.color("blue")}[info]{stdio.color("reset")} {msg}\n`) +end + +local function c_warn(msg: string) + stdio.write(`{stdio.color("yellow")}[warn]{stdio.color("reset")} {msg}\n`) +end + +local function c_error(msg: string) + stdio.ewrite(`{stdio.color("red")}[error]{stdio.color("reset")} {msg}\n`) +end + +local function c_spawn(command: string): result.Identity + local args = string.split(command, " ") + local program = table.remove(args, 1) + if not program then + return result(false, "Couldn't find program in command.") + end + + local spawn_result = process.spawn(program, args) + if not spawn_result.ok then + return result(false, spawn_result.stderr) + end + + return result(true, spawn_result) +end + +local common = { + default_name = "CSSharpTemplate", + + print = c_print, + warn = c_warn, + error = c_error, + + spawn = c_spawn, +} + +export type Replicator = (dist_folder: string, remote_output: string) -> result.Identity + +return common diff --git a/lune/manager/init.luau b/lune/manager/init.luau new file mode 100644 index 0000000..1bb5e04 --- /dev/null +++ b/lune/manager/init.luau @@ -0,0 +1,67 @@ +--!strict +local process = require("@lune/process") + +local common = require("./common") +local frkcli = require("@pkg/frkcli") + +local cli = frkcli.new_subcommands("build_cli", "CLI for automatic basic C# tasks") + +local command_build = cli:add_subcommand( + "build", + [[ +Build the project. + + Additionally, you can use a replicator to replicate your built plugin to: + 1. A docker container: + `lune run manager -- build --replicator docker --remote-output container-id:/path/to/plugin` + 2. A remote machine: + `lune run manager -- build --replicator ssh --remote-output username@hostname:/path/to/plugin` + 3. A docker container on a remote machine: + `lune run manager -- build --replicator docker-ssh --remote-output username@hostname->container-id:/path/to/plugin` + + You can also use a custom replicator, as long as it's located inside `lune/manager/replicators`. +]] +) +command_build:add_option( + "configuration", + { help = "The configuration to use for building the project", aliases = { "c" }, default = "Debug" } +) +command_build:add_option( + "remote-output", + { help = "Path on a remote machine to copy the built project to", aliases = { "o" }, default = "" } +) +command_build:add_option( + "replicator", + { help = "Replicator to use for copying builds to a remote machine", aliases = { "r" }, default = "ssh" } +) + +local command_setup = cli:add_subcommand("setup", "Setup the project") +command_setup:add_positional("name", { help = "Name of the project", default = nil }) +command_setup:add_flag("force", { help = "Force the setup?", aliases = { "f" } }) + +local parsed, err = cli:parse(process.args) +if err ~= nil then + error(err) +end +assert(parsed ~= nil) + +local values = parsed.result.values +local flags = parsed.result.flags +if parsed.command == "build" then + local replicator_data = nil + if values.remote_output ~= "" then + replicator_data = { + remote_output = values.remote_output, + replicator = values.replicator, + } + end + + require("./build")(values.configuration, replicator_data) +elseif parsed.command == "setup" then + local setup = require("./setup") + local result = setup(values.name, flags.force) + + if not result.ok then + common.error(result.err) + end +end diff --git a/lune/manager/replicators/docker.luau b/lune/manager/replicators/docker.luau new file mode 100644 index 0000000..747935f --- /dev/null +++ b/lune/manager/replicators/docker.luau @@ -0,0 +1,16 @@ +--!strict +local process = require("@lune/process") + +local common = require("../common") +local result = require("@pkg/result") + +local function docker_replicator(dist_folder: string, remote_output: string): result.Identity + local copy_result = process.spawn("docker", { "cp", "-r", dist_folder, remote_output }) + if not copy_result.ok then + return result(false, `Failed to copy files:\n{copy_result.stderr}`) + end + + return result(true, nil) +end + +return docker_replicator :: common.Replicator diff --git a/lune/manager/replicators/ssh-docker.luau b/lune/manager/replicators/ssh-docker.luau new file mode 100644 index 0000000..86d81ec --- /dev/null +++ b/lune/manager/replicators/ssh-docker.luau @@ -0,0 +1,41 @@ +--!strict +local process = require("@lune/process") + +local common = require("../common") +local result = require("@pkg/result") + +local function ssh_docker_replicator(dist_folder: string, remote_output: string): result.Identity + local split = string.split(remote_output, "->") + + local remote_machine = split[1] + if not remote_machine then + return result(false, "Couldn't get the remote machine to ssh into.") + end + + local docker_path = split[2] + if not docker_path then + return result(false, "Couldn't get the docker container path for the remote machine.") + end + + local ssh_copy_result = process.spawn( + "scp", + { "-r", "-O", dist_folder, `{remote_machine}:/tmp/cssharp-replicate` }, + { stdio = "forward" } + ) + if not ssh_copy_result.ok then + return result(false, `Failed to copy files to the remote machine:\n{ssh_copy_result.stderr}`) + end + + local docker_copy_result = process.spawn( + "ssh", + { "-t", remote_machine, `"docker cp -r /tmp/cssharp-replicate {docker_path}"` }, + { stdio = "forward" } + ) + if not docker_copy_result.ok then + return result(false, `Failed to copy files via docker on the remote machine:\n{docker_copy_result.stderr}`) + end + + return result(true, nil) +end + +return ssh_docker_replicator :: common.Replicator diff --git a/lune/manager/replicators/ssh.luau b/lune/manager/replicators/ssh.luau new file mode 100644 index 0000000..908bafb --- /dev/null +++ b/lune/manager/replicators/ssh.luau @@ -0,0 +1,16 @@ +--!strict +local process = require("@lune/process") + +local common = require("../common") +local result = require("@pkg/result") + +local function ssh_replicator(dist_folder: string, remote_output: string): result.Identity + local copy_result = process.spawn("scp", { "-r", "-O", dist_folder, remote_output }, { stdio = "forward" }) + if not copy_result.ok then + return result(false, `Failed to copy files:\n{copy_result.stderr}`) + end + + return result(true, nil) +end + +return ssh_replicator :: common.Replicator diff --git a/lune/manager/setup.luau b/lune/manager/setup.luau new file mode 100644 index 0000000..b7fbfc6 --- /dev/null +++ b/lune/manager/setup.luau @@ -0,0 +1,69 @@ +--!strict +local fs = require("@lune/fs") + +local common = require("./common") +local progress_bar = require("@pkg/progress") +local result = require("@pkg/result") + +local function setup(name: string, force: boolean): result.Identity + local progress = progress_bar + .new() + :with_stage("init", "Initializing") + :with_stage("prepare", "Cleaning up defaults") + :with_stage("modify", "Replacing default names") + :with_stage("generate", "Generating .sln") + progress:start() -- init + + if not fs.metadata(`{common.default_name}.sln`).exists and not force then + progress:stop() + return result(false, "Aborting -- the template is already set up.") + end + + progress:next_stage() -- prepare + + fs.removeFile(`{common.default_name}.sln`) + fs.removeFile(`PROJECT-SETUP.md`) + + progress:next_stage() -- modify + + fs.move(`src/{common.default_name}.cs`, `src/{name}.cs`) + fs.move(`src/{common.default_name}.csproj`, `src/{name}.csproj`) + + do + local main_src = fs.readFile(`src/{name}.cs`) + local edited_src = string.gsub(main_src, common.default_name, name) + fs.writeFile(`src/{name}.cs`, edited_src) + end + + progress:next_stage() -- generate + + do + local res = common.spawn(`dotnet new sln --name {name}`) + if not res.ok then + return result(false, `Failed to create new sln:\n{res.err}`) + end + end + + do + local res = common.spawn("dotnet sln add src") + if not res.ok then + return result(false, `Failed to add src to sln:\n{res.err}`) + end + end + + do + local sln_src = fs.readFile(`{name}.sln`) + local by_line = string.split(sln_src, "\n") + for _ = 1, 3 do + table.remove(by_line, 3) + end + local concated = table.concat(by_line, "\n") + fs.writeFile(`{name}.sln`, concated) + end + + progress:stop() -- finish + + return result(true, nil) +end + +return setup diff --git a/lune/pkg/frkcli.luau b/lune/pkg/frkcli.luau new file mode 100644 index 0000000..300e64b --- /dev/null +++ b/lune/pkg/frkcli.luau @@ -0,0 +1,585 @@ +--!nolint LocalShadow + +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 diff --git a/lune/pkg/progress.luau b/lune/pkg/progress.luau new file mode 100644 index 0000000..1529d31 --- /dev/null +++ b/lune/pkg/progress.luau @@ -0,0 +1,135 @@ +--!strict +--> Inspired by Rokit's progress bar: https://github.com/rojo-rbx/rokit/blob/a303faf/src/util/progress.rs +-- Original: https://github.com/pesde-pkg/tooling/blob/main/toolchainlib/src/utils/progress.luau +--[[ + MIT License + + Copyright (c) 2024 pesde-pkg + + 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. +]] +local stdio = require("@lune/stdio") +local task = require("@lune/task") + +local result = require("./result") + +-- FORMAT: {SPINNER} {MESSAGE} {BAR} {STAGE} +local SPINNERS = { "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } +local BAR_COMPONENT = "▇" +local MAX_BAR_LENGTH = 30 + +local ProgressBar = {} +type ProgressBar = { + stages: { { tag: string, message: string } }, + current_stage_index: number, + finished: boolean, + paused: boolean, + thread: thread?, +} +export type ProgressBarImpl = typeof(setmetatable({} :: ProgressBar, { __index = ProgressBar })) + +function ProgressBar.new(): ProgressBarImpl + return setmetatable( + { + stages = {}, + current_stage_index = 1, + finished = false, + } :: ProgressBar, + { + __index = ProgressBar, + } + ) +end + +function ProgressBar.with_stage(self: ProgressBarImpl, tag: string, msg: string): ProgressBarImpl + table.insert(self.stages, { tag = tag, message = msg }) + return self +end + +function ProgressBar.start(self: ProgressBarImpl) + local BAR_LENGTH = MAX_BAR_LENGTH // #self.stages + local TOTAL_BAR_LENGTH = BAR_LENGTH * #self.stages + local BAR = string.rep(BAR_COMPONENT, BAR_LENGTH) + local MAX_MESSAGE_LENGTH = 0 + for _, stage in self.stages do + local len = #stage.message + if len > MAX_MESSAGE_LENGTH then + MAX_MESSAGE_LENGTH = len + end + end + + self.thread = task.spawn(function() + while true do + if self.paused then + task.wait(0.1) + continue + end + + for _, spinner in SPINNERS do + if self.finished then + return + end + + local stage = self.stages[self.current_stage_index] + stdio.ewrite( + `\x1b[2K\x1b[0G{stdio.color("cyan")}{spinner} {stage.message}{stdio.color("reset")}{string.rep( + " ", + MAX_MESSAGE_LENGTH - #stage.message + )} [{stdio.style("dim")}{string.rep(BAR, self.current_stage_index)}{string.rep( + " ", + TOTAL_BAR_LENGTH - (BAR_LENGTH * self.current_stage_index) + )}{stdio.style("reset")}] {stdio.style("bold")}{self.current_stage_index} / {#self.stages}{stdio.style( + "reset" + )}` + ) + + task.wait(0.1) + end + end + end) +end + +function ProgressBar.stop(self: ProgressBarImpl) + -- Trigger upvalue, kill thread and clean progress bar remnant + self.finished = true + stdio.ewrite("\x1b[2K\x1b[0G") +end + +function ProgressBar.pause(self: ProgressBarImpl) + self.paused = true + stdio.ewrite("\x1b[2K\x1b[0G") +end + +function ProgressBar.unpause(self: ProgressBarImpl) + self.paused = false +end + +function ProgressBar.next_stage(self: ProgressBarImpl): result.Identity + local inc = self.current_stage_index + 1 + if inc > #self.stages then + -- TODO: Make this a result + self.finished = true + return result(false, "OutOfBounds - Attempted to advance past last stage") + end + + self.current_stage_index = inc + return result(true, nil) +end + +return ProgressBar diff --git a/lune/pkg/result.luau b/lune/pkg/result.luau new file mode 100644 index 0000000..0433cf3 --- /dev/null +++ b/lune/pkg/result.luau @@ -0,0 +1,24 @@ +--!strict +export type Identity = { + ok: true, + val: T, +} | { + ok: false, + err: string, +} + +local function construct(ok: boolean, value: T & string): Identity + if ok then + return { + ok = true, + val = value, + } + else + return { + ok = false, + err = value, + } + end +end + +return (construct :: any) :: ((ok: true, value: T) -> Identity) & ((ok: false, value: string) -> Identity) diff --git a/src/CSSharpTemplate.cs b/src/CSSharpTemplate.cs new file mode 100644 index 0000000..2dbe3a3 --- /dev/null +++ b/src/CSSharpTemplate.cs @@ -0,0 +1,13 @@ +using CounterStrikeSharp.API.Core; + +namespace SimleAdmin; +public class CSSharpTemplate : BasePlugin +{ + public override string ModuleName => "CSSharpTemplate"; + public override string ModuleVersion => "0.1.0"; + + public override void Load(bool hotReload) + { + Console.WriteLine("Hello World!"); + } +} diff --git a/src/CSSharpTemplate.csproj b/src/CSSharpTemplate.csproj new file mode 100644 index 0000000..161ac65 --- /dev/null +++ b/src/CSSharpTemplate.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..920827f --- /dev/null +++ b/stylua.toml @@ -0,0 +1,10 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 4 +quote_style = "AutoPreferDouble" +call_parentheses = "Input" +collapse_simple_statement = "Never" + +[sort_requires] +enabled = true