From 7a11538767c08cc7a60c0c64c55eab593c3a0501 Mon Sep 17 00:00:00 2001 From: Nikita Chulkov Date: Tue, 1 Sep 2026 10:09:06 +1100 Subject: [PATCH 1/4] build: improved windows compat for build.c --- build.c | 127 +++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 107 insertions(+), 20 deletions(-) diff --git a/build.c b/build.c index 054fff9..47cf03b 100644 --- a/build.c +++ b/build.c @@ -7,7 +7,6 @@ // It parses its own command line with optly, so the library is exercised by // the thing that builds it. -#include #include #include #include @@ -27,21 +26,40 @@ #endif #define mkdir(path, mode) _mkdir(path) +#define chdir(path) _chdir(path) #define PATH_SEP "\\" #define PATH_LIST_SEP ';' +#define EXE_SUFFIX ".exe" + +// NOTE: MSVC's sys/stat.h has the S_IF* constants but not the S_IS* macros +// that C99 code uses to test them. +#ifndef S_ISDIR +#define S_ISDIR(mode) (((mode) & _S_IFMT) == _S_IFDIR) +#endif + +#ifndef S_ISREG +#define S_ISREG(mode) (((mode) & _S_IFMT) == _S_IFREG) +#endif #else +#include #include #include #include #define PATH_SEP "/" #define PATH_LIST_SEP ':' +#define EXE_SUFFIX "" #endif // _WIN32 #define PATH_MAX_LEN 4096 #define CMD_MAX_ARGV 64 #define MAX_ENTRIES 128 +// NOTE: sized to hold a Windows WIN32_FIND_DATAA.cFileName, which is MAX_PATH +// (260) bytes. A smaller buffer is a truncation warning there and -Werror is +// on. +#define NAME_MAX_LEN 260 + // NOTE: strum is the reference .tspec runner, but the format is not tied to // it. --tspec-runner exists so a second implementation can be dropped in // without touching this file. @@ -100,7 +118,45 @@ static int compare_names(const void *a, const void *b) { return strcmp(*(const char *const *)a, *(const char *const *)b); } -static int list_dir(const char *dir, bool (*filter)(const char *name), char names[][256], int max) { +// Collects the names of the entries in dir that pass filter. Sorted, so a +// build does not depend on the order the filesystem hands them back, and the +// two implementations agree on what a run looks like. +static int collect_names(const char *dir, bool (*filter)(const char *name), char names[][NAME_MAX_LEN], int max) { +#ifdef _WIN32 + // NOTE: the ...A forms are used explicitly. The unsuffixed names change + // meaning under UNICODE, and this file deals in char * paths throughout. + char pattern[PATH_MAX_LEN]; + WIN32_FIND_DATAA fd; + + if (snprintf(pattern, sizeof(pattern), "%s" PATH_SEP "*", dir) < 0) { + FATAL("Path too long: %s", dir); + return -1; + } + + HANDLE find = FindFirstFileA(pattern, &fd); + + if (find == INVALID_HANDLE_VALUE) { + FATAL("Can not open %s", dir); + return -1; + } + + int count = 0; + + do { + if (fd.cFileName[0] == '.') { + continue; + } + + if (filter && !filter(fd.cFileName)) { + continue; + } + + snprintf(names[count], NAME_MAX_LEN, "%s", fd.cFileName); + count++; + } while (count < max && FindNextFileA(find, &fd)); + + FindClose(find); +#else DIR *d = opendir(dir); if (!d) { @@ -119,11 +175,12 @@ static int list_dir(const char *dir, bool (*filter)(const char *name), char name continue; } - snprintf(names[count], 256, "%s", e->d_name); + snprintf(names[count], NAME_MAX_LEN, "%s", e->d_name); count++; } closedir(d); +#endif char *ptrs[MAX_ENTRIES]; @@ -133,14 +190,14 @@ static int list_dir(const char *dir, bool (*filter)(const char *name), char name qsort(ptrs, (size_t)count, sizeof(ptrs[0]), compare_names); - char sorted[MAX_ENTRIES][256]; + char sorted[MAX_ENTRIES][NAME_MAX_LEN]; for (int i = 0; i < count; i++) { - snprintf(sorted[i], 256, "%s", ptrs[i]); + snprintf(sorted[i], NAME_MAX_LEN, "%s", ptrs[i]); } for (int i = 0; i < count; i++) { - snprintf(names[i], 256, "%s", sorted[i]); + snprintf(names[i], NAME_MAX_LEN, "%s", sorted[i]); } return count; @@ -158,10 +215,44 @@ static bool is_executable(const char *path) { // Resolves a bare name against PATH so a missing runner is reported before // anything is compiled, rather than as a failed exec halfway through. -static bool find_executable(const char *name, char *out, size_t cap) { - if (strchr(name, PATH_SEP[0]) != NULL) { +static bool has_separator(const char *path) { +#ifdef _WIN32 + // NOTE: Windows accepts both, and people type both. + return strchr(path, '\\') != NULL || strchr(path, '/') != NULL; +#else + return strchr(path, '/') != NULL; +#endif +} + +// A bare name on Windows is spelled without .exe but stored with it, so both +// spellings have to be tried before deciding the program is missing. +static bool is_executable_named(const char *dir, size_t dir_len, const char *name, char *out, size_t cap) { + if (dir) { + snprintf(out, cap, "%.*s" PATH_SEP "%s", (int)dir_len, dir, name); + } else { snprintf(out, cap, "%s", name); - return is_executable(out); + } + + if (is_executable(out)) { + return true; + } + + if (EXE_SUFFIX[0] == '\0') { + return false; + } + + if (dir) { + snprintf(out, cap, "%.*s" PATH_SEP "%s" EXE_SUFFIX, (int)dir_len, dir, name); + } else { + snprintf(out, cap, "%s" EXE_SUFFIX, name); + } + + return is_executable(out); +} + +static bool find_executable(const char *name, char *out, size_t cap) { + if (has_separator(name)) { + return is_executable_named(NULL, 0, name, out, cap); } const char *path = getenv("PATH"); @@ -174,12 +265,8 @@ static bool find_executable(const char *name, char *out, size_t cap) { const char *sep = strchr(path, PATH_LIST_SEP); size_t len = sep ? (size_t)(sep - path) : strlen(path); - if (len > 0 && len < cap) { - snprintf(out, cap, "%.*s" PATH_SEP "%s", (int)len, path, name); - - if (is_executable(out)) { - return true; - } + if (len > 0 && len < cap && is_executable_named(path, len, name, out, cap)) { + return true; } if (!sep) { @@ -257,7 +344,7 @@ static bool build_example(const char *cc, const char *std, bool debug, const cha char out[PATH_MAX_LEN]; char stdflag[64]; - char stem[256]; + char stem[NAME_MAX_LEN]; snprintf(stdflag, sizeof(stdflag), "-std=%s", std); snprintf(stem, sizeof(stem), "%s", name); @@ -295,8 +382,8 @@ static bool build_example(const char *cc, const char *std, bool debug, const cha } static bool build_examples(const char *cc, const char *std, bool debug) { - char names[MAX_ENTRIES][256]; - int count = list_dir("examples", has_c_extension, names, MAX_ENTRIES); + char names[MAX_ENTRIES][NAME_MAX_LEN]; + int count = collect_names("examples", has_c_extension, names, MAX_ENTRIES); if (count < 0) { return false; @@ -356,8 +443,8 @@ static bool run_tests(const char *runner_name) { } static bool clean(void) { - char names[MAX_ENTRIES][256]; - int count = list_dir(outdir, NULL, names, MAX_ENTRIES); + char names[MAX_ENTRIES][NAME_MAX_LEN]; + int count = collect_names(outdir, NULL, names, MAX_ENTRIES); if (count < 0) { return false; From f78fa60f1c01237336f68e296690f0625a84028f Mon Sep 17 00:00:00 2001 From: Nikita Chulkov Date: Tue, 1 Sep 2026 10:09:26 +1100 Subject: [PATCH 2/4] ci: improved ci/cd workflows --- .github/workflows/cd.yml | 91 +++++++++++++++++++++ .github/workflows/ci.yml | 157 +++++++++++++++++++++++++++++++++++++ .github/workflows/main.yml | 36 --------- 3 files changed, 248 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/cd.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/main.yml diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml new file mode 100644 index 0000000..f364527 --- /dev/null +++ b/.github/workflows/cd.yml @@ -0,0 +1,91 @@ +name: CD + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # A tag that fails its own tests should not become a release. + - name: Build strum + run: | + git clone --depth 1 https://github.com/Strongleong/Strum strum + cd strum + cc build.c -o build + ./build -c gcc + echo "$PWD/out" >> "$GITHUB_PATH" + + - name: Build and test + run: | + cc build.c -o build + ./build + ./build tests + + # The tag and the version macros have to agree, or people who check + # OPTLY_VERSION_NUMBER at compile time get a different answer from people + # who read the release page. + - name: Check the version matches the tag + run: | + major=$(sed -n 's/^#define OPTLY_VERSION_MAJOR *//p' optly.h | tr -d ' ') + minor=$(sed -n 's/^#define OPTLY_VERSION_MINOR *//p' optly.h | tr -d ' ') + patch=$(sed -n 's/^#define OPTLY_VERSION_RELEASE *//p' optly.h | tr -d ' ') + header="v${major}.${minor}.${patch}" + tag="${GITHUB_REF_NAME}" + + echo "header says $header, tag says $tag" + + if [ "$header" != "$tag" ]; then + echo "::error::optly.h is $header but the tag is $tag" + exit 1 + fi + + # The version also appears in the banner comment at the top of the file, + # which is what people actually read when they open a vendored copy. + - name: Check the banner matches the tag + run: | + banner=$(sed -n 's/^ optly\.h — v//p' optly.h | head -1 | tr -d ' ') + tag="${GITHUB_REF_NAME#v}" + + echo "banner says $banner, tag says $tag" + + if [ "$banner" != "$tag" ]; then + echo "::error::the banner in optly.h says v$banner but the tag is v$tag" + exit 1 + fi + + # The changelog should describe the tag being cut, not still say + # Upcoming. + - name: Check the changelog has an entry for this tag + run: | + if ! grep -q "^## ${GITHUB_REF_NAME}$" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## ${GITHUB_REF_NAME}' heading" + exit 1 + fi + + # optly.h is the artifact. Shipping it as a release asset means a user + # can pin a version without cloning or trusting a moving branch. + - name: Package + run: | + mkdir -p dist + cp optly.h LICENSE README.md CHANGELOG.md dist/ + tar -czf "optly-${GITHUB_REF_NAME}.tar.gz" -C dist . + sha256sum optly.h "optly-${GITHUB_REF_NAME}.tar.gz" > checksums.txt + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + optly.h + optly-${{ github.ref_name }}.tar.gz + checksums.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd74f40 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,157 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + workflow_dispatch: + +jobs: + # The .tspec suite is the real check. strum is POSIX-only, so this is + # everywhere it can run. + tests: + name: tests (${{ matrix.os }}, ${{ matrix.cc }}, ${{ matrix.std }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + cc: [gcc, clang] + std: [c99, c11, c17] + steps: + - uses: actions/checkout@v4 + + # Built from source rather than pulled from a release, so this does not + # depend on an asset existing for the runner's platform. + - name: Build strum + run: | + git clone --depth 1 https://github.com/Strongleong/Strum strum + cd strum + cc build.c -o build + ./build -c ${{ matrix.cc }} + echo "$PWD/out" >> "$GITHUB_PATH" + + - name: Build examples + run: | + cc build.c -o build + ./build -c ${{ matrix.cc }} --std ${{ matrix.std }} + + - name: Run tests + run: ./build tests + + # Windows has no strum, so these jobs answer a narrower question: does the + # header compile, and does a program built with it behave. Each step asks one + # thing, so a red run names its own cause instead of leaving three + # candidates. Commands are echoed and output is compared against exact bytes; + # a green tick here means something specific happened. + mingw: + name: windows (mingw, ${{ matrix.std }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + std: [c99, c11, c17] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - name: Compile the header on its own + run: | + set -x + gcc -std=${{ matrix.std }} -Wall -Wextra -Werror -pedantic \ + -DOPTLY_IMPLEMENTATION -I. -c -x c optly.h -o header.o + + - name: Compile the build tool + run: | + set -x + gcc -std=${{ matrix.std }} -Wall -Wextra -Werror -pedantic build.c -o build.exe + + - name: Build the examples with it + run: | + set -x + ./build.exe -c gcc --std ${{ matrix.std }} + ls -l out + + - name: A program built with optly parses its command line + run: | + set -x + gcc -std=${{ matrix.std }} -Wall -Wextra -Werror -pedantic \ + -I. tests/flags/flags.c -o flags.exe + ./flags.exe -vqf --threads=16 --out=bin/app > got.txt + printf 'verbose=1\nquiet=1\nforce=1\nthreads=16\nout=bin/app\n' > want.txt + diff -u want.txt got.txt + + # MSVC is the toolchain optly has never been built with. /std:c11 is not + # optional: the whole DSL is designated initializers and compound literals, + # and MSVC rejects both in its default C mode. + msvc: + name: windows (msvc) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: ilammy/msvc-dev-cmd@v1 + + - name: Compile the header on its own + shell: cmd + run: | + echo on + cl /W4 /WX /std:c11 /TC /DOPTLY_IMPLEMENTATION /I. /c optly.h /Fo:header.obj + + - name: Compile the build tool + shell: cmd + run: | + echo on + cl /W4 /WX /std:c11 /TC build.c /Fe:build.exe + + - name: Build the examples with it + shell: cmd + run: | + echo on + build.exe -c cl --std c11 + dir out + + - name: A program built with optly parses its command line + shell: cmd + run: | + echo on + cl /W4 /WX /std:c11 /TC /I. tests\flags\flags.c /Fe:flags.exe + flags.exe -vqf --threads=16 --out=bin/app > got.txt + type got.txt + + - name: Compare that output against the expected bytes + shell: bash + run: | + printf 'verbose=1\nquiet=1\nforce=1\nthreads=16\nout=bin/app\n' > want.txt + diff -u --strip-trailing-cr want.txt got.txt + + sanitizers: + name: sanitizers + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build strum + run: | + git clone --depth 1 https://github.com/Strongleong/Strum strum + cd strum + cc build.c -o build + ./build -c gcc + echo "$PWD/out" >> "$GITHUB_PATH" + + # The over-long --flag=value crash fixed in v2.4.0 was a NULL write that + # a normal build can miss. ASan is how that class gets caught next time. + - name: Test fixtures under AddressSanitizer + run: | + for fixture in tests/*/*.c; do + gcc -std=c99 -Wall -Wextra -pedantic -fsanitize=address,undefined \ + -I. "$fixture" -o /tmp/fixture + /tmp/fixture >/dev/null 2>&1 || true + done + + - name: Tests + run: | + cc build.c -o build + ./build tests diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 3fc0553..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build and Test - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - paths: - - '**.c' - - '**.h' - - '**.tspec' - - '.github/workflows/main.yml' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Build strum - run: | - git clone --depth 1 https://github.com/Strongleong/Strum strum - cd strum - cc build.c -o build - ./build -c gcc - echo "$PWD/out" >> "$GITHUB_PATH" - - - name: Build - run: | - cc build.c -o build - ./build - - - name: Test - run: ./build tests From 20942c0bf8d77561c0ec621183836ac549200754 Mon Sep 17 00:00:00 2001 From: Nikita Chulkov Date: Tue, 1 Sep 2026 11:19:45 +1100 Subject: [PATCH 3/4] feat: batched flags allows last one to be non-bool, tar style --- README.md | 7 ++++- optly.h | 35 +++++++++++++++++++----- tests/errors/test.tspec | 32 ++++++++++++++++------ tests/flags/test.tspec | 60 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 57c298c..c49130b 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,12 @@ Equivalent to: -a -b -c -*(Batched flags must be boolean.)* +The last flag in a batch may take a value, the way tar does it: + + tar -xzvf archive.tar + +Everything before the last one must be boolean -- a flag in the middle has no +way to say where its value ends. ## Commands diff --git a/optly.h b/optly.h index eb6c1c8..69bb223 100644 --- a/optly.h +++ b/optly.h @@ -141,7 +141,8 @@ --threads 4 -t 4 - Short flags can be batched. + Short flags can be batched. Every flag in a batch must be boolean except the + last, which may take a value, the way tar spells `-xzvf archive.tar`. -abc -> -a -b -c @@ -913,7 +914,14 @@ inline static bool optly_is_version_flag(char *arg) { strchr(arg, OPTLY_VERSION_SHORT_FLAG[1]) != NULL); } -static void optly_parse_batch_flags(char *arg, OptlyFlag *flags, OptlyErrors *errs) { +// A batch is short bool flags with one optional value-taking flag at the end, +// the way tar spells -xzvf archive.tar. Only the last character may be +// non-boolean: anything earlier has no way to say where its value stops. +static void optly_parse_batch_flags(char ***argv_ptr, int *argc_ptr, OptlyFlag *flags, OptlyErrors *errs) { + char **argv = *argv_ptr; + int argc = *argc_ptr; + char *arg = *argv; + if (strchr(arg, '=') != NULL) { return; } @@ -932,16 +940,29 @@ static void optly_parse_batch_flags(char *arg, OptlyFlag *flags, OptlyErrors *er } if (flag->type != OPTLY_TYPE_BOOL) { - OPTLY_LOG(WARN, "cannot batch non-boolean flags (invalid flag in %s)", sarg); - optly_push_error(errs, OPTLY_ERR_BATCH_NON_BOOL, &flag->shortname); - continue; + if (c[1] != '\0') { + OPTLY_LOG(WARN, "cannot batch non-boolean flags (invalid flag in %s)", sarg); + optly_push_error(errs, OPTLY_ERR_BATCH_NON_BOOL, &flag->shortname); + continue; + } + + if (argc <= 1) { + OPTLY_LOG(WARN, "No value for flag %s", sarg); + optly_push_error(errs, OPTLY_ERR_MISSING_VALUE, sarg); + break; + } + + SHIFT_ARG(argv, argc); + optly_flag_set_value(flag, *argv, errs); + break; } flag->value.as_bool = true; flag->present = true; } - return; + *argv_ptr = argv; + *argc_ptr = argc; } static void optly_parse_long_flags(char ***argv_ptr, int *argc_ptr, OptlyFlag *flags, OptlyErrors *errs) { @@ -1022,7 +1043,7 @@ static void optly_parse_flags(char ***argv_ptr, int *argc_ptr, OptlyFlag *flags, bool is_batch_short = (arg[0] == '-' && arg[1] != '-' && strlen(arg) > 2) && arg[2] != '='; if (is_batch_short) { - optly_parse_batch_flags(arg, flags, errs); + optly_parse_batch_flags(argv_ptr, argc_ptr, flags, errs); } else { optly_parse_long_flags(argv_ptr, argc_ptr, flags, errs); } diff --git a/tests/errors/test.tspec b/tests/errors/test.tspec index c556867..a68b524 100644 --- a/tests/errors/test.tspec +++ b/tests/errors/test.tspec @@ -66,7 +66,7 @@ a.txt errors=1 0: Required flag is not present (token) -:test batching_a_non_bool_flag_is_reported +:test a_non_bool_flag_before_the_end_of_a_batch_is_reported :command compile :blob executable 2 cc @@ -76,15 +76,31 @@ cc :command run :blob executable 20 .side_effects/errors -:blob args 3 --tT +:blob args 11 +-tT x a.txt :int return 0 -:blob stdout 168 -errors=4 +:blob stdout 47 +errors=1 0: Cannot batch non-boolean flags (t) -1: Cannot batch non-boolean flags (T) -2: Required flag is not present (token) -3: Not enough positional arguments (files) + +:test a_batch_ending_in_a_value_flag_with_no_value_is_reported +:command compile +:blob executable 2 +cc +:blob args 81 +-std=c99 -Wall -Wextra -Werror -pedantic -I../.. errors.c -o .side_effects/errors +:int return 0 +:command run +:blob executable 20 +.side_effects/errors +:blob args 3 +-vT +:int return 0 +:blob stdout 121 +errors=3 +0: Flag requires a value () +1: Required flag is not present (token) +2: Not enough positional arguments (files) :test too_few_positionals_are_reported :command compile diff --git a/tests/flags/test.tspec b/tests/flags/test.tspec index e2a198c..c5a8c37 100644 --- a/tests/flags/test.tspec +++ b/tests/flags/test.tspec @@ -136,3 +136,63 @@ force=0 threads=4 out=bin/app +:test a_batch_may_end_with_a_flag_that_takes_a_value +:command compile +:blob executable 2 +cc +:blob args 79 +-std=c99 -Wall -Wextra -Werror -pedantic -I../.. flags.c -o .side_effects/flags +:int return 0 +:command run +:blob executable 19 +.side_effects/flags +:blob args 12 +-vqo bin/app +:int return 0 +:blob stdout 48 +verbose=1 +quiet=1 +force=0 +threads=4 +out=bin/app + +:test the_value_taking_flag_may_be_the_only_one_after_bools +:command compile +:blob executable 2 +cc +:blob args 79 +-std=c99 -Wall -Wextra -Werror -pedantic -I../.. flags.c -o .side_effects/flags +:int return 0 +:command run +:blob executable 19 +.side_effects/flags +:blob args 5 +-vt 8 +:int return 0 +:blob stdout 46 +verbose=1 +quiet=0 +force=0 +threads=8 +out=a.out + +:test a_batch_of_one_value_taking_flag_still_works +:command compile +:blob executable 2 +cc +:blob args 79 +-std=c99 -Wall -Wextra -Werror -pedantic -I../.. flags.c -o .side_effects/flags +:int return 0 +:command run +:blob executable 19 +.side_effects/flags +:blob args 10 +-o bin/app +:int return 0 +:blob stdout 48 +verbose=0 +quiet=0 +force=0 +threads=4 +out=bin/app + From 5e4be49f2b6f5c9db6a0eb2c7442a918426e4e79 Mon Sep 17 00:00:00 2001 From: Nikita Chulkov Date: Tue, 1 Sep 2026 11:21:29 +1100 Subject: [PATCH 4/4] chore: changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e488c9..92e171e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Upcoming +### Added + +- The last flag in a batch of short flags can now take a value, the way tar + spells `-xzvf archive.tar`. So `-vqo out.txt` works. Everything before the + last flag still has to be boolean, since a flag in the middle has no way to + say where its value ends. + ## v2.4.0 ### Added