Browse Source

Introduce nobuild build system

rexim 2 years ago
parent
commit
6bf054b7c7
5 changed files with 1253 additions and 68 deletions
  1. 2 0
      .gitignore
  2. 3 2
      README.md
  3. 0 66
      build.sh
  4. 97 0
      nobuild.c
  5. 1151 0
      nobuild.h

+ 2 - 0
.gitignore

@@ -1,3 +1,5 @@
 *.ppm
 build/
 *.swp
+nobuild
+nobuild.old

+ 3 - 2
README.md

@@ -53,10 +53,11 @@ int main(void)
 
 ## Building the Tests and Demos
 
-Even though the library does not require any special building, the tests and demos do. Just execute `build.sh` script to build everything.
+Even though the library does not require any special building, the tests and demos do. We use [nobuild](https://github.com/tsoding/nobuild) build system:
 
 ```console
-$ ./build.sh
+$ clang -o nobuild nobuild.c
+$ ./nobuild
 ```
 
 ## Tests

+ 0 - 66
build.sh

@@ -1,66 +0,0 @@
-#!/bin/sh
-
-set -xe
-
-COMMON_CFLAGS="-Wall -Wextra -pedantic -std=c99 -ggdb -I. -I./build/ -I./dev-deps/"
-
-build_wasm_demo() {
-    NAME=$1
-    clang $COMMON_CFLAGS -O2 -fno-builtin --target=wasm32 --no-standard-libraries -Wl,--no-entry -Wl,--export=vc_render -Wl,--export=__heap_base -Wl,--allow-undefined -o ./build/demos/$NAME.wasm -DVC_PLATFORM=VC_WASM_PLATFORM ./demos/$NAME.c
-    cp ./build/demos/$NAME.wasm ./wasm/
-}
-
-build_term_demo() {
-    NAME=$1
-    clang $COMMON_CFLAGS -O2 -o ./build/demos/$NAME.term -DVC_PLATFORM=VC_TERM_PLATFORM -D_XOPEN_SOURCE=600 ./demos/$NAME.c -lm
-}
-
-build_sdl_demo() {
-    NAME=$1
-    clang $COMMON_CFLAGS -O2 -o ./build/demos/$NAME.sdl -DVC_PLATFORM=VC_SDL_PLATFORM ./demos/$NAME.c -lm -lSDL2
-}
-
-build_vc_demo() {
-    NAME=$1
-    build_wasm_demo $NAME
-    build_term_demo $NAME
-    build_sdl_demo $NAME
-}
-
-build_all_vc_demos() {
-    mkdir -p ./build/demos
-    build_vc_demo triangle &
-    build_vc_demo dots3d &
-    build_vc_demo squish &
-    build_vc_demo triangle3d &
-    build_vc_demo triangleTex &
-    build_vc_demo triangle3dTex &
-    build_vc_demo cup3d &
-    build_vc_demo teapot3d &
-    wait # TODO: the whole script must fail if one of the jobs fails
-}
-
-build_tools() {
-    mkdir -p ./build/tools/
-    clang $COMMON_CFLAGS -o ./build/tools/png2c ./tools/png2c.c -lm &
-    clang $COMMON_CFLAGS -o ./build/tools/obj2c ./tools/obj2c.c -lm &
-    wait # TODO: the whole script must fail if one of the jobs fails
-}
-
-build_assets() {
-    mkdir -p ./build/assets/
-    ./build/tools/png2c -n tsodinPog -o ./build/assets/tsodinPog.c ./assets/tsodinPog.png &
-    ./build/tools/png2c -n tsodinCup -o ./build/assets/tsodinCup.c ./assets/tsodinCup.png &
-    ./build/tools/obj2c -o ./build/assets/tsodinCupLowPoly.c ./assets/tsodinCupLowPoly.obj &
-    ./build/tools/obj2c -s 0.40 -o ./build/assets/utahTeapot.c ./assets/utahTeapot.obj &
-    wait # TODO: the whole script must fail if one of the jobs fails
-}
-
-build_tests() {
-    clang $COMMON_CFLAGS -fsanitize=memory -o ./build/test ./test.c -lm
-}
-
-build_tools
-build_assets
-build_tests
-build_all_vc_demos

+ 97 - 0
nobuild.c

@@ -0,0 +1,97 @@
+#define NOBUILD_IMPLEMENTATION
+#include "./nobuild.h"
+
+#define COMMON_CFLAGS "-Wall", "-Wextra", "-pedantic", "-std=c99", "-ggdb", "-I.", "-I./build/", "-I./dev-deps/"
+
+// TODO: implement parallel building
+
+void build_tools(void)
+{
+    MKDIRS("build", "tools");
+    CMD("clang", COMMON_CFLAGS, "-o", "./build/tools/png2c", "./tools/png2c.c", "-lm");
+    CMD("clang", COMMON_CFLAGS, "-o", "./build/tools/obj2c", "./tools/obj2c.c", "-lm");
+}
+
+void build_assets(void)
+{
+    MKDIRS("build", "assets");
+    CMD("./build/tools/png2c", "-n", "tsodinPog", "-o", "./build/assets/tsodinPog.c", "./assets/tsodinPog.png");
+    CMD("./build/tools/png2c", "-n", "tsodinCup", "-o", "./build/assets/tsodinCup.c", "./assets/tsodinCup.png");
+    CMD("./build/tools/obj2c", "-o", "./build/assets/tsodinCupLowPoly.c", "./assets/tsodinCupLowPoly.obj");
+    CMD("./build/tools/obj2c", "-s", "0.40", "-o", "./build/assets/utahTeapot.c", "./assets/utahTeapot.obj");
+}
+
+void build_tests(void)
+{
+    CMD("clang", COMMON_CFLAGS, "-fsanitize=memory", "-o", "./build/test", "test.c", "-lm");
+}
+
+void build_wasm_demo(const char *name)
+{
+    CMD("clang", COMMON_CFLAGS, "-O2", "-fno-builtin", "--target=wasm32", "--no-standard-libraries", "-Wl,--no-entry", "-Wl,--export=vc_render", "-Wl,--export=__heap_base", "-Wl,--allow-undefined", "-o", CONCAT("./build/demos/", name, ".wasm"), "-DVC_PLATFORM=VC_WASM_PLATFORM", CONCAT("./demos/", name, ".c"));
+    // TODO: implement copying wasm blobs into ./wasm/ folder
+    // cp ./build/demos/$NAME.wasm ./wasm/
+}
+
+void build_term_demo(const char *name)
+{
+    CMD("clang", COMMON_CFLAGS, "-O2", "-o", CONCAT("./build/demos/", name, ".term"), "-DVC_PLATFORM=VC_TERM_PLATFORM", "-D_XOPEN_SOURCE=600", CONCAT("./demos/", name, ".c"), "-lm");
+}
+
+void build_sdl_demo(const char *name)
+{
+    CMD("clang", COMMON_CFLAGS, "-O2", "-o", CONCAT("./build/demos/", name, ".sdl"), "-DVC_PLATFORM=VC_SDL_PLATFORM", CONCAT("./demos/", name, ".c"), "-lm", "-lSDL2");
+}
+
+void build_vc_demo(const char *name)
+{
+    build_wasm_demo(name);
+    build_term_demo(name);
+    build_sdl_demo(name);
+}
+
+void build_all_vc_demos(void)
+{
+    MKDIRS("build", "demos");
+    build_vc_demo("triangle");
+    build_vc_demo("dots3d");
+    build_vc_demo("squish");
+    build_vc_demo("triangle3d");
+    build_vc_demo("triangleTex");
+    build_vc_demo("triangle3dTex");
+    build_vc_demo("cup3d");
+    build_vc_demo("teapot3d");
+}
+
+int main(int argc, char **argv)
+{
+    GO_REBUILD_URSELF(argc, argv);
+
+    const char *program = shift_args(&argc, &argv);
+
+    if (argc > 0) {
+        const char *subcmd = shift_args(&argc, &argv);
+        if (strcmp(subcmd, "tools") == 0) {
+            // TODO: rebuild specific tool
+            build_tools();
+        } else if (strcmp(subcmd, "assets") == 0) {
+            // TODO: rebuild specific assets
+            build_assets();
+        } else if (strcmp(subcmd, "tests") == 0) {
+            // TODO: pass arguments to the ./build/test command
+            build_tests();
+        } else if (strcmp(subcmd, "demos") == 0) {
+            // TODO: build specific demos for the specific platforms
+            build_all_vc_demos();
+        } else {
+            PANIC("Unknown command `%s`", subcmd);
+        }
+    } else {
+        build_tools();
+        build_assets();
+        build_tests();
+        build_all_vc_demos();
+    } 
+
+    return 0;
+}

+ 1151 - 0
nobuild.h

@@ -0,0 +1,1151 @@
+#ifndef NOBUILD_H_
+#define NOBUILD_H_
+
+#ifndef _WIN32
+#    define _POSIX_C_SOURCE 200809L
+#    include <sys/types.h>
+#    include <sys/wait.h>
+#    include <sys/stat.h>
+#    include <unistd.h>
+#    include <dirent.h>
+#    include <fcntl.h>
+#    define PATH_SEP "/"
+typedef pid_t Pid;
+typedef int Fd;
+#else
+#    define WIN32_MEAN_AND_LEAN
+#    include "windows.h"
+#    include <process.h>
+#    define PATH_SEP "\\"
+typedef HANDLE Pid;
+typedef HANDLE Fd;
+// minirent.h HEADER BEGIN ////////////////////////////////////////
+// Copyright 2021 Alexey Kutepov <[email protected]>
+//
+// 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.
+//
+// ============================================================
+//
+// minirent — 0.0.1 — A subset of dirent interface for Windows.
+//
+// https://github.com/tsoding/minirent
+//
+// ============================================================
+//
+// ChangeLog (https://semver.org/ is implied)
+//
+//    0.0.1 First Official Release
+
+#ifndef MINIRENT_H_
+#define MINIRENT_H_
+
+#define WIN32_LEAN_AND_MEAN
+#include "windows.h"
+
+struct dirent {
+    char d_name[MAX_PATH+1];
+};
+
+typedef struct DIR DIR;
+
+DIR *opendir(const char *dirpath);
+struct dirent *readdir(DIR *dirp);
+int closedir(DIR *dirp);
+
+#endif  // MINIRENT_H_
+// minirent.h HEADER END ////////////////////////////////////////
+
+// TODO(#28): use GetLastErrorAsString everywhere on Windows error reporting
+LPSTR GetLastErrorAsString(void);
+
+#endif  // _WIN32
+
+#include <assert.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdarg.h>
+#include <string.h>
+#include <errno.h>
+
+#define FOREACH_ARRAY(type, elem, array, body)  \
+    for (size_t elem_##index = 0;                           \
+         elem_##index < array.count;                        \
+         ++elem_##index)                                    \
+    {                                                       \
+        type *elem = &array.elems[elem_##index];            \
+        body;                                               \
+    }
+
+typedef const char * Cstr;
+
+int cstr_ends_with(Cstr cstr, Cstr postfix);
+#define ENDS_WITH(cstr, postfix) cstr_ends_with(cstr, postfix)
+
+Cstr cstr_no_ext(Cstr path);
+#define NOEXT(path) cstr_no_ext(path)
+
+typedef struct {
+    Cstr *elems;
+    size_t count;
+} Cstr_Array;
+
+Cstr_Array cstr_array_make(Cstr first, ...);
+Cstr_Array cstr_array_append(Cstr_Array cstrs, Cstr cstr);
+Cstr cstr_array_join(Cstr sep, Cstr_Array cstrs);
+
+#define JOIN(sep, ...) cstr_array_join(sep, cstr_array_make(__VA_ARGS__, NULL))
+#define CONCAT(...) JOIN("", __VA_ARGS__)
+#define PATH(...) JOIN(PATH_SEP, __VA_ARGS__)
+
+typedef struct {
+    Fd read;
+    Fd write;
+} Pipe;
+
+Pipe pipe_make(void);
+
+typedef struct {
+    Cstr_Array line;
+} Cmd;
+
+Fd fd_open_for_read(Cstr path);
+Fd fd_open_for_write(Cstr path);
+void fd_close(Fd fd);
+void pid_wait(Pid pid);
+Cstr cmd_show(Cmd cmd);
+Pid cmd_run_async(Cmd cmd, Fd *fdin, Fd *fdout);
+void cmd_run_sync(Cmd cmd);
+
+typedef struct {
+    Cmd *elems;
+    size_t count;
+} Cmd_Array;
+
+// TODO(#1): no way to disable echo in nobuild scripts
+// TODO(#2): no way to ignore fails
+#define CMD(...)                                        \
+    do {                                                \
+        Cmd cmd = {                                     \
+            .line = cstr_array_make(__VA_ARGS__, NULL)  \
+        };                                              \
+        INFO("CMD: %s", cmd_show(cmd));                 \
+        cmd_run_sync(cmd);                              \
+    } while (0)
+
+typedef enum {
+    CHAIN_TOKEN_END = 0,
+    CHAIN_TOKEN_IN,
+    CHAIN_TOKEN_OUT,
+    CHAIN_TOKEN_CMD
+} Chain_Token_Type;
+
+// A single token for the CHAIN(...) DSL syntax
+typedef struct {
+    Chain_Token_Type type;
+    Cstr_Array args;
+} Chain_Token;
+
+// TODO(#17): IN and OUT are already taken by WinAPI
+#define IN(path) \
+    (Chain_Token) { \
+        .type = CHAIN_TOKEN_IN, \
+        .args = cstr_array_make(path, NULL) \
+    }
+
+#define OUT(path) \
+    (Chain_Token) { \
+        .type = CHAIN_TOKEN_OUT, \
+        .args = cstr_array_make(path, NULL) \
+    }
+
+#define CHAIN_CMD(...) \
+    (Chain_Token) { \
+        .type = CHAIN_TOKEN_CMD, \
+        .args = cstr_array_make(__VA_ARGS__, NULL) \
+    }
+
+// TODO(#20): pipes do not allow redirecting stderr
+typedef struct {
+    Cstr input_filepath;
+    Cmd_Array cmds;
+    Cstr output_filepath;
+} Chain;
+
+Chain chain_build_from_tokens(Chain_Token first, ...);
+void chain_run_sync(Chain chain);
+void chain_echo(Chain chain);
+
+// TODO(#15): PIPE does not report where exactly a syntactic error has happened
+#define CHAIN(...)                                                      \
+    do {                                                                \
+        Chain chain = chain_build_from_tokens(__VA_ARGS__, (Chain_Token) {0}); \
+        chain_echo(chain);                                              \
+        chain_run_sync(chain);                                          \
+    } while(0)
+
+#ifndef REBUILD_URSELF
+#  if _WIN32
+#    if defined(__GNUC__)
+#       define REBUILD_URSELF(binary_path, source_path) CMD("gcc", "-o", binary_path, source_path)
+#    elif defined(__clang__)
+#       define REBUILD_URSELF(binary_path, source_path) CMD("clang", "-o", binary_path, source_path)
+#    elif defined(_MSC_VER)
+#       define REBUILD_URSELF(binary_path, source_path) CMD("cl.exe", source_path)
+#    endif
+#  else
+#    define REBUILD_URSELF(binary_path, source_path) CMD("cc", "-o", binary_path, source_path)
+#  endif
+#endif
+
+// Go Rebuild Urself™ Technology
+//
+//   How to use it:
+//     int main(int argc, char** argv) {
+//         GO_REBUILD_URSELF(argc, argv);
+//         // actual work
+//         return 0;
+//     }
+//
+//   After your added this macro every time you run ./nobuild it will detect
+//   that you modified its original source code and will try to rebuild itself
+//   before doing any actual work. So you only need to bootstrap your build system
+//   once.
+//
+//   The modification is detected by comparing the last modified times of the executable
+//   and its source code. The same way the make utility usually does it.
+//
+//   The rebuilding is done by using the REBUILD_URSELF macro which you can redefine
+//   if you need a special way of bootstraping your build system. (which I personally
+//   do not recommend since the whole idea of nobuild is to keep the process of bootstrapping
+//   as simple as possible and doing all of the actual work inside of the nobuild)
+//
+#define GO_REBUILD_URSELF(argc, argv)                                  \
+    do {                                                               \
+        const char *source_path = __FILE__;                            \
+        assert(argc >= 1);                                             \
+        const char *binary_path = argv[0];                             \
+                                                                       \
+        if (is_path1_modified_after_path2(source_path, binary_path)) { \
+            RENAME(binary_path, CONCAT(binary_path, ".old"));          \
+            REBUILD_URSELF(binary_path, source_path);                  \
+            Cmd cmd = {                                                \
+                .line = {                                              \
+                    .elems = (Cstr*) argv,                             \
+                    .count = argc,                                     \
+                },                                                     \
+            };                                                         \
+            INFO("CMD: %s", cmd_show(cmd));                            \
+            cmd_run_sync(cmd);                                         \
+            exit(0);                                                   \
+        }                                                              \
+    } while(0)
+// The implementation idea is stolen from https://github.com/zhiayang/nabs
+
+void rebuild_urself(const char *binary_path, const char *source_path);
+
+int path_is_dir(Cstr path);
+#define IS_DIR(path) path_is_dir(path)
+
+int path_exists(Cstr path);
+#define PATH_EXISTS(path) path_exists(path)
+
+void path_mkdirs(Cstr_Array path);
+#define MKDIRS(...)                                             \
+    do {                                                        \
+        Cstr_Array path = cstr_array_make(__VA_ARGS__, NULL);   \
+        INFO("MKDIRS: %s", cstr_array_join(PATH_SEP, path));    \
+        path_mkdirs(path);                                      \
+    } while (0)
+
+void path_rename(Cstr old_path, Cstr new_path);
+#define RENAME(old_path, new_path)                    \
+    do {                                              \
+        INFO("RENAME: %s -> %s", old_path, new_path); \
+        path_rename(old_path, new_path);              \
+    } while (0)
+
+void path_rm(Cstr path);
+#define RM(path)                                \
+    do {                                        \
+        INFO("RM: %s", path);                   \
+        path_rm(path);                          \
+    } while(0)
+
+#define FOREACH_FILE_IN_DIR(file, dirpath, body)        \
+    do {                                                \
+        struct dirent *dp = NULL;                       \
+        DIR *dir = opendir(dirpath);                    \
+        if (dir == NULL) {                              \
+            PANIC("could not open directory %s: %s",    \
+                  dirpath, strerror(errno));            \
+        }                                               \
+        errno = 0;                                      \
+        while ((dp = readdir(dir))) {                   \
+            const char *file = dp->d_name;              \
+            body;                                       \
+        }                                               \
+                                                        \
+        if (errno > 0) {                                \
+            PANIC("could not read directory %s: %s",    \
+                  dirpath, strerror(errno));            \
+        }                                               \
+                                                        \
+        closedir(dir);                                  \
+    } while(0)
+
+#if defined(__GNUC__) || defined(__clang__)
+// https://gcc.gnu.org/onlinedocs/gcc-4.7.2/gcc/Function-Attributes.html
+#define NOBUILD_PRINTF_FORMAT(STRING_INDEX, FIRST_TO_CHECK) __attribute__ ((format (printf, STRING_INDEX, FIRST_TO_CHECK)))
+#else
+#define NOBUILD_PRINTF_FORMAT(STRING_INDEX, FIRST_TO_CHECK)
+#endif
+
+void VLOG(FILE *stream, Cstr tag, Cstr fmt, va_list args);
+void INFO(Cstr fmt, ...) NOBUILD_PRINTF_FORMAT(1, 2);
+void WARN(Cstr fmt, ...) NOBUILD_PRINTF_FORMAT(1, 2);
+void ERRO(Cstr fmt, ...) NOBUILD_PRINTF_FORMAT(1, 2);
+void PANIC(Cstr fmt, ...) NOBUILD_PRINTF_FORMAT(1, 2);
+
+char *shift_args(int *argc, char ***argv);
+
+#endif  // NOBUILD_H_
+
+////////////////////////////////////////////////////////////////////////////////
+
+#ifdef NOBUILD_IMPLEMENTATION
+
+#ifdef _WIN32
+LPSTR GetLastErrorAsString(void)
+{
+    // https://stackoverflow.com/questions/1387064/how-to-get-the-error-message-from-the-error-code-returned-by-getlasterror
+
+    DWORD errorMessageId = GetLastError();
+    assert(errorMessageId != 0);
+
+    LPSTR messageBuffer = NULL;
+
+    DWORD size =
+        FormatMessage(
+            FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, // DWORD   dwFlags,
+            NULL, // LPCVOID lpSource,
+            errorMessageId, // DWORD   dwMessageId,
+            MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), // DWORD   dwLanguageId,
+            (LPSTR) &messageBuffer, // LPTSTR  lpBuffer,
+            0, // DWORD   nSize,
+            NULL // va_list *Arguments
+        );
+
+    return messageBuffer;
+}
+
+// minirent.h IMPLEMENTATION BEGIN ////////////////////////////////////////
+struct DIR {
+    HANDLE hFind;
+    WIN32_FIND_DATA data;
+    struct dirent *dirent;
+};
+
+DIR *opendir(const char *dirpath)
+{
+    assert(dirpath);
+
+    char buffer[MAX_PATH];
+    snprintf(buffer, MAX_PATH, "%s\\*", dirpath);
+
+    DIR *dir = (DIR*)calloc(1, sizeof(DIR));
+
+    dir->hFind = FindFirstFile(buffer, &dir->data);
+    if (dir->hFind == INVALID_HANDLE_VALUE) {
+        errno = ENOSYS;
+        goto fail;
+    }
+
+    return dir;
+
+fail:
+    if (dir) {
+        free(dir);
+    }
+
+    return NULL;
+}
+
+struct dirent *readdir(DIR *dirp)
+{
+    assert(dirp);
+
+    if (dirp->dirent == NULL) {
+        dirp->dirent = (struct dirent*)calloc(1, sizeof(struct dirent));
+    } else {
+        if(!FindNextFile(dirp->hFind, &dirp->data)) {
+            if (GetLastError() != ERROR_NO_MORE_FILES) {
+                errno = ENOSYS;
+            }
+
+            return NULL;
+        }
+    }
+
+    memset(dirp->dirent->d_name, 0, sizeof(dirp->dirent->d_name));
+
+    strncpy(
+        dirp->dirent->d_name,
+        dirp->data.cFileName,
+        sizeof(dirp->dirent->d_name) - 1);
+
+    return dirp->dirent;
+}
+
+int closedir(DIR *dirp)
+{
+    assert(dirp);
+
+    if(!FindClose(dirp->hFind)) {
+        errno = ENOSYS;
+        return -1;
+    }
+
+    if (dirp->dirent) {
+        free(dirp->dirent);
+    }
+    free(dirp);
+
+    return 0;
+}
+// minirent.h IMPLEMENTATION END ////////////////////////////////////////
+#endif // _WIN32
+
+Cstr_Array cstr_array_append(Cstr_Array cstrs, Cstr cstr)
+{
+    Cstr_Array result = {
+        .count = cstrs.count + 1
+    };
+    result.elems = malloc(sizeof(result.elems[0]) * result.count);
+    memcpy(result.elems, cstrs.elems, cstrs.count * sizeof(result.elems[0]));
+    result.elems[cstrs.count] = cstr;
+    return result;
+}
+
+int cstr_ends_with(Cstr cstr, Cstr postfix)
+{
+    const size_t cstr_len = strlen(cstr);
+    const size_t postfix_len = strlen(postfix);
+    return postfix_len <= cstr_len
+           && strcmp(cstr + cstr_len - postfix_len, postfix) == 0;
+}
+
+Cstr cstr_no_ext(Cstr path)
+{
+    size_t n = strlen(path);
+    while (n > 0 && path[n - 1] != '.') {
+        n -= 1;
+    }
+
+    if (n > 0) {
+        char *result = malloc(n);
+        memcpy(result, path, n);
+        result[n - 1] = '\0';
+
+        return result;
+    } else {
+        return path;
+    }
+}
+
+Cstr_Array cstr_array_make(Cstr first, ...)
+{
+    Cstr_Array result = {0};
+
+    if (first == NULL) {
+        return result;
+    }
+
+    result.count += 1;
+
+    va_list args;
+    va_start(args, first);
+    for (Cstr next = va_arg(args, Cstr);
+            next != NULL;
+            next = va_arg(args, Cstr)) {
+        result.count += 1;
+    }
+    va_end(args);
+
+    result.elems = malloc(sizeof(result.elems[0]) * result.count);
+    if (result.elems == NULL) {
+        PANIC("could not allocate memory: %s", strerror(errno));
+    }
+    result.count = 0;
+
+    result.elems[result.count++] = first;
+
+    va_start(args, first);
+    for (Cstr next = va_arg(args, Cstr);
+            next != NULL;
+            next = va_arg(args, Cstr)) {
+        result.elems[result.count++] = next;
+    }
+    va_end(args);
+
+    return result;
+}
+
+Cstr cstr_array_join(Cstr sep, Cstr_Array cstrs)
+{
+    if (cstrs.count == 0) {
+        return "";
+    }
+
+    const size_t sep_len = strlen(sep);
+    size_t len = 0;
+    for (size_t i = 0; i < cstrs.count; ++i) {
+        len += strlen(cstrs.elems[i]);
+    }
+
+    const size_t result_len = (cstrs.count - 1) * sep_len + len + 1;
+    char *result = malloc(sizeof(char) * result_len);
+    if (result == NULL) {
+        PANIC("could not allocate memory: %s", strerror(errno));
+    }
+
+    len = 0;
+    for (size_t i = 0; i < cstrs.count; ++i) {
+        if (i > 0) {
+            memcpy(result + len, sep, sep_len);
+            len += sep_len;
+        }
+
+        size_t elem_len = strlen(cstrs.elems[i]);
+        memcpy(result + len, cstrs.elems[i], elem_len);
+        len += elem_len;
+    }
+    result[len] = '\0';
+
+    return result;
+}
+
+Pipe pipe_make(void)
+{
+    Pipe pip = {0};
+
+#ifdef _WIN32
+    // https://docs.microsoft.com/en-us/windows/win32/ProcThread/creating-a-child-process-with-redirected-input-and-output
+
+    SECURITY_ATTRIBUTES saAttr = {0};
+    saAttr.nLength = sizeof(SECURITY_ATTRIBUTES);
+    saAttr.bInheritHandle = TRUE;
+
+    if (!CreatePipe(&pip.read, &pip.write, &saAttr, 0)) {
+        PANIC("Could not create pipe: %s", GetLastErrorAsString());
+    }
+#else
+    Fd pipefd[2];
+    if (pipe(pipefd) < 0) {
+        PANIC("Could not create pipe: %s", strerror(errno));
+    }
+
+    pip.read = pipefd[0];
+    pip.write = pipefd[1];
+#endif // _WIN32
+
+    return pip;
+}
+
+Fd fd_open_for_read(Cstr path)
+{
+#ifndef _WIN32
+    Fd result = open(path, O_RDONLY);
+    if (result < 0) {
+        PANIC("Could not open file %s: %s", path, strerror(errno));
+    }
+    return result;
+#else
+    // https://docs.microsoft.com/en-us/windows/win32/fileio/opening-a-file-for-reading-or-writing
+    SECURITY_ATTRIBUTES saAttr = {0};
+    saAttr.nLength = sizeof(SECURITY_ATTRIBUTES);
+    saAttr.bInheritHandle = TRUE;
+
+    Fd result = CreateFile(
+                    path,
+                    GENERIC_READ,
+                    0,
+                    &saAttr,
+                    OPEN_EXISTING,
+                    FILE_ATTRIBUTE_READONLY,
+                    NULL);
+
+    if (result == INVALID_HANDLE_VALUE) {
+        PANIC("Could not open file %s", path);
+    }
+
+    return result;
+#endif // _WIN32
+}
+
+Fd fd_open_for_write(Cstr path)
+{
+#ifndef _WIN32
+    Fd result = open(path,
+                     O_WRONLY | O_CREAT | O_TRUNC,
+                     S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
+    if (result < 0) {
+        PANIC("could not open file %s: %s", path, strerror(errno));
+    }
+    return result;
+#else
+    SECURITY_ATTRIBUTES saAttr = {0};
+    saAttr.nLength = sizeof(SECURITY_ATTRIBUTES);
+    saAttr.bInheritHandle = TRUE;
+
+    Fd result = CreateFile(
+                    path,                  // name of the write
+                    GENERIC_WRITE,         // open for writing
+                    0,                     // do not share
+                    &saAttr,               // default security
+                    CREATE_NEW,            // create new file only
+                    FILE_ATTRIBUTE_NORMAL, // normal file
+                    NULL                   // no attr. template
+                );
+
+    if (result == INVALID_HANDLE_VALUE) {
+        PANIC("Could not open file %s: %s", path, GetLastErrorAsString());
+    }
+
+    return result;
+#endif // _WIN32
+}
+
+void fd_close(Fd fd)
+{
+#ifdef _WIN32
+    CloseHandle(fd);
+#else
+    close(fd);
+#endif // _WIN32
+}
+
+void pid_wait(Pid pid)
+{
+#ifdef _WIN32
+    DWORD result = WaitForSingleObject(
+                       pid,     // HANDLE hHandle,
+                       INFINITE // DWORD  dwMilliseconds
+                   );
+
+    if (result == WAIT_FAILED) {
+        PANIC("could not wait on child process: %s", GetLastErrorAsString());
+    }
+
+    DWORD exit_status;
+    if (GetExitCodeProcess(pid, &exit_status) == 0) {
+        PANIC("could not get process exit code: %lu", GetLastError());
+    }
+
+    if (exit_status != 0) {
+        PANIC("command exited with exit code %lu", exit_status);
+    }
+
+    CloseHandle(pid);
+#else
+    for (;;) {
+        int wstatus = 0;
+        if (waitpid(pid, &wstatus, 0) < 0) {
+            PANIC("could not wait on command (pid %d): %s", pid, strerror(errno));
+        }
+
+        if (WIFEXITED(wstatus)) {
+            int exit_status = WEXITSTATUS(wstatus);
+            if (exit_status != 0) {
+                PANIC("command exited with exit code %d", exit_status);
+            }
+
+            break;
+        }
+
+        if (WIFSIGNALED(wstatus)) {
+            PANIC("command process was terminated by %s", strsignal(WTERMSIG(wstatus)));
+        }
+    }
+
+#endif // _WIN32
+}
+
+Cstr cmd_show(Cmd cmd)
+{
+    // TODO(#31): cmd_show does not render the command line properly
+    // - No string literals when arguments contains space
+    // - No escaping of special characters
+    // - Etc.
+    return cstr_array_join(" ", cmd.line);
+}
+
+Pid cmd_run_async(Cmd cmd, Fd *fdin, Fd *fdout)
+{
+#ifdef _WIN32
+    // https://docs.microsoft.com/en-us/windows/win32/procthread/creating-a-child-process-with-redirected-input-and-output
+
+    STARTUPINFO siStartInfo;
+    ZeroMemory(&siStartInfo, sizeof(siStartInfo));
+    siStartInfo.cb = sizeof(STARTUPINFO);
+    // NOTE: theoretically setting NULL to std handles should not be a problem
+    // https://docs.microsoft.com/en-us/windows/console/getstdhandle?redirectedfrom=MSDN#attachdetach-behavior
+    siStartInfo.hStdError = GetStdHandle(STD_ERROR_HANDLE);
+    // TODO(#32): check for errors in GetStdHandle
+    siStartInfo.hStdOutput = fdout ? *fdout : GetStdHandle(STD_OUTPUT_HANDLE);
+    siStartInfo.hStdInput = fdin ? *fdin : GetStdHandle(STD_INPUT_HANDLE);
+    siStartInfo.dwFlags |= STARTF_USESTDHANDLES;
+
+    PROCESS_INFORMATION piProcInfo;
+    ZeroMemory(&piProcInfo, sizeof(PROCESS_INFORMATION));
+
+    BOOL bSuccess =
+        CreateProcess(
+            NULL,
+            // TODO(#33): cmd_run_async on Windows does not render command line properly
+            // It may require wrapping some arguments with double-quotes if they contains spaces, etc.
+            cstr_array_join(" ", cmd.line),
+            NULL,
+            NULL,
+            TRUE,
+            0,
+            NULL,
+            NULL,
+            &siStartInfo,
+            &piProcInfo
+        );
+
+    if (!bSuccess) {
+        PANIC("Could not create child process %s: %s\n",
+              cmd_show(cmd), GetLastErrorAsString());
+    }
+
+    CloseHandle(piProcInfo.hThread);
+
+    return piProcInfo.hProcess;
+#else
+    pid_t cpid = fork();
+    if (cpid < 0) {
+        PANIC("Could not fork child process: %s: %s",
+              cmd_show(cmd), strerror(errno));
+    }
+
+    if (cpid == 0) {
+        Cstr_Array args = cstr_array_append(cmd.line, NULL);
+
+        if (fdin) {
+            if (dup2(*fdin, STDIN_FILENO) < 0) {
+                PANIC("Could not setup stdin for child process: %s", strerror(errno));
+            }
+        }
+
+        if (fdout) {
+            if (dup2(*fdout, STDOUT_FILENO) < 0) {
+                PANIC("Could not setup stdout for child process: %s", strerror(errno));
+            }
+        }
+
+        if (execvp(args.elems[0], (char * const*) args.elems) < 0) {
+            PANIC("Could not exec child process: %s: %s",
+                  cmd_show(cmd), strerror(errno));
+        }
+    }
+
+    return cpid;
+#endif // _WIN32
+}
+
+void cmd_run_sync(Cmd cmd)
+{
+    pid_wait(cmd_run_async(cmd, NULL, NULL));
+}
+
+static void chain_set_input_output_files_or_count_cmds(Chain *chain, Chain_Token token)
+{
+    switch (token.type) {
+    case CHAIN_TOKEN_CMD: {
+        chain->cmds.count += 1;
+    }
+    break;
+
+    case CHAIN_TOKEN_IN: {
+        if (chain->input_filepath) {
+            PANIC("Input file path was already set");
+        }
+
+        chain->input_filepath = token.args.elems[0];
+    }
+    break;
+
+    case CHAIN_TOKEN_OUT: {
+        if (chain->output_filepath) {
+            PANIC("Output file path was already set");
+        }
+
+        chain->output_filepath = token.args.elems[0];
+    }
+    break;
+
+    case CHAIN_TOKEN_END:
+    default: {
+        assert(0 && "unreachable");
+        exit(1);
+    }
+    }
+}
+
+static void chain_push_cmd(Chain *chain, Chain_Token token)
+{
+    if (token.type == CHAIN_TOKEN_CMD) {
+        chain->cmds.elems[chain->cmds.count++] = (Cmd) {
+            .line = token.args
+        };
+    }
+}
+
+Chain chain_build_from_tokens(Chain_Token first, ...)
+{
+    Chain result = {0};
+
+    chain_set_input_output_files_or_count_cmds(&result, first);
+    va_list args;
+    va_start(args, first);
+    Chain_Token next = va_arg(args, Chain_Token);
+    while (next.type != CHAIN_TOKEN_END) {
+        chain_set_input_output_files_or_count_cmds(&result, next);
+        next = va_arg(args, Chain_Token);
+    }
+    va_end(args);
+
+    result.cmds.elems = malloc(sizeof(result.cmds.elems[0]) * result.cmds.count);
+    if (result.cmds.elems == NULL) {
+        PANIC("could not allocate memory: %s", strerror(errno));
+    }
+    result.cmds.count = 0;
+
+    chain_push_cmd(&result, first);
+
+    va_start(args, first);
+    next = va_arg(args, Chain_Token);
+    while (next.type != CHAIN_TOKEN_END) {
+        chain_push_cmd(&result, next);
+        next = va_arg(args, Chain_Token);
+    }
+    va_end(args);
+
+    return result;
+}
+
+void chain_run_sync(Chain chain)
+{
+    if (chain.cmds.count == 0) {
+        return;
+    }
+
+    Pid *cpids = malloc(sizeof(Pid) * chain.cmds.count);
+
+    Pipe pip = {0};
+    Fd fdin = 0;
+    Fd *fdprev = NULL;
+
+    if (chain.input_filepath) {
+        fdin = fd_open_for_read(chain.input_filepath);
+        if (fdin < 0) {
+            PANIC("could not open file %s: %s", chain.input_filepath, strerror(errno));
+        }
+        fdprev = &fdin;
+    }
+
+    for (size_t i = 0; i < chain.cmds.count - 1; ++i) {
+        pip = pipe_make();
+
+        cpids[i] = cmd_run_async(
+                       chain.cmds.elems[i],
+                       fdprev,
+                       &pip.write);
+
+        if (fdprev) fd_close(*fdprev);
+        fd_close(pip.write);
+        fdprev = &fdin;
+        fdin = pip.read;
+    }
+
+    {
+        Fd fdout = 0;
+        Fd *fdnext = NULL;
+
+        if (chain.output_filepath) {
+            fdout = fd_open_for_write(chain.output_filepath);
+            if (fdout < 0) {
+                PANIC("could not open file %s: %s",
+                      chain.output_filepath,
+                      strerror(errno));
+            }
+            fdnext = &fdout;
+        }
+
+        const size_t last = chain.cmds.count - 1;
+        cpids[last] =
+            cmd_run_async(
+                chain.cmds.elems[last],
+                fdprev,
+                fdnext);
+
+        if (fdprev) fd_close(*fdprev);
+        if (fdnext) fd_close(*fdnext);
+    }
+
+    for (size_t i = 0; i < chain.cmds.count; ++i) {
+        pid_wait(cpids[i]);
+    }
+}
+
+void chain_echo(Chain chain)
+{
+    printf("[INFO] CHAIN:");
+    if (chain.input_filepath) {
+        printf(" %s", chain.input_filepath);
+    }
+
+    FOREACH_ARRAY(Cmd, cmd, chain.cmds, {
+        printf(" |> %s", cmd_show(*cmd));
+    });
+
+    if (chain.output_filepath) {
+        printf(" |> %s", chain.output_filepath);
+    }
+
+    printf("\n");
+}
+
+int path_exists(Cstr path)
+{
+#ifdef _WIN32
+    DWORD dwAttrib = GetFileAttributes(path);
+    return (dwAttrib != INVALID_FILE_ATTRIBUTES);
+#else
+    struct stat statbuf = {0};
+    if (stat(path, &statbuf) < 0) {
+        if (errno == ENOENT) {
+            errno = 0;
+            return 0;
+        }
+
+        PANIC("could not retrieve information about file %s: %s",
+              path, strerror(errno));
+    }
+
+    return 1;
+#endif
+}
+
+int path_is_dir(Cstr path)
+{
+#ifdef _WIN32
+    DWORD dwAttrib = GetFileAttributes(path);
+
+    return (dwAttrib != INVALID_FILE_ATTRIBUTES &&
+            (dwAttrib & FILE_ATTRIBUTE_DIRECTORY));
+#else
+    struct stat statbuf = {0};
+    if (stat(path, &statbuf) < 0) {
+        if (errno == ENOENT) {
+            errno = 0;
+            return 0;
+        }
+
+        PANIC("could not retrieve information about file %s: %s",
+              path, strerror(errno));
+    }
+
+    return S_ISDIR(statbuf.st_mode);
+#endif // _WIN32
+}
+
+void path_rename(const char *old_path, const char *new_path)
+{
+#ifdef _WIN32
+    if (!MoveFileEx(old_path, new_path, MOVEFILE_REPLACE_EXISTING)) {
+        PANIC("could not rename %s to %s: %s", old_path, new_path,
+              GetLastErrorAsString());
+    }
+#else
+    if (rename(old_path, new_path) < 0) {
+        PANIC("could not rename %s to %s: %s", old_path, new_path,
+              strerror(errno));
+    }
+#endif // _WIN32
+}
+
+void path_mkdirs(Cstr_Array path)
+{
+    if (path.count == 0) {
+        return;
+    }
+
+    size_t len = 0;
+    for (size_t i = 0; i < path.count; ++i) {
+        len += strlen(path.elems[i]);
+    }
+
+    size_t seps_count = path.count - 1;
+    const size_t sep_len = strlen(PATH_SEP);
+
+    char *result = malloc(len + seps_count * sep_len + 1);
+
+    len = 0;
+    for (size_t i = 0; i < path.count; ++i) {
+        size_t n = strlen(path.elems[i]);
+        memcpy(result + len, path.elems[i], n);
+        len += n;
+
+        if (seps_count > 0) {
+            memcpy(result + len, PATH_SEP, sep_len);
+            len += sep_len;
+            seps_count -= 1;
+        }
+
+        result[len] = '\0';
+
+        if (mkdir(result, 0755) < 0) {
+            if (errno == EEXIST) {
+                errno = 0;
+                WARN("directory %s already exists", result);
+            } else {
+                PANIC("could not create directory %s: %s", result, strerror(errno));
+            }
+        }
+    }
+}
+
+void path_rm(Cstr path)
+{
+    if (IS_DIR(path)) {
+        FOREACH_FILE_IN_DIR(file, path, {
+            if (strcmp(file, ".") != 0 && strcmp(file, "..") != 0)
+            {
+                path_rm(PATH(path, file));
+            }
+        });
+
+        if (rmdir(path) < 0) {
+            if (errno == ENOENT) {
+                errno = 0;
+                WARN("directory %s does not exist", path);
+            } else {
+                PANIC("could not remove directory %s: %s", path, strerror(errno));
+            }
+        }
+    } else {
+        if (unlink(path) < 0) {
+            if (errno == ENOENT) {
+                errno = 0;
+                WARN("file %s does not exist", path);
+            } else {
+                PANIC("could not remove file %s: %s", path, strerror(errno));
+            }
+        }
+    }
+}
+
+int is_path1_modified_after_path2(const char *path1, const char *path2)
+{
+#ifdef _WIN32
+    FILETIME path1_time, path2_time;
+
+    Fd path1_fd = fd_open_for_read(path1);
+    if (!GetFileTime(path1_fd, NULL, NULL, &path1_time)) {
+        PANIC("could not get time of %s: %s", path1, GetLastErrorAsString());
+    }
+    fd_close(path1_fd);
+
+    Fd path2_fd = fd_open_for_read(path2);
+    if (!GetFileTime(path2_fd, NULL, NULL, &path2_time)) {
+        PANIC("could not get time of %s: %s", path2, GetLastErrorAsString());
+    }
+    fd_close(path2_fd);
+
+    return CompareFileTime(&path1_time, &path2_time) == 1;
+#else
+    struct stat statbuf = {0};
+
+    if (stat(path1, &statbuf) < 0) {
+        PANIC("could not stat %s: %s\n", path1, strerror(errno));
+    }
+    int path1_time = statbuf.st_mtime;
+
+    if (stat(path2, &statbuf) < 0) {
+        PANIC("could not stat %s: %s\n", path2, strerror(errno));
+    }
+    int path2_time = statbuf.st_mtime;
+
+    return path1_time > path2_time;
+#endif
+}
+
+void VLOG(FILE *stream, Cstr tag, Cstr fmt, va_list args)
+{
+    fprintf(stream, "[%s] ", tag);
+    vfprintf(stream, fmt, args);
+    fprintf(stream, "\n");
+}
+
+void INFO(Cstr fmt, ...)
+{
+    va_list args;
+    va_start(args, fmt);
+    VLOG(stderr, "INFO", fmt, args);
+    va_end(args);
+}
+
+void WARN(Cstr fmt, ...)
+{
+    va_list args;
+    va_start(args, fmt);
+    VLOG(stderr, "WARN", fmt, args);
+    va_end(args);
+}
+
+void ERRO(Cstr fmt, ...)
+{
+    va_list args;
+    va_start(args, fmt);
+    VLOG(stderr, "ERRO", fmt, args);
+    va_end(args);
+}
+
+void PANIC(Cstr fmt, ...)
+{
+    va_list args;
+    va_start(args, fmt);
+    VLOG(stderr, "ERRO", fmt, args);
+    va_end(args);
+    exit(1);
+}
+
+char *shift_args(int *argc, char ***argv)
+{
+    assert(*argc > 0);
+    char *result = **argv;
+    *argc -= 1;
+    *argv += 1;
+    return result;
+}
+
+#endif // NOBUILD_IMPLEMENTATION