From 7a36b262865a417d5613e6cbd8b0841abaa51c2a Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 20 Apr 2026 11:20:31 +0800 Subject: [PATCH 1/2] feat: help, utf8 supports --- CMakeLists.txt | 15 +- CONTRIBUTING.md | 38 +++- README.en.md | 52 +++--- README.md | 52 +++--- Roadmap.md | 269 +++++++++++++++++++++++++++++ cmake/Config.cmake | 57 ++++++ document/architecture.md | 89 ++++++++-- include/cfbox/applet_config.hpp.in | 23 +++ include/cfbox/applets.hpp | 73 +++++++- include/cfbox/args.hpp | 70 +++++++- include/cfbox/help.hpp | 96 ++++++++++ include/cfbox/term.hpp | 74 ++++++++ include/cfbox/utf8.hpp | 148 ++++++++++++++++ src/applets/cat.cpp | 15 ++ src/applets/cp.cpp | 18 +- src/applets/echo.cpp | 16 ++ src/applets/find.cpp | 22 +++ src/applets/grep.cpp | 36 +++- src/applets/head.cpp | 14 ++ src/applets/init.cpp | 42 ++++- src/applets/ls.cpp | 21 ++- src/applets/mkdir.cpp | 18 +- src/applets/mv.cpp | 13 ++ src/applets/printf.cpp | 14 ++ src/applets/rm.cpp | 20 ++- src/applets/sed.cpp | 14 ++ src/applets/sort.cpp | 23 ++- src/applets/tail.cpp | 14 ++ src/applets/uniq.cpp | 15 ++ src/applets/wc.cpp | 16 ++ src/main.cpp | 5 + tests/integration/test_help.sh | 48 +++++ tests/unit/test_args.cpp | 119 +++++++++++++ tests/unit/test_cp.cpp | 5 + tests/unit/test_find.cpp | 5 + tests/unit/test_grep.cpp | 5 + tests/unit/test_help.cpp | 105 +++++++++++ tests/unit/test_ls.cpp | 5 + tests/unit/test_mkdir.cpp | 5 + tests/unit/test_mv.cpp | 5 + tests/unit/test_rm.cpp | 5 + tests/unit/test_sed.cpp | 5 + tests/unit/test_sort.cpp | 5 + tests/unit/test_term.cpp | 48 +++++ tests/unit/test_uniq.cpp | 5 + tests/unit/test_utf8.cpp | 114 ++++++++++++ 46 files changed, 1777 insertions(+), 99 deletions(-) create mode 100644 Roadmap.md create mode 100644 cmake/Config.cmake create mode 100644 include/cfbox/applet_config.hpp.in create mode 100644 include/cfbox/help.hpp create mode 100644 include/cfbox/term.hpp create mode 100644 include/cfbox/utf8.hpp create mode 100755 tests/integration/test_help.sh create mode 100644 tests/unit/test_help.cpp create mode 100644 tests/unit/test_term.cpp create mode 100644 tests/unit/test_utf8.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ec6f4c..965c454 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,12 +15,22 @@ include(cmake/third_party/CPM.cmake) # ── Compiler flags ──────────────────────────────────────────── include(cmake/compile/CompilerFlag.cmake) -# ── Applet sources (shared between main exe and tests) ──────── -file(GLOB_RECURSE CFBOX_APPLET_SOURCES CONFIGURE_DEPENDS src/applets/*.cpp) +# ── Applet configuration ───────────────────────────────────── +include(cmake/Config.cmake) + +# ── Applet sources (conditional on config) ─────────────────── +set(CFBOX_APPLET_SOURCES) +foreach(applet IN LISTS CFBOX_APPLETS) + string(TOUPPER "${applet}" APPLET_UPPER) + if(CFBOX_ENABLE_${APPLET_UPPER}) + list(APPEND CFBOX_APPLET_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/applets/${applet}.cpp) + endif() +endforeach() # ── Main executable ─────────────────────────────────────────── add_executable(cfbox src/main.cpp ${CFBOX_APPLET_SOURCES}) target_include_directories(cfbox PUBLIC include) +target_include_directories(cfbox PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/include) target_link_libraries(cfbox PRIVATE cfbox_compiler_flags) # ── GTest via CPM (FetchContent) ────────────────────────────── @@ -39,6 +49,7 @@ if(GTest_ADDED) if(CFBOX_TEST_SOURCES) add_executable(cfbox_tests ${CFBOX_TEST_SOURCES} ${CFBOX_APPLET_SOURCES}) target_include_directories(cfbox_tests PUBLIC include) + target_include_directories(cfbox_tests PUBLIC ${CMAKE_CURRENT_BINARY_DIR}/include) target_link_libraries(cfbox_tests PRIVATE cfbox_compiler_flags GTest::gtest_main diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f46574b..da7a87a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,10 +31,10 @@ Release builds use `-O2` by default and enable LTO. For size-optimized builds, a ## Running Tests ```bash -# Unit tests (108 GTest cases) +# Unit tests (149 GTest cases) ctest --test-dir build --output-on-failure -# Integration tests (16 shell scripts comparing against GNU coreutils) +# Integration tests (17 shell scripts comparing against GNU coreutils) bash tests/integration/run_all.sh ``` @@ -138,12 +138,36 @@ The CI pipeline ([ci.yml](.github/workflows/ci.yml)) runs on every push/PR to `m 1. Create `src/applets/.cpp` with signature `auto _main(int argc, char* argv[]) -> int`. - Add a comment header listing supported flags and known differences from GNU. -2. Declare the function in [applets.hpp](include/cfbox/applets.hpp). -3. Add one entry to `APPLET_REGISTRY` in [applets.hpp](include/cfbox/applets.hpp). -4. Add GTest unit tests in `tests/unit/test_.cpp` (see [test_capture.hpp](tests/unit/test_capture.hpp) for stdout capture and `TempDir` utilities). -5. Add shell integration tests in `tests/integration/test_.sh` following the pattern in existing scripts. + - Add a `constexpr cfbox::help::HelpEntry HELP` constant in the anonymous namespace. + - Handle `--help` / `--version` right after `args::parse()`: + ```cpp + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + ``` +2. Declare the function in [applets.hpp](include/cfbox/applets.hpp), guarded by `#if CFBOX_ENABLE_`. +3. Add one entry to `APPLET_REGISTRY` in [applets.hpp](include/cfbox/applets.hpp), also guarded by `#if CFBOX_ENABLE_`. +4. Add the applet name to the `CFBOX_APPLETS` list in [cmake/Config.cmake](cmake/Config.cmake). +5. Add a `#cmakedefine01 CFBOX_ENABLE_` line to [include/cfbox/applet_config.hpp.in](include/cfbox/applet_config.hpp.in). +6. Add GTest unit tests in `tests/unit/test_.cpp` (see [test_capture.hpp](tests/unit/test_capture.hpp) for stdout capture and `TempDir` utilities). Guard the test file with `#if CFBOX_ENABLE_`. +7. Add shell integration tests in `tests/integration/test_.sh` following the pattern in existing scripts. -> **Note:** The `init` applet is special — it runs as PID 1 in QEMU system-mode tests. Regular applets should not need special PID 1 handling. +> **Note:** The `init` applet is special — it runs as PID 1 in QEMU system-mode tests and uses manual `argv` scanning instead of `args::parse()`. Regular applets should not need special PID 1 handling. + +## Build Configuration + +CFBox supports per-applet configuration via CMake options: + +```bash +# Disable individual applets +cmake -DCFBOX_ENABLE_GREP=OFF -DCFBOX_ENABLE_SED=OFF .. + +# Use preset profiles +cmake -DCFBOX_PROFILE=minimal .. # Only core file operations +cmake -DCFBOX_PROFILE=embedded .. # Everything except optional text processing +cmake -DCFBOX_PROFILE=desktop .. # All applets enabled (default) +``` + +Available profiles: `minimal` (echo, cat, ls, cp, mv, rm, mkdir, grep), `embedded` (all except sort/uniq/sed), `desktop`/`full` (all enabled). ## Submitting Changes diff --git a/README.en.md b/README.en.md index a3817b2..b881a9a 100644 --- a/README.en.md +++ b/README.en.md @@ -8,12 +8,12 @@ A minimalist BusyBox alternative written in modern C++23. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![C++23](https://img.shields.io/badge/C++23-00599C?logo=cplusplus)](https://en.cppreference.com/w/cpp/23) [![CMake](https://img.shields.io/badge/CMake-3.26+-064F8C?logo=cmake)](https://cmake.org/) -[![Tests](https://img.shields.io/badge/Tests-124_passing-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/Tests-149_passing-brightgreen)](tests/) [![Applets](https://img.shields.io/badge/Applets-17-brightgreen)](src/applets/) ## Overview -CFBox is a single-executable Unix utility collection distributed via symbolic links. All development is complete — 17 applets implemented and tested, with a CI pipeline covering native builds, cross-compilation, and QEMU user/system-mode testing across 5 stages. +CFBox is a single-executable Unix utility collection distributed via symbolic links. 17 applets implemented and tested, with a CI pipeline covering native builds, cross-compilation, and QEMU user/system-mode testing across 5 stages. Features configurable CMake builds (per-applet toggles), GNU-style long options, and colored help output. **Design philosophy:** Simplicity first — Modern C++ (`std::expected`) — Embedded-friendly (cross-compilation, static linking) @@ -25,8 +25,8 @@ cmake -B build cmake --build build # Test -ctest --test-dir build --output-on-failure # 108 GTest unit tests -bash tests/integration/run_all.sh # 16 integration test scripts +ctest --test-dir build --output-on-failure # 149 GTest unit tests +bash tests/integration/run_all.sh # 17 integration test scripts # Run via subcommand ./build/cfbox echo "Hello, World!" @@ -42,7 +42,7 @@ echo "Hello, World!" # now calls cfbox via symlink | Applet | Supported Flags / Features | |--------|----------------------------| -| `echo` | `-n` (no trailing newline), `-e` (interpret escape sequences) | +| `echo` | `-n` (no trailing newline), `-e` (interpret escape sequences), all applets support `--help` / `--version` | | `printf` | Format strings (`%s` `%d` `%f` `%c` `%%`), format reuse | | `cat` | `-n` (number lines), `-b` (number non-blank), `-A` (show non-printing), stdin passthrough | | `head` | `-n N` (first N lines), `-c N` (first N bytes), multi-file headers | @@ -57,16 +57,16 @@ echo "Hello, World!" # now calls cfbox via symlink | Applet | Supported Flags / Features | |--------|----------------------------| -| `mkdir` | `-p` (create parents), `-m MODE` (permissions) | -| `rm` | `-r` (recursive), `-f` (force), `-i` (interactive), `/` safety check | -| `cp` | `-r` (recursive), `-p` (preserve permissions), multi-file to directory | +| `mkdir` | `-p`/`--parents` (create parents), `-m`/`--mode MODE` (permissions) | +| `rm` | `-r`/`--recursive` (recursive), `-f`/`--force` (force), `-i` (interactive), `/` safety check | +| `cp` | `-r`/`--recursive` (recursive), `-p`/`--preserve` (preserve permissions), multi-file to directory | | `mv` | `-f` (force overwrite), cross-filesystem fallback (copy + remove) | ### Directory & Search | Applet | Supported Flags / Features | |--------|----------------------------| -| `ls` | `-a` (show hidden), `-l` (long format), `-h` (human-readable sizes) | +| `ls` | `-a`/`--all` (show hidden), `-l`/`--long` (long format), `-h`/`--human-readable` (human-readable sizes) | | `find` | `-name PATTERN` (glob), `-type [f\|d\|l]`, `-maxdepth N`, `-exec CMD {} ;` | ### System @@ -97,22 +97,30 @@ echo "Hello, World!" # now calls cfbox via symlink cfbox/ ├── CMakeLists.txt ├── cmake/ -│ ├── compile/CompilerFlag.cmake # Compiler warnings & optimization flags -│ ├── third_party/CPM.cmake # CPM dependency manager -│ └── toolchain/ # Cross-compilation toolchains +│ ├── Config.cmake # Per-applet configuration (CFBOX_ENABLE_xxx options) +│ ├── compile/CompilerFlag.cmake # Compiler warnings & optimization flags +│ ├── third_party/CPM.cmake # CPM dependency manager +│ └── toolchain/ # Cross-compilation toolchains ├── configs/ -│ └── qemu-virt-aarch64.config # Minimal QEMU aarch64 kernel config -├── document/ # Detailed documentation -├── include/cfbox/ # Public headers +│ └── qemu-virt-aarch64.config # Minimal QEMU aarch64 kernel config +├── document/ # Detailed documentation +├── include/cfbox/ +│ ├── applet_config.hpp.in # CMake-generated config (version + enable flags) +│ ├── applet.hpp / applets.hpp # Registry & dispatch +│ ├── args.hpp # Short + long option argument parser +│ ├── help.hpp # --help / --version help system +│ ├── term.hpp # ANSI colored output (NO_COLOR support) +│ ├── utf8.hpp # Unicode-aware width/count utilities +│ └── ... # error.hpp, io.hpp, fs_util.hpp, escape.hpp ├── src/ -│ ├── main.cpp # Dispatch entry -│ └── applets/ # 17 command implementations +│ ├── main.cpp # Dispatch entry +│ └── applets/ # 17 command implementations ├── tests/ -│ ├── unit/ # GTest unit tests (108 cases) -│ └── integration/ # Shell integration tests (16 scripts) -├── scripts/ # Build, test, install scripts -├── .github/workflows/ci.yml # CI pipeline -└── CONTRIBUTING.md # Contributing guide +│ ├── unit/ # GTest unit tests (149 cases) +│ └── integration/ # Shell integration tests (17 scripts) +├── scripts/ # Build, test, install scripts +├── .github/workflows/ci.yml # CI pipeline +└── CONTRIBUTING.md # Contributing guide ``` ## Contributing diff --git a/README.md b/README.md index 94795f9..a2e0f33 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![C++23](https://img.shields.io/badge/C++23-00599C?logo=cplusplus)](https://en.cppreference.com/w/cpp/23) [![CMake](https://img.shields.io/badge/CMake-3.26+-064F8C?logo=cmake)](https://cmake.org/) -[![Tests](https://img.shields.io/badge/Tests-124_passing-brightgreen)](tests/) +[![Tests](https://img.shields.io/badge/Tests-149_passing-brightgreen)](tests/) [![Applets](https://img.shields.io/badge/Applets-17-brightgreen)](src/applets/) ## 概述 -CFBox 是一个单一可执行文件的 Unix 工具集,通过符号链接分发。全部开发已完成,17 个 applet 已实现并通过测试,CI 流水线覆盖原生构建、交叉编译、QEMU 用户/系统模式 5 种测试场景。 +CFBox 是一个单一可执行文件的 Unix 工具集,通过符号链接分发。17 个 applet 已实现并通过测试,CI 流水线覆盖原生构建、交叉编译、QEMU 用户/系统模式 5 种测试场景。支持 CMake 配置化构建(per-applet 开关)、GNU 风格长选项、彩色帮助输出。 **设计理念:** 简洁优先 — 现代C++(`std::expected`) — 嵌入式友好(交叉编译、静态链接) @@ -25,8 +25,8 @@ cmake -B build cmake --build build # 测试 -ctest --test-dir build --output-on-failure # 108 个 GTest 单元测试 -bash tests/integration/run_all.sh # 16 套集成测试脚本 +ctest --test-dir build --output-on-failure # 149 个 GTest 单元测试 +bash tests/integration/run_all.sh # 17 套集成测试脚本 # 通过子命令运行 ./build/cfbox echo "Hello, World!" @@ -42,7 +42,7 @@ echo "Hello, World!" # 通过符号链接调用 cfbox | 命令 | 支持的标志 / 功能 | |------|-------------------| -| `echo` | `-n`(不换行),`-e`(解释转义序列) | +| `echo` | `-n`(不换行),`-e`(解释转义序列),所有 applet 支持 `--help` / `--version` | | `printf` | 格式字符串(`%s` `%d` `%f` `%c` `%%`),格式重用 | | `cat` | `-n`(显示行号),`-b`(非空行编号),`-A`(显示不可打印字符),stdin 透传 | | `head` | `-n N`(前 N 行),`-c N`(前 N 字节),多文件头部 | @@ -57,16 +57,16 @@ echo "Hello, World!" # 通过符号链接调用 cfbox | 命令 | 支持的标志 / 功能 | |------|-------------------| -| `mkdir` | `-p`(递归创建父目录),`-m MODE`(设置权限) | -| `rm` | `-r`(递归删除),`-f`(强制),`-i`(交互确认),`/` 安全检查 | -| `cp` | `-r`(递归复制),`-p`(保留权限),多文件到目录 | +| `mkdir` | `-p`/`--parents`(递归创建父目录),`-m`/`--mode MODE`(设置权限) | +| `rm` | `-r`/`--recursive`(递归删除),`-f`/`--force`(强制),`-i`(交互确认),`/` 安全检查 | +| `cp` | `-r`/`--recursive`(递归复制),`-p`/`--preserve`(保留权限),多文件到目录 | | `mv` | `-f`(强制覆盖),跨文件系统回退(复制 + 删除) | ### 目录与搜索 | 命令 | 支持的标志 / 功能 | |------|-------------------| -| `ls` | `-a`(显示隐藏文件),`-l`(长格式),`-h`(人类可读大小) | +| `ls` | `-a`/`--all`(显示隐藏文件),`-l`/`--long`(长格式),`-h`/`--human-readable`(人类可读大小) | | `find` | `-name 模式`(glob 匹配),`-type [f\|d\|l]`,`-maxdepth N`,`-exec 命令 {} ;` | ### 系统 @@ -97,22 +97,30 @@ echo "Hello, World!" # 通过符号链接调用 cfbox cfbox/ ├── CMakeLists.txt ├── cmake/ -│ ├── compile/CompilerFlag.cmake # 编译器警告与优化标志 -│ ├── third_party/CPM.cmake # CPM 依赖管理 -│ └── toolchain/ # 交叉编译工具链 +│ ├── Config.cmake # Per-applet 配置(CFBOX_ENABLE_xxx 选项) +│ ├── compile/CompilerFlag.cmake # 编译器警告与优化标志 +│ ├── third_party/CPM.cmake # CPM 依赖管理 +│ └── toolchain/ # 交叉编译工具链 ├── configs/ -│ └── qemu-virt-aarch64.config # QEMU aarch64 最小内核配置 -├── document/ # 详细文档 -├── include/cfbox/ # 公共头文件 +│ └── qemu-virt-aarch64.config # QEMU aarch64 最小内核配置 +├── document/ # 详细文档 +├── include/cfbox/ +│ ├── applet_config.hpp.in # CMake 生成的配置(版本号 + 启用开关) +│ ├── applet.hpp / applets.hpp # 注册表与分发 +│ ├── args.hpp # 短选项 + 长选项参数解析器 +│ ├── help.hpp # --help / --version 帮助系统 +│ ├── term.hpp # ANSI 彩色输出(NO_COLOR 支持) +│ ├── utf8.hpp # Unicode 感知的宽度/计数工具 +│ └── ... # error.hpp, io.hpp, fs_util.hpp, escape.hpp ├── src/ -│ ├── main.cpp # 分发入口 -│ └── applets/ # 17 个命令实现 +│ ├── main.cpp # 分发入口 +│ └── applets/ # 17 个命令实现 ├── tests/ -│ ├── unit/ # GTest 单元测试(108 个用例) -│ └── integration/ # Shell 集成测试(16 个脚本) -├── scripts/ # 构建、测试、安装脚本 -├── .github/workflows/ci.yml # CI 流水线 -└── CONTRIBUTING.md # 贡献指南 +│ ├── unit/ # GTest 单元测试(149 个用例) +│ └── integration/ # Shell 集成测试(17 个脚本) +├── scripts/ # 构建、测试、安装脚本 +├── .github/workflows/ci.yml # CI 流水线 +└── CONTRIBUTING.md # 贡献指南 ``` ## 贡献 diff --git a/Roadmap.md b/Roadmap.md new file mode 100644 index 0000000..6ed2bae --- /dev/null +++ b/Roadmap.md @@ -0,0 +1,269 @@ +# CFBox 路线图:从玩具到真正可用的 BusyBox 替代品 + +## Context + +CFBox 是一个 C++23 BusyBox 替代品,当前版本有 17 个 applet(echo, printf, cat, head, tail, wc, sort, uniq, grep, sed, mkdir, rm, cp, mv, ls, find, init)。项目使用注册表分发模式(`APPLET_REGISTRY`)、`std::expected` 错误处理、自定义参数解析器,CI 覆盖原生构建、交叉编译和 QEMU 测试。 + +**目标**:全面对齐 BusyBox,覆盖嵌入式、容器、救援和通用场景。Shell 是最关键的组件,必须最先实现。 + +**差异化**:C++23 现代特性、现代化 UX(彩色输出)、更好的可扩展性、严格 POSIX 兼容。 + +--- + +## 阶段总览 + +| 阶段 | 主题 | 新增 Applet | 核心基础设施 | 累计 | +|------|------|------------|-------------|------| +| 0 | 构建系统现代化 ✅ | 0 | CMake 配置、help 系统、UTF-8、彩色输出 | 17 | +| 1 | POSIX Shell + Coreutils I | ~17 | Shell 引擎、进程管理、信号处理 | ~34 | +| 2 | Coreutils II + findutils | ~41 | 流处理管线、校验和框架 | ~75 | +| 3 | 编辑器 + 归档 + 压缩 | ~15 | 终端抽象、压缩框架 | ~90 | +| 4 | 进程/Init + util-linux | ~38 | /proc 解析器、TUI 框架 | ~128 | +| 5 | 网络 + 登录 + 日志 | ~35 | Socket 抽象、HTTP 解析、shadow 密码 | ~163 | +| 6 | 剩余组件 + 集成验证 | ~40+ | POSIX 验证、容器替换测试 | ~200+ | + +--- + +## Phase 0:构建系统现代化 ✅ + +**目标**:为 200+ applet 做好构建基础,建立共享基础设施。 + +### 基础设施工作 + +1. **CMake 配置系统** ✅:`cmake/Config.cmake` + `include/cfbox/applet_config.hpp.in`,每个 applet 有 `CFBOX_ENABLE_` 选项(默认 ON),通过 `configure_file()` 生成 `applet_config.hpp`。`APPLET_REGISTRY` 中用 `#if CFBOX_ENABLE_ECHO` 守卫。提供预设 profile(`embedded`/`desktop`/`minimal`/`full`)。 + +2. **长选项支持** ✅:`args.hpp` 支持 `--recursive`、`--help`、`--version` 等 GNU 风格长选项,完全向后兼容现有短选项 API。 + +3. **内置 help 系统** ✅:`include/cfbox/help.hpp`,所有 17 个 applet 支持格式化的 `--help` 输出和 `--version`。 + +4. **彩色输出** ✅:`include/cfbox/term.hpp`,ANSI 颜色辅助(尊重 `NO_COLOR` 环境变量)。 + +5. **UTF-8 工具** ✅:`include/cfbox/utf8.hpp`,Unicode 感知的字符计数和宽度计算。 + +### 验证 +- `cmake -DCFBOX_ENABLE_GREP=OFF` 编译成功且不包含 grep ✅ +- `./cfbox echo --help` 输出格式化帮助 ✅ +- 现有 17 个 applet 全部测试通过 ✅ +- 149 个单元测试 + 17 个集成测试脚本全部通过 ✅ + +--- + +## Phase 1:POSIX Shell + Coreutils 第一批(最高优先级) + +**目标**:实现交互式 POSIX Shell——这是在真实 Linux 上使用的基础。同时实现简单的 coreutils 建立势头。 + +### Shell 架构(`src/applets/sh/`) + +Shell 是最复杂的单一组件,按模块拆分: + +| 模块 | 文件 | 功能 | +|------|------|------| +| 词法分析 | `sh_lexer.cpp` | 分词、引号处理、here-document | +| 语法解析 | `sh_parser.cpp` | AST 构建:管道、列表、复合命令、函数定义 | +| 执行器 | `sh_executor.cpp` | AST 遍历执行,fork/exec,管道/重定向设置 | +| 内建命令 | `sh_builtins.cpp` | cd, export, exit, test, read, trap, umask, eval, source 等 | +| 行编辑 | `sh_lineedit.cpp` | 原生实现(不依赖 readline):行编辑、历史记录、Tab 补全 | +| 作业控制 | `sh_jobs.cpp` | 进程组、会话管理、jobs/fg/bg、Ctrl+Z | +| 变量系统 | `sh_vars.cpp` | 环境变量、Shell 变量、特殊参数、位置参数 | +| 词展开 | `sh_expand.cpp` | 波浪号、参数展开、命令替换、算术展开、文件名展开 | + +### 需要的基础设施 +- **进程管理** `include/cfbox/process.hpp`:fork/exec/pipe/dup2/waitpid RAII 封装 +- **信号处理** `include/cfbox/signal.hpp`:RAII 信号处理器 + +### Coreutils 第一批(简单 applet,50-200 行/个) + +`basename`, `dirname`, `true`, `false`, `yes`, `sleep`, `pwd`, `tty`, `uname`, `whoami`, `hostname`, `id`, `logname`, `nproc`, `test`, `link` + +### 验证 +- `./cfbox sh -c "echo hello | wc -l"` 管道工作 +- `./cfbox sh -c "for i in 1 2 3; do echo $i; done"` 循环工作 +- 交互模式:行编辑、历史、Tab 补全 +- 作业控制:后台进程、fg/bg、Ctrl+Z +- 所有新 coreutils 通过单元 + 集成测试 + +--- + +## Phase 2:Coreutils 第二批 + findutils/xargs + +**目标**:完成剩余 coreutils,添加 xargs,建立流处理管线基础设施。 + +### Coreutils 第二批(中等复杂度,100-400 行/个) + +**文本处理**:`date`, `env`, `printenv`, `seq`, `tee`, `touch`, `tr`, `fold`, `expand`, `nl`, `paste`, `cut`, `comm`, `cksum`, `md5sum`, `sum`, `shred`, `shuf`, `factor`, `tac`, `od`, `split` + +**系统/文件**:`timeout`, `nice`, `nohup`, `expr`, `hostid`, `install`, `readlink`, `realpath`, `rmdir`, `unlink`, `truncate`, `tsort`, `ln`, `mktemp`, `mkfifo`, `mknod`, `du`, `df`, `stat`, `sync`, `usleep`, `who` + +### findutils +- **xargs**:从 stdin 构建参数列表执行命令,支持 `-n`, `-I`, `-0`, `-p`, `-r` + +### 基础设施 +- **流管线** `include/cfbox/stream.hpp`:逐行处理、字段分割 +- **校验和** `include/cfbox/checksum.hpp`:MD5, SHA-256 实现 + +### 验证 +- `find . -name "*.cpp" | xargs grep "main"` 端到端工作 +- `date`, `seq 1 10`, `tr a-z A-Z` 与 GNU 行为一致 +- 所有新 applet 通过单元 + 集成测试 + +--- + +## Phase 3:编辑器 + 归档 + 压缩 + +**目标**:实现让 CFBox 能够独立进行系统管理的重量级组件。 + +### 编辑器 +- **awk**(`src/applets/awk/`):完整 AWK 实现——词法/语法/解释器,模式-动作、BEGIN/END、字段、关联数组、printf、用户函数、正则匹配。目标 POSIX awk 兼容。第二复杂组件。 +- **vi**(`src/applets/vi/`):完整可视化编辑器——命令模式、插入模式、ex 命令行、缓冲区管理、光标移动、搜索替换、撤销。 +- **diff** + **cmp**:文件比较,支持 unified/context/normal 格式 +- **patch**:应用 diff 输出 +- **ed**:行编辑器 + +### 归档 +- **tar**(`src/applets/tar/`):创建/提取/列出,支持 ustar/GNU/pax 格式 +- **cpio**:initramfs 必需,支持 newc/odc 格式 +- **ar**:静态库管理 +- **unzip**:ZIP 提取 +- **dpkg_deb** / **rpm**:基本包提取 + +### 压缩(`include/cfbox/compress/`) +- **gzip/gunzip**:DEFLATE 算法 +- **xz/unxz**:LZMA2 格式(初期可链接 liblzma) +- **zstd**:(初期可链接 libzstd) + +### 基础设施 +- **终端抽象** `include/cfbox/terminal.hpp`:termios raw 模式、转义序列、终端大小、备用屏幕缓冲区 +- **压缩框架**:流式压缩/解压接口,允许 tar 透明处理 .tar.gz/.tar.xz + +### 验证 +- `echo "hello" | gzip | gunzip` 往返正确 +- `tar cf - /etc | tar tf -` 列出文件 +- `awk '{print $1}' file` 输出正确 +- vi 能打开、编辑、保存文件(自动化测试) +- `diff file1 file2 | patch file1` 往返正确 + +--- + +## Phase 4:进程管理 + Init 系统 + util-linux + +**目标**:构建让 CFBox 适合作为完整 init 环境的系统级工具。 + +### Init 系统(完整) +- **inittab 解析器**:解析 `/etc/inittab`,支持运行级别、respawn、once 条目 +- **运行级别管理**:sysinit, boot, single-user, multi-user +- **服务监控**:进程监控 + respawn 能力 +- **关机/重启**:SIGTERM → 等待 → SIGKILL → sync → 卸载 → 重启 +- **getty 集成**:在 TTY 上生成登录提示 + +### procps(解析 `/proc` 文件系统) +`ps`, `top`, `kill`, `free`, `uptime`, `pgrep`/`pkill`, `pidof`, `pmap`, `iostat`, `lsof`, `watch`, `sysctl`, `pstree`, `fuser`, `pwdx` + +### util-linux +**存储/块设备**:`mount`/`umount`, `blkid`, `blockdev`, `dmesg`, `fdisk`, `mkfs`, `fsck`, `losetup`, `pivot_root`, `switch_root`, `swapon`/`swapoff` + +**系统工具**:`hexdump`, `more`, `flock`, `getopt`, `cal`, `rev`, `setsid`, `nsenter`, `unshare`, `mdev`, `lspci`, `lsusb`, `hwclock`, `rtcwake`, `taskset`, `chrt`, `ionice`, `renice`, `last`, `mesg`, `wall`, `script` + +### 基础设施 +- **`/proc` 解析器** `include/cfbox/proc.hpp`:集中解析 /proc/meminfo, /proc/stat, /proc/[pid]/stat 等 +- **TUI 框架** `include/cfbox/tui.hpp`:全屏终端应用抽象(top, vi, less 共用) + +### 验证 +- CFBox 作为 PID 1 在 QEMU 中启动,运行 inittab,生成 getty,处理关机 +- `ps aux` 输出与 procps 格式匹配 +- 容器测试:CFBox 替换 Alpine 容器中的 BusyBox,`docker run` 成功 + +--- + +## Phase 5:网络 + 登录管理 + 系统日志 + +**目标**:添加网络工具和用户/会话管理,使 CFBox 适用于网络启动系统和多用户环境。 + +### 网络工具 +**基础网络**:`ifconfig`, `ip`, `route`, `hostname`, `ping`, `traceroute` + +**文件传输**:`wget`, `nc` (netcat), `tftp` + +**网络服务器**:`httpd`, `ftpd`, `telnetd`, `inetd` + +**DHCP/DNS**:DHCP 客户端/服务端 (`udhcpc`/`udhcpd`), `nslookup`, `dnsd`, `whois` + +### 登录管理 +`login`, `su`, `passwd`, `adduser`, `deluser`, `addgroup`, `delgroup`, `getty`, `sulogin`, `vlock`, `chpasswd` + +### 系统日志 +`syslogd`, `klogd`, `logger`, `logread` + +### 基础设施 +- **Socket 抽象** `include/cfbox/socket.hpp`:TCP/UDP/Unix domain socket RAII 封装 +- **HTTP 解析器** `include/cfbox/http.hpp`:最小 HTTP/1.1 解析 +- **shadow 密码** `include/cfbox/shadow.hpp`:安全解析 /etc/passwd, /etc/shadow, /etc/group + +### 验证 +- `ping -c 3 8.8.8.8` 工作(QEMU 网络桥接) +- `wget http://example.com/` 下载成功 +- `nc -l -p 8080` 与 `nc localhost 8080` 通信成功 +- `login` 通过 `/etc/shadow` 认证 +- `syslogd` 收集 `logger` 发送的日志 + +--- + +## Phase 6:剩余组件 + 全面集成验证 + +**目标**:完成所有剩余 BusyBox applet 类别,进行 POSIX 合规验证和集成测试。 + +### 剩余类别 +- **modutils**:`insmod`, `rmmod`, `lsmod`, `modprobe`, `depmod`, `modinfo` +- **runit**:`runsv`, `sv`, `svlogd`, `runsvdir`, `chpst` +- **console-tools**:`chvt`, `clear`, `deallocvt`, `reset`, `setconsole`, `loadfont`, `loadkmap`, `openvt`, `fgconsole`, `showkey` +- **debianutils**:`which`, `run_parts`, `start_stop_daemon`, `pipe_progress` +- **e2fsprogs**:`chattr`, `lsattr`, `tune2fs` +- **miscutils**:`strings`, `less`, `man`, `time`, `tree`, `bc`, `dc`, `crond`, `crontab`, `watchdog`, `hdparm`, `beep`, `devmem`, `inotifyd`, `microcom`, `chat` + +### 最终集成验证 + +1. **POSIX 合规测试**:运行 Open Group POSIX 测试套件(或子集)验证所有 applet +2. **容器替换测试**:在 Alpine/Ubuntu 最小容器中用 CFBox 替换 BusyBox,运行完整冒烟测试 +3. **initramfs 救援测试**:构建仅包含 CFBox 的 initramfs,在 QEMU 中启动执行救援操作 +4. **真实硬件测试**:在树莓派(aarch64)上启动 +5. **体积报告**:对比不同配置(full/minimal/shell-only)与 BusyBox 的体积 +6. **musl 支持**:添加 musl libc 交叉编译支持,CI 同时测试 glibc 和 musl +7. **文档完善**:所有 applet 的 `--help`,更新 README 和架构文档 + +### 验证 +- 所有 ~200 applet 编译通过且测试通过 +- 容器替换测试通过 +- initramfs 救援测试通过 +- POSIX 合规文档化 +- 二进制体积满足嵌入式使用 + +--- + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| **Shell 复杂度** | 最高——5000+ 行代码 | 增量构建:先非交互 → 行编辑 → 作业控制,从第一天开始测试 POSIX shell 测试套件 | +| **AWK 复杂度** | 高——第二复杂组件 | 从 POSIX awk 子集开始,实现解释器而非编译器 | +| **vi 复杂度** | 高——终端处理微妙 | 使用 Phase 0 的终端抽象,自动化按键注入测试 | +| **二进制体积膨胀** | 中——200+ applet | Phase 0 的 CMake 配置允许裁剪,LTO 和死代码消除,每阶段监控体积 | +| **跨平台边界情况** | 中——ioctl、/proc 格式差异 | 平台特性抽象到 `include/cfbox/` 头文件,每阶段三平台测试 | +| **网络安全** | 中——wget/httpd 需要 TLS | 先做 HTTP-only,HTTPS 通过可选 mbedTLS 依赖,作为 CMake 选项 | +| **大规模测试覆盖** | 高——200+ applet | 每个 applet 添加时即包含单元+集成测试,阶段完成需全部通过 | + +--- + +## 关键架构决策 + +1. **简单 applet 单文件**(`src/applets/`),复杂 applet(shell, awk, vi, tar, fdisk)用子目录 +2. **共享基础设施放头文件**(`include/cfbox/`),遵循现有 io.hpp/fs_util.hpp 模式 +3. **`std::expected` 错误处理**:继续使用 CFBOX_TRY 模式 +4. **注册表分发**:`APPLET_REGISTRY` 通过 `#if` 配置守卫增长 +5. **无外部运行时依赖**:静态链接目标,可选编译时依赖(lzma, zstd, mbedtls) + +## 关键文件 + +- `include/cfbox/applets.hpp`——注册表从 17 增长到 200+ +- `include/cfbox/args.hpp`——扩展长选项支持 +- `include/cfbox/error.hpp`——所有 applet 的错误处理基础 +- `CMakeLists.txt`——从简单构建演进为配置化选择 +- `src/applets/init.cpp`——从简单 init 扩展为完整 init 系统 diff --git a/cmake/Config.cmake b/cmake/Config.cmake new file mode 100644 index 0000000..504029c --- /dev/null +++ b/cmake/Config.cmake @@ -0,0 +1,57 @@ +# cmake/Config.cmake — Per-applet configuration +include_guard() + +# ── Per-applet options ────────────────────────────────────── +# Default all to ON. Each generates CFBOX_ENABLE_ in applet_config.hpp +set(CFBOX_APPLETS + echo printf cat head tail wc sort uniq + mkdir rm cp mv ls grep find sed init +) + +foreach(applet IN LISTS CFBOX_APPLETS) + string(TOUPPER "${applet}" APPLET_UPPER) + option(CFBOX_ENABLE_${APPLET_UPPER} "Enable ${applet} applet" ON) +endforeach() + +# ── Preset profiles ───────────────────────────────────────── +# Usage: cmake -DCFBOX_PROFILE=minimal .. +set(CFBOX_PROFILE "" CACHE STRING "Configuration profile: full, desktop, minimal, embedded") + +if(CFBOX_PROFILE) + if(CFBOX_PROFILE STREQUAL "minimal") + # Minimal: only essential file operations + set(CFBOX_ENABLE_ECHO ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_PRINTF OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_CAT ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_HEAD OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_TAIL OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_WC OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_SORT OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_UNIQ OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_MKDIR ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_RM ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_CP ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_MV ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_LS ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_GREP ON CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_FIND OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_SED OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_INIT OFF CACHE BOOL "" FORCE) + elseif(CFBOX_PROFILE STREQUAL "embedded") + # Embedded: everything except optional text processing + set(CFBOX_ENABLE_SORT OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_UNIQ OFF CACHE BOOL "" FORCE) + set(CFBOX_ENABLE_SED OFF CACHE BOOL "" FORCE) + elseif(CFBOX_PROFILE STREQUAL "desktop" OR CFBOX_PROFILE STREQUAL "full") + # All enabled (same as default) + else() + message(WARNING "Unknown CFBOX_PROFILE: ${CFBOX_PROFILE}") + endif() +endif() + +# ── Generate applet_config.hpp ────────────────────────────── +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/include/cfbox/applet_config.hpp.in + ${CMAKE_CURRENT_BINARY_DIR}/include/cfbox/applet_config.hpp + @ONLY +) diff --git a/document/architecture.md b/document/architecture.md index 3c18ad3..93efbfe 100644 --- a/document/architecture.md +++ b/document/architecture.md @@ -8,11 +8,15 @@ CFBox 是单一可执行文件,通过两种方式调用子命令: 2. **子命令语法:** 若 `argv[0]` 未匹配任何 applet,则将 `argv[1]` 作为子命令名查找(如 `cfbox echo ...`)。 ```cpp -// include/cfbox/applets.hpp +// include/cfbox/applets.hpp — 条件编译的注册表 constexpr auto APPLET_REGISTRY = std::to_array({ +#if CFBOX_ENABLE_ECHO {"echo", echo_main, "display text"}, +#endif +#if CFBOX_ENABLE_CAT {"cat", cat_main, "concatenate files"}, - // ... 共 17 个条目 +#endif + // ... 共 17 个条目,每个由 #if 守卫 }); ``` @@ -22,10 +26,15 @@ constexpr auto APPLET_REGISTRY = std::to_array({ |--------|------| | [error.hpp](../include/cfbox/error.hpp) | `std::expected` 及 `CFBOX_TRY` 宏用于错误传播 | | [applet.hpp](../include/cfbox/applet.hpp) | `AppEntry` 结构体与 `find_applet()` 模板查找 | -| [args.hpp](../include/cfbox/args.hpp) | 命令行参数解析器 — 短标志、带值标志、`--` 分隔符、位置参数 | +| [applets.hpp](../include/cfbox/applets.hpp) | `APPLET_REGISTRY` 注册表,每个条目由 `#if CFBOX_ENABLE_xxx` 守卫 | +| [applet_config.hpp.in](../include/cfbox/applet_config.hpp.in) | CMake 生成的配置头文件:`CFBOX_ENABLE_` 宏和 `CFBOX_VERSION_STRING` | +| [args.hpp](../include/cfbox/applets.hpp) | 命令行参数解析器 — 短标志、长选项(`--recursive`)、带值标志、`--` 分隔符、位置参数 | | [io.hpp](../include/cfbox/io.hpp) | 文件 I/O 工具 — `read_all`、`read_lines`、`read_all_stdin`、`write_all`、`split_lines` | | [fs_util.hpp](../include/cfbox/fs_util.hpp) | 返回 `Result` 的文件系统封装 — `exists`、`mkdir_recursive`、`copy_recursive`、`rename` 等 | | [escape.hpp](../include/cfbox/escape.hpp) | `echo` / `printf` 的转义序列处理(`\n`、`\t`、`\0NNN` 等) | +| [help.hpp](../include/cfbox/help.hpp) | 帮助系统 — `HelpEntry` 结构体、`print_help()`、`print_version()`,支持彩色输出 | +| [term.hpp](../include/cfbox/term.hpp) | 终端颜色输出 — ANSI SGR 辅助函数,尊重 `NO_COLOR` 环境变量 | +| [utf8.hpp](../include/cfbox/utf8.hpp) | UTF-8 工具 — Unicode 感知的代码点计数、终端显示宽度计算、截断 | ## 错误处理 @@ -39,21 +48,64 @@ auto content = CFBOX_TRY(cfbox::io::read_all(path)); ## 参数解析 -[args.hpp](../include/cfbox/args.hpp) 提供统一的参数解析器: +[args.hpp](../include/cfbox/args.hpp) 提供统一的参数解析器,支持短选项和 GNU 风格长选项: ```cpp auto parsed = cfbox::args::parse(argc, argv, { - {'n', false}, // -n 无值标志 - {'e', false}, // -e 无值标志 - {'m', true}, // -m 需要值(如 -m 755 或 -m755) + {'r', false, "recursive"}, // -r 或 --recursive,无值标志 + {'n', false, ""}, // -n 仅短选项 + {'m', true, "mode"}, // -m 755 或 --mode=755 或 --mode 755 }); -if (parsed.has('n')) { /* ... */ } -auto mode = parsed.get('m'); // std::optional -auto files = parsed.positional(); // const std::vector& +if (parsed.has('r')) { /* ... */ } // 短选项查询 +if (parsed.has_long("recursive")) { /* ... */ } // 长选项查询 +if (parsed.has_any('r', "recursive")) { /* ... } // 两者任一 +auto mode = parsed.get_any('m', "mode"); // 从短或长形式获取值 +auto files = parsed.positional(); ``` -支持的语法:短标志组合(`-ne`)、带值标志(`-n5` 或 `-n 5`)、`--` 分隔符。 +支持的语法:短标志组合(`-ne`)、带值标志(`-n5` 或 `-n 5`)、长选项(`--recursive`、`--mode=755`)、`--` 分隔符。未注册的长选项(如 `--help`、`--version`)仍存储在结果中供 applet 检查。 + +## 帮助系统 + +每个 applet 定义一个 `HelpEntry` 常量: + +```cpp +static constexpr cfbox::help::HelpEntry HELP = { + .name = "echo", + .version = CFBOX_VERSION_STRING, + .one_line = "display a line of text", + .usage = "echo [OPTIONS] [STRING]...", + .options = " -n do not output trailing newline\n" + " -e enable backslash escape interpretation", + .extra = "", +}; +``` + +在 `parse()` 之后检查 `--help` / `--version`: + +```cpp +if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } +if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } +``` + +`print_help()` 输出格式化的帮助文本(带彩色标题和使用说明),自动追加 `--help` / `--version` 选项。当 `NO_COLOR` 环境变量设置时自动禁用颜色。 + +## CMake 配置系统 + +[cmake/Config.cmake](../cmake/Config.cmake) 提供 per-applet 编译选项: + +```bash +# 禁用单个 applet +cmake -DCFBOX_ENABLE_GREP=OFF .. + +# 使用预设 profile +cmake -DCFBOX_PROFILE=minimal .. # 仅核心文件操作 applet +cmake -DCFBOX_PROFILE=embedded .. # 除文本处理外全部启用 +cmake -DCFBOX_PROFILE=desktop .. # 全部启用 +``` + +配置通过 `configure_file()` 生成 `include/cfbox/applet_config.hpp`,包含 `CFBOX_ENABLE_` 宏(0 或 1)和 `CFBOX_VERSION_STRING`。 ## Applet 注册 @@ -63,29 +115,34 @@ auto files = parsed.positional(); // const std::vector& auto echo_main(int argc, char* argv[]) -> int; ``` -添加新 applet 只需: +添加新 applet 需要: 1. 实现 `_main` 函数 -2. 在 [applets.hpp](../include/cfbox/applets.hpp) 声明 -3. 在 `APPLET_REGISTRY` 添加条目 +2. 在 [applets.hpp](../include/cfbox/applets.hpp) 用 `#if CFBOX_ENABLE_xxx` 守卫声明 +3. 在 `APPLET_REGISTRY` 用 `#if` 守卫添加条目 +4. 在 `cmake/Config.cmake` 的 `CFBOX_APPLETS` 列表中添加名字 +5. 定义 `HelpEntry` 常量并处理 `--help`/`--version` 详见 [CONTRIBUTING.md](../CONTRIBUTING.md)。 ## 测试体系 -### 单元测试(108 个用例) +### 单元测试(149 个用例) 基于 GoogleTest(通过 CPM 获取),位于 [tests/unit/](../tests/unit/): - [test_capture.hpp](../tests/unit/test_capture.hpp) — 测试工具:stdout 捕获、临时目录 - 各 applet 独立测试文件(`test_echo.cpp`、`test_grep.cpp` 等) +- 基础设施测试:`test_args.cpp`、`test_help.cpp`、`test_term.cpp`、`test_utf8.cpp` 等 +- Applet 测试文件由 `#if CFBOX_ENABLE_xxx` 守卫,禁用 applet 时自动跳过 运行:`ctest --test-dir build --output-on-failure` -### 集成测试(16 个脚本) +### 集成测试(17 个脚本) Shell 脚本位于 [tests/integration/](../tests/integration/),与 GNU coreutils 行为对比: - [helpers.sh](../tests/integration/helpers.sh) — `assert_output()`、`assert_exit()` 等断言函数 - 各 applet 独立测试脚本 +- [test_help.sh](../tests/integration/test_help.sh) — 验证所有 applet 的 `--help` 和 `--version` 运行:`bash tests/integration/run_all.sh` diff --git a/include/cfbox/applet_config.hpp.in b/include/cfbox/applet_config.hpp.in new file mode 100644 index 0000000..4688106 --- /dev/null +++ b/include/cfbox/applet_config.hpp.in @@ -0,0 +1,23 @@ +// Generated by cmake/Config.cmake — do not edit +#pragma once + +#define CFBOX_VERSION_STRING "@PROJECT_VERSION@" + +// Per-applet enable flags (0 or 1) +#cmakedefine01 CFBOX_ENABLE_ECHO +#cmakedefine01 CFBOX_ENABLE_PRINTF +#cmakedefine01 CFBOX_ENABLE_CAT +#cmakedefine01 CFBOX_ENABLE_HEAD +#cmakedefine01 CFBOX_ENABLE_TAIL +#cmakedefine01 CFBOX_ENABLE_WC +#cmakedefine01 CFBOX_ENABLE_SORT +#cmakedefine01 CFBOX_ENABLE_UNIQ +#cmakedefine01 CFBOX_ENABLE_MKDIR +#cmakedefine01 CFBOX_ENABLE_RM +#cmakedefine01 CFBOX_ENABLE_CP +#cmakedefine01 CFBOX_ENABLE_MV +#cmakedefine01 CFBOX_ENABLE_LS +#cmakedefine01 CFBOX_ENABLE_GREP +#cmakedefine01 CFBOX_ENABLE_FIND +#cmakedefine01 CFBOX_ENABLE_SED +#cmakedefine01 CFBOX_ENABLE_INIT diff --git a/include/cfbox/applets.hpp b/include/cfbox/applets.hpp index 64a4601..cf85d35 100644 --- a/include/cfbox/applets.hpp +++ b/include/cfbox/applets.hpp @@ -1,43 +1,112 @@ #pragma once #include +#include -// applet entry points — add declaration here when adding a new applet +// applet entry points — conditionally declared based on config +#if CFBOX_ENABLE_ECHO extern auto echo_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_PRINTF extern auto printf_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_CAT extern auto cat_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_HEAD extern auto head_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_TAIL extern auto tail_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_WC extern auto wc_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_SORT extern auto sort_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_UNIQ extern auto uniq_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_MKDIR extern auto mkdir_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_RM extern auto rm_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_CP extern auto cp_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_MV extern auto mv_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_LS extern auto ls_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_GREP extern auto grep_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_FIND extern auto find_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_SED extern auto sed_main(int argc, char* argv[]) -> int; +#endif +#if CFBOX_ENABLE_INIT extern auto init_main(int argc, char* argv[]) -> int; +#endif -// registry — one line per applet, easy to generate/extend +// registry — one line per applet, conditionally compiled constexpr auto APPLET_REGISTRY = std::to_array({ +#if CFBOX_ENABLE_ECHO {"echo", echo_main, "display a line of text"}, +#endif +#if CFBOX_ENABLE_PRINTF {"printf", printf_main, "format and print data"}, +#endif +#if CFBOX_ENABLE_CAT {"cat", cat_main, "concatenate files and print"}, +#endif +#if CFBOX_ENABLE_HEAD {"head", head_main, "output the first part of files"}, +#endif +#if CFBOX_ENABLE_TAIL {"tail", tail_main, "output the last part of files"}, +#endif +#if CFBOX_ENABLE_WC {"wc", wc_main, "print newline, word, and byte counts"}, +#endif +#if CFBOX_ENABLE_SORT {"sort", sort_main, "sort lines of text"}, +#endif +#if CFBOX_ENABLE_UNIQ {"uniq", uniq_main, "report or omit repeated lines"}, +#endif +#if CFBOX_ENABLE_MKDIR {"mkdir", mkdir_main, "create directories"}, +#endif +#if CFBOX_ENABLE_RM {"rm", rm_main, "remove files or directories"}, +#endif +#if CFBOX_ENABLE_CP {"cp", cp_main, "copy files and directories"}, +#endif +#if CFBOX_ENABLE_MV {"mv", mv_main, "move or rename files"}, +#endif +#if CFBOX_ENABLE_LS {"ls", ls_main, "list directory contents"}, +#endif +#if CFBOX_ENABLE_GREP {"grep", grep_main, "search patterns in text"}, +#endif +#if CFBOX_ENABLE_FIND {"find", find_main, "search for files in directory hierarchy"}, +#endif +#if CFBOX_ENABLE_SED {"sed", sed_main, "stream editor for filtering and transforming text"}, +#endif +#if CFBOX_ENABLE_INIT {"init", init_main, "system init for boot testing (PID 1)"}, +#endif }); diff --git a/include/cfbox/args.hpp b/include/cfbox/args.hpp index cd0822d..a7ef3de 100644 --- a/include/cfbox/args.hpp +++ b/include/cfbox/args.hpp @@ -10,12 +10,14 @@ namespace cfbox::args { struct OptSpec { char flag; - bool has_value; // true = flag takes a value (e.g. -n 10) + bool has_value; // true = flag takes a value (e.g. -n 10) + std::string_view long_name{}; // e.g. "recursive", empty = no long option }; class ParseResult { std::vector flags_; std::vector> values_; + std::vector>> long_opts_; std::vector positional_; friend auto parse(int argc, char* argv[], @@ -39,6 +41,31 @@ class ParseResult { [[nodiscard]] auto positional() const -> const std::vector& { return positional_; } + + // Long option queries + [[nodiscard]] auto has_long(std::string_view name) const -> bool { + for (const auto& [n, _] : long_opts_) + if (n == name) return true; + return false; + } + + [[nodiscard]] auto get_long(std::string_view name) const + -> std::optional { + for (const auto& [n, v] : long_opts_) + if (n == name) return v; + return std::nullopt; + } + + // Check either short or long form + [[nodiscard]] auto has_any(char flag, std::string_view long_name) const -> bool { + return has(flag) || has_long(long_name); + } + + [[nodiscard]] auto get_any(char flag, std::string_view long_name) const + -> std::optional { + if (auto v = get(flag)) return v; + return get_long(long_name); + } }; inline auto parse(int argc, char* argv[], @@ -52,6 +79,12 @@ inline auto parse(int argc, char* argv[], return false; }; + auto find_long_spec = [&](std::string_view name) -> const OptSpec* { + for (const auto& s : specs) + if (s.long_name == name) return &s; + return nullptr; + }; + for (int i = 1; i < argc; ++i) { const std::string_view arg{argv[i]}; @@ -66,6 +99,41 @@ inline auto parse(int argc, char* argv[], continue; } + // Long option: starts with "--" and has more than 2 chars + if (arg.size() > 2 && arg[0] == '-' && arg[1] == '-') { + std::string_view long_arg = arg.substr(2); + std::string_view name = long_arg; + std::optional value; + + // Check for --name=value + auto eq_pos = long_arg.find('='); + if (eq_pos != std::string_view::npos) { + name = long_arg.substr(0, eq_pos); + value = long_arg.substr(eq_pos + 1); + } + + const OptSpec* spec = find_long_spec(name); + + if (spec && spec->has_value && !value) { + // --name value (separate arg) + if (i + 1 < argc) { + value = std::string_view{argv[++i]}; + } + } + + // Store in both short and long for matched specs + if (spec && value) { + result.values_.emplace_back(spec->flag, *value); + } else if (spec && !spec->has_value) { + result.flags_.push_back(spec->flag); + } + + // Always store the long option entry + result.long_opts_.emplace_back(name, value); + continue; + } + + // Short option(s): -abc for (std::size_t j = 1; j < arg.size(); ++j) { const char c = arg[j]; diff --git a/include/cfbox/help.hpp b/include/cfbox/help.hpp new file mode 100644 index 0000000..c0f4472 --- /dev/null +++ b/include/cfbox/help.hpp @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include + +#ifndef CFBOX_VERSION_STRING +#define CFBOX_VERSION_STRING "0.0.1" +#endif + +namespace cfbox::help { + +struct HelpEntry { + std::string_view name; + std::string_view version; + std::string_view one_line; + std::string_view usage; + std::string_view options; + std::string_view extra; +}; + +namespace detail { +inline void write_sv(std::string_view sv) { + if (!sv.empty()) std::fwrite(sv.data(), 1, sv.size(), stdout); +} +} // namespace detail + +inline auto print_help(const HelpEntry& entry) -> void { + // Title: "cfbox -- " + detail::write_sv(term::bold()); + std::fwrite("cfbox ", 1, 6, stdout); + detail::write_sv(entry.name); + detail::write_sv(term::reset()); + std::fwrite(" -- ", 1, 4, stdout); + detail::write_sv(entry.one_line); + std::fputc('\n', stdout); + std::fputc('\n', stdout); + + // Usage + detail::write_sv(term::bold()); + std::fwrite("Usage:", 1, 6, stdout); + detail::write_sv(term::reset()); + std::fputc('\n', stdout); + std::fputc(' ', stdout); + std::fputc(' ', stdout); + detail::write_sv(entry.usage); + std::fputc('\n', stdout); + std::fputc('\n', stdout); + + // Options + if (!entry.options.empty()) { + detail::write_sv(term::bold()); + std::fwrite("Options:", 1, 8, stdout); + detail::write_sv(term::reset()); + std::fputc('\n', stdout); + detail::write_sv(entry.options); + std::fputc('\n', stdout); + } + + // Auto-append --help and --version + std::fwrite(" ", 1, 6, stdout); + detail::write_sv(term::cyan()); + std::fwrite("--help", 1, 6, stdout); + detail::write_sv(term::reset()); + std::fwrite(" display this help and exit\n", 1, 32, stdout); + + std::fwrite(" ", 1, 6, stdout); + detail::write_sv(term::cyan()); + std::fwrite("--version", 1, 9, stdout); + detail::write_sv(term::reset()); + std::fwrite(" output version information and exit\n", 1, 38, stdout); + + // Extra notes + if (!entry.extra.empty()) { + std::fputc('\n', stdout); + detail::write_sv(entry.extra); + std::fputc('\n', stdout); + } +} + +inline auto print_version(const HelpEntry& entry) -> void { + std::fwrite("cfbox ", 1, 6, stdout); + detail::write_sv(entry.name); + std::fputc(' ', stdout); + detail::write_sv(entry.version); + std::fputc('\n', stdout); +} + +inline auto print_cfbox_version() -> void { + std::fwrite("cfbox ", 1, 6, stdout); + std::fwrite(CFBOX_VERSION_STRING, 1, sizeof(CFBOX_VERSION_STRING) - 1, stdout); + std::fputc('\n', stdout); +} + +} // namespace cfbox::help diff --git a/include/cfbox/term.hpp b/include/cfbox/term.hpp new file mode 100644 index 0000000..c7a61f7 --- /dev/null +++ b/include/cfbox/term.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include +#include + +namespace cfbox::term { + +namespace detail { +struct ColorState { + bool auto_detected = false; + bool auto_value = false; + bool override_set = false; + bool override_value = false; +}; + +inline auto color_state() -> ColorState& { + static ColorState state; + return state; +} +} // namespace detail + +inline auto color_enabled() -> bool { + auto& s = detail::color_state(); + if (s.override_set) return s.override_value; + if (!s.auto_detected) { + s.auto_detected = true; + s.auto_value = (std::getenv("NO_COLOR") == nullptr) && (isatty(STDOUT_FILENO) != 0); + } + return s.auto_value; +} + +inline void set_color_enabled(bool enabled) { + auto& s = detail::color_state(); + s.override_set = true; + s.override_value = enabled; +} + +inline void reset_color_enabled() { + auto& s = detail::color_state(); + s.override_set = false; + s.auto_detected = false; +} + +namespace detail { +inline auto sv(const char* code) -> std::string_view { + return color_enabled() ? std::string_view{code} : std::string_view{}; +} +} // namespace detail + +// Foreground colors +inline auto red() -> std::string_view { return detail::sv("\033[31m"); } +inline auto green() -> std::string_view { return detail::sv("\033[32m"); } +inline auto yellow() -> std::string_view { return detail::sv("\033[33m"); } +inline auto blue() -> std::string_view { return detail::sv("\033[34m"); } +inline auto magenta() -> std::string_view { return detail::sv("\033[35m"); } +inline auto cyan() -> std::string_view { return detail::sv("\033[36m"); } + +// Attributes +inline auto bold() -> std::string_view { return detail::sv("\033[1m"); } +inline auto dim() -> std::string_view { return detail::sv("\033[2m"); } +inline auto underline() -> std::string_view { return detail::sv("\033[4m"); } + +// Reset +inline auto reset() -> std::string_view { return detail::sv("\033[0m"); } + +// Utility: wrap text with a color and reset +inline auto colored(std::string_view text, std::string_view color_code) -> std::string { + if (!color_enabled()) return std::string{text}; + return std::string{color_code} + std::string{text} + std::string{reset()}; +} + +} // namespace cfbox::term diff --git a/include/cfbox/utf8.hpp b/include/cfbox/utf8.hpp new file mode 100644 index 0000000..516fcba --- /dev/null +++ b/include/cfbox/utf8.hpp @@ -0,0 +1,148 @@ +#pragma once + +#include +#include + +namespace cfbox::utf8 { + +constexpr auto is_continuation(unsigned char b) -> bool { + return (b & 0xC0) == 0x80; +} + +struct DecodeResult { + char32_t code_point; + std::size_t bytes_consumed; +}; + +inline auto decode(std::string_view str, std::size_t pos) -> DecodeResult { + if (pos >= str.size()) return {char32_t(0), 0}; + + unsigned char b0 = static_cast(str[pos]); + + // Single byte (0xxxxxxx) + if (b0 < 0x80) return {char32_t(b0), 1}; + + // Invalid: continuation byte as lead + if (is_continuation(b0)) return {char32_t(0xFFFD), 1}; + + // Determine sequence length and initial bits + std::size_t len = 0; + char32_t cp = 0; + + if ((b0 & 0xE0) == 0xC0) { len = 2; cp = char32_t(b0 & 0x1F); } + else if ((b0 & 0xF0) == 0xE0) { len = 3; cp = char32_t(b0 & 0x0F); } + else if ((b0 & 0xF8) == 0xF0) { len = 4; cp = char32_t(b0 & 0x07); } + else return {char32_t(0xFFFD), 1}; // 0xF8..0xFF invalid + + if (pos + len > str.size()) return {char32_t(0xFFFD), 1}; + + for (std::size_t i = 1; i < len; ++i) { + unsigned char b = static_cast(str[pos + i]); + if (!is_continuation(b)) return {char32_t(0xFFFD), 1}; + cp = (cp << 6) | char32_t(b & 0x3F); + } + + // Reject overlong encodings and surrogates + if (len == 2 && cp < 0x80) return {char32_t(0xFFFD), 1}; + if (len == 3 && cp < 0x800) return {char32_t(0xFFFD), 1}; + if (len == 4 && cp < 0x10000) return {char32_t(0xFFFD), 1}; + if (cp >= 0xD800 && cp <= 0xDFFF) return {char32_t(0xFFFD), len}; + if (cp > 0x10FFFF) return {char32_t(0xFFFD), len}; + + return {cp, len}; +} + +inline auto count_code_points(std::string_view str) -> std::size_t { + std::size_t count = 0; + std::size_t pos = 0; + while (pos < str.size()) { + auto [cp, consumed] = decode(str, pos); + if (consumed == 0) break; + (void)cp; + ++count; + pos += consumed; + } + return count; +} + +inline auto char_width(char32_t cp) -> int { + // Control characters + if (cp < 0x20) return 0; + // DEL + if (cp == 0x7F) return 0; + // ASCII printable + if (cp < 0x80) return 1; + + // CJK Unified Ideographs + if (cp >= 0x4E00 && cp <= 0x9FFF) return 2; + // CJK Compatibility Ideographs + if (cp >= 0xF900 && cp <= 0xFAFF) return 2; + // CJK Extensions A+B + if (cp >= 0x3400 && cp <= 0x4DBF) return 2; + // Halfwidth and Fullwidth Forms + if (cp >= 0xFF01 && cp <= 0xFF60) return 2; + if (cp >= 0xFFE0 && cp <= 0xFFE6) return 2; + // Hangul Syllables + if (cp >= 0xAC00 && cp <= 0xD7AF) return 2; + // Hangul Jamo + if (cp >= 0x1100 && cp <= 0x11FF) return 2; + // Katakana + Hiragana + if (cp >= 0x3040 && cp <= 0x309F) return 2; // Hiragana + if (cp >= 0x30A0 && cp <= 0x30FF) return 2; // Katakana + if (cp >= 0x31F0 && cp <= 0x31FF) return 2; // Katakana Phonetic Extensions + // Bopomofo + if (cp >= 0x3100 && cp <= 0x312F) return 2; + if (cp >= 0x31A0 && cp <= 0x31BF) return 2; + // Fullwidth digits/letters + if (cp >= 0xFF10 && cp <= 0xFF19) return 2; // fullwidth digits + if (cp >= 0xFF21 && cp <= 0xFF3A) return 2; // fullwidth upper + if (cp >= 0xFF41 && cp <= 0xFF5A) return 2; // fullwidth lower + // Various CJK range + if (cp >= 0x2E80 && cp <= 0x2EFF) return 2; // CJK Radicals Supplement + if (cp >= 0x2F00 && cp <= 0x2FDF) return 2; // Kangxi Radicals + if (cp >= 0x3000 && cp <= 0x303F) return 2; // CJK Symbols and Punctuation + // Combining marks — width 0 + if (cp >= 0x0300 && cp <= 0x036F) return 0; + if (cp >= 0x1AB0 && cp <= 0x1AFF) return 0; + if (cp >= 0x1DC0 && cp <= 0x1DFF) return 0; + if (cp >= 0x20D0 && cp <= 0x20FF) return 0; + if (cp >= 0xFE20 && cp <= 0xFE2F) return 0; + + return 1; +} + +inline auto display_width(std::string_view str) -> std::size_t { + std::size_t width = 0; + std::size_t pos = 0; + while (pos < str.size()) { + auto [cp, consumed] = decode(str, pos); + if (consumed == 0) break; + if (cp == '\t') { + // Tab stops at every 8 columns + width = (width / 8 + 1) * 8; + } else { + width += static_cast(char_width(cp)); + } + pos += consumed; + } + return width; +} + +inline auto truncate_width(std::string_view str, std::size_t max_width) -> std::string_view { + std::size_t width = 0; + std::size_t pos = 0; + while (pos < str.size()) { + auto [cp, consumed] = decode(str, pos); + if (consumed == 0) break; + + int cw = (cp == '\t') ? static_cast(((width / 8) + 1) * 8 - width) : char_width(cp); + if (width + static_cast(cw) > max_width) { + return str.substr(0, pos); + } + width += static_cast(cw); + pos += consumed; + } + return str; +} + +} // namespace cfbox::utf8 diff --git a/src/applets/cat.cpp b/src/applets/cat.cpp index 5ba8c17..b6efa89 100644 --- a/src/applets/cat.cpp +++ b/src/applets/cat.cpp @@ -2,10 +2,22 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "cat", + .version = CFBOX_VERSION_STRING, + .one_line = "concatenate files and print on the standard output", + .usage = "cat [OPTIONS] [FILE]...", + .options = " -n number all output lines\n" + " -b number nonempty output lines\n" + " -A show all nonprinting chars, display $ at end of line", + .extra = "", +}; + auto print_visible_char(unsigned char c) -> void { if (c >= 128) { std::fputs("M-", stdout); @@ -69,6 +81,9 @@ auto cat_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'A', false}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool n_flag = parsed.has('n'); bool b_flag = parsed.has('b'); bool A_flag = parsed.has('A'); diff --git a/src/applets/cp.cpp b/src/applets/cp.cpp index e3206f0..6d72f7d 100644 --- a/src/applets/cp.cpp +++ b/src/applets/cp.cpp @@ -4,10 +4,21 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "cp", + .version = CFBOX_VERSION_STRING, + .one_line = "copy files and directories", + .usage = "cp [OPTIONS] SOURCE... DEST", + .options = " -r copy directories recursively\n" + " -p preserve mode, ownership, and timestamps", + .extra = "", +}; + auto copy_preserve(const std::string& src, const std::string& dst) -> int { // Copy the file first auto copy_result = cfbox::fs::copy_file(src, dst); @@ -32,10 +43,13 @@ auto copy_preserve(const std::string& src, const std::string& dst) -> int { auto cp_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'r', false}, - cfbox::args::OptSpec{'p', false}, + cfbox::args::OptSpec{'r', false, "recursive"}, + cfbox::args::OptSpec{'p', false, "preserve"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool recursive = parsed.has('r'); bool preserve = parsed.has('p'); diff --git a/src/applets/echo.cpp b/src/applets/echo.cpp index 9643323..47a496b 100644 --- a/src/applets/echo.cpp +++ b/src/applets/echo.cpp @@ -3,6 +3,19 @@ #include #include +#include + +namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "echo", + .version = CFBOX_VERSION_STRING, + .one_line = "display a line of text", + .usage = "echo [OPTIONS] [STRING]...", + .options = " -n do not output the trailing newline\n" + " -e enable interpretation of backslash escapes", + .extra = "", +}; +} // namespace auto echo_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { @@ -10,6 +23,9 @@ auto echo_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'e', false}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool no_newline = parsed.has('n'); bool interpret = parsed.has('e'); diff --git a/src/applets/find.cpp b/src/applets/find.cpp index 09f9043..76ac175 100644 --- a/src/applets/find.cpp +++ b/src/applets/find.cpp @@ -14,8 +14,23 @@ #include #include +#include + namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "find", + .version = CFBOX_VERSION_STRING, + .one_line = "search for files in a directory hierarchy", + .usage = "find [PATH] [PREDICATE]...", + .options = " Predicates:\n" + " -name PATTERN match filename (glob)\n" + " -type [f|d|l] match file type\n" + " -maxdepth N descend at most N levels\n" + " -exec CMD {} ; execute command on matches", + .extra = "", +}; + // Simple fnmatch-style glob matching: supports * ? and literal chars auto glob_match(std::string_view pattern, std::string_view text) -> bool { std::size_t pi = 0, ti = 0; @@ -233,6 +248,13 @@ auto do_find(const std::filesystem::path& root, const std::vector& pr } // namespace auto find_main(int argc, char* argv[]) -> int { + // Handle --help/--version before any other processing + for (int i = 1; i < argc; ++i) { + std::string_view arg{argv[i]}; + if (arg == "--help") { cfbox::help::print_help(HELP); return 0; } + if (arg == "--version") { cfbox::help::print_version(HELP); return 0; } + } + if (argc < 2) { // default: find . return do_find(".", {}); diff --git a/src/applets/grep.cpp b/src/applets/grep.cpp index ba63b3f..6d0bc5b 100644 --- a/src/applets/grep.cpp +++ b/src/applets/grep.cpp @@ -13,10 +13,27 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "grep", + .version = CFBOX_VERSION_STRING, + .one_line = "search patterns in text", + .usage = "grep [OPTIONS] PATTERN [FILE]...", + .options = " -E extended regex\n" + " -i ignore case\n" + " -v invert match\n" + " -n print line numbers\n" + " -r recursive search\n" + " -c print only a count of matching lines\n" + " -l print only names of files with matches\n" + " -q quiet mode", + .extra = "", +}; + struct GrepOptions { bool extended = false; bool ignore_case = false; @@ -107,16 +124,19 @@ auto grep_recursive(const std::string& pattern, const GrepOptions& opts, auto grep_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'E', false}, - cfbox::args::OptSpec{'i', false}, - cfbox::args::OptSpec{'v', false}, - cfbox::args::OptSpec{'n', false}, - cfbox::args::OptSpec{'r', false}, - cfbox::args::OptSpec{'c', false}, - cfbox::args::OptSpec{'l', false}, - cfbox::args::OptSpec{'q', false}, + cfbox::args::OptSpec{'E', false, "extended-regexp"}, + cfbox::args::OptSpec{'i', false, "ignore-case"}, + cfbox::args::OptSpec{'v', false, "invert-match"}, + cfbox::args::OptSpec{'n', false, "line-number"}, + cfbox::args::OptSpec{'r', false, "recursive"}, + cfbox::args::OptSpec{'c', false, "count"}, + cfbox::args::OptSpec{'l', false, "files-with-matches"}, + cfbox::args::OptSpec{'q', false, "quiet"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + GrepOptions opts; opts.extended = parsed.has('E'); opts.ignore_case = parsed.has('i'); diff --git a/src/applets/head.cpp b/src/applets/head.cpp index 155dbe7..2a00c2e 100644 --- a/src/applets/head.cpp +++ b/src/applets/head.cpp @@ -3,10 +3,21 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "head", + .version = CFBOX_VERSION_STRING, + .one_line = "output the first part of files", + .usage = "head [OPTIONS] [FILE]...", + .options = " -n N output the first N lines (default 10)\n" + " -c N output the first N bytes", + .extra = "", +}; + auto head_lines(const std::vector& lines, long n) -> void { long count = (n >= 0) ? n : static_cast(lines.size()) + n; if (count < 0) count = 0; @@ -56,6 +67,9 @@ auto head_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'c', true}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool use_lines = true; long n_lines = 10; long n_bytes = 0; diff --git a/src/applets/init.cpp b/src/applets/init.cpp index bfa2640..9b2085a 100644 --- a/src/applets/init.cpp +++ b/src/applets/init.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -8,9 +9,20 @@ #include #include +#include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "init", + .version = CFBOX_VERSION_STRING, + .one_line = "system init for boot testing (PID 1)", + .usage = "init", + .options = "", + .extra = "Designed for QEMU-based boot testing. Mounts /proc, /sys, /dev\n" + "and runs applet smoke tests before powering off.", +}; + auto printf_flush [[gnu::format(printf, 1, 2)]] (const char* fmt, ...) -> int { va_list ap; va_start(ap, fmt); @@ -26,6 +38,9 @@ struct TestResult { }; auto run_smoke_tests(TestResult& result) -> void { + int tested = 0; + +#if CFBOX_ENABLE_ECHO // Build argv on the stack (no heap allocation) char echo_name[] = "echo"; char echo_arg[] = "hello"; @@ -38,7 +53,10 @@ auto run_smoke_tests(TestResult& result) -> void { printf_flush(" FAIL: echo\n"); ++result.fail; } + ++tested; +#endif +#if CFBOX_ENABLE_CAT char cat_name[] = "cat"; char cat_arg[] = "/proc/version"; char* cat_argv[] = { cat_name, cat_arg, nullptr }; @@ -50,7 +68,10 @@ auto run_smoke_tests(TestResult& result) -> void { printf_flush(" FAIL: cat\n"); ++result.fail; } + ++tested; +#endif +#if CFBOX_ENABLE_LS char ls_name[] = "ls"; char ls_arg[] = "/"; char* ls_argv[] = { ls_name, ls_arg, nullptr }; @@ -62,7 +83,10 @@ auto run_smoke_tests(TestResult& result) -> void { printf_flush(" FAIL: ls\n"); ++result.fail; } + ++tested; +#endif +#if CFBOX_ENABLE_WC char wc_name[] = "wc"; char wc_arg1[] = "-l"; char wc_arg2[] = "/proc/cpuinfo"; @@ -75,18 +99,26 @@ auto run_smoke_tests(TestResult& result) -> void { printf_flush(" FAIL: wc\n"); ++result.fail; } + ++tested; +#endif // Mark remaining applets as "tested via Level 1" - constexpr int skipped = 13; // 17 total - 4 tested - init itself - printf_flush(" (remaining %d applets verified by Level 1 QEMU user-mode)\n", skipped); - result.pass += skipped; + const int skipped = 17 - tested - 1; // 17 total - tested - init itself + if (skipped > 0) { + printf_flush(" (remaining %d applets verified by Level 1 QEMU user-mode)\n", skipped); + result.pass += skipped; + } } } // anonymous namespace auto init_main(int argc, char* argv[]) -> int { - (void)argc; - (void)argv; + // Handle --help/--version (no args::parse for init) + for (int i = 1; i < argc; ++i) { + std::string_view arg{argv[i]}; + if (arg == "--help") { cfbox::help::print_help(HELP); return 0; } + if (arg == "--version") { cfbox::help::print_version(HELP); return 0; } + } bool is_pid1 = (getpid() == 1); diff --git a/src/applets/ls.cpp b/src/applets/ls.cpp index 09770fa..d9ee628 100644 --- a/src/applets/ls.cpp +++ b/src/applets/ls.cpp @@ -8,6 +8,7 @@ #include #include +#include namespace { @@ -209,15 +210,29 @@ auto list_path(const std::string& path, const LsOptions& opts, bool show_header) return list_directory(path, opts); } +constexpr cfbox::help::HelpEntry HELP = { + .name = "ls", + .version = CFBOX_VERSION_STRING, + .one_line = "list directory contents", + .usage = "ls [OPTIONS] [FILE]...", + .options = " -a do not ignore entries starting with .\n" + " -l use a long listing format\n" + " -h print sizes in human readable format", + .extra = "", +}; + } // namespace auto ls_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'a', false}, - cfbox::args::OptSpec{'l', false}, - cfbox::args::OptSpec{'h', false}, + cfbox::args::OptSpec{'a', false, "all"}, + cfbox::args::OptSpec{'l', false, "long"}, + cfbox::args::OptSpec{'h', false, "human-readable"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + LsOptions opts; opts.all = parsed.has('a'); opts.long_format = parsed.has('l'); diff --git a/src/applets/mkdir.cpp b/src/applets/mkdir.cpp index 32ccb9a..21438c1 100644 --- a/src/applets/mkdir.cpp +++ b/src/applets/mkdir.cpp @@ -5,9 +5,20 @@ #include #include +#include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "mkdir", + .version = CFBOX_VERSION_STRING, + .one_line = "create directories", + .usage = "mkdir [OPTIONS] DIRECTORY...", + .options = " -p no error if existing, make parent directories as needed\n" + " -m MODE set file mode (as in chmod), not a=rwx - umask", + .extra = "", +}; + auto parse_mode(std::string_view mode_str) -> std::filesystem::perms { unsigned long mode = std::strtoul(std::string{mode_str}.c_str(), nullptr, 8); return static_cast(mode); @@ -17,10 +28,13 @@ auto parse_mode(std::string_view mode_str) -> std::filesystem::perms { auto mkdir_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'p', false}, - cfbox::args::OptSpec{'m', true}, + cfbox::args::OptSpec{'p', false, "parents"}, + cfbox::args::OptSpec{'m', true, "mode"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool recursive = parsed.has('p'); auto mode_val = std::filesystem::perms::all; diff --git a/src/applets/mv.cpp b/src/applets/mv.cpp index 65c78b1..606a11d 100644 --- a/src/applets/mv.cpp +++ b/src/applets/mv.cpp @@ -4,9 +4,19 @@ #include #include +#include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "mv", + .version = CFBOX_VERSION_STRING, + .one_line = "move or rename files", + .usage = "mv [OPTIONS] SOURCE... DEST", + .options = " -f do not prompt before overwriting", + .extra = "", +}; + auto do_move(const std::string& src, const std::string& dst, bool force) -> int { if (!cfbox::fs::exists(src)) { std::fprintf(stderr, "cfbox mv: cannot stat '%s': No such file or directory\n", @@ -71,6 +81,9 @@ auto mv_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'f', false}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool force = parsed.has('f'); const auto& pos = parsed.positional(); diff --git a/src/applets/printf.cpp b/src/applets/printf.cpp index c9ea86d..99c5611 100644 --- a/src/applets/printf.cpp +++ b/src/applets/printf.cpp @@ -5,9 +5,19 @@ #include #include +#include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "printf", + .version = CFBOX_VERSION_STRING, + .one_line = "format and print data", + .usage = "printf FORMAT [ARG]...", + .options = " FORMAT printf format string", + .extra = "", +}; + // Extract a full format spec: %[flags][width][.precision]specifier // Returns (format_str, arg_consumed) where format_str is like "%.2f" auto extract_spec(std::string_view fmt, std::size_t pos) @@ -125,6 +135,10 @@ auto count_specs(std::string_view fmt) -> std::size_t { auto printf_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, {}); + + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + const auto& pos = parsed.positional(); if (pos.empty()) return 0; diff --git a/src/applets/rm.cpp b/src/applets/rm.cpp index be7af5a..3978af4 100644 --- a/src/applets/rm.cpp +++ b/src/applets/rm.cpp @@ -5,9 +5,20 @@ #include #include +#include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "rm", + .version = CFBOX_VERSION_STRING, + .one_line = "remove files or directories", + .usage = "rm [OPTIONS] FILE...", + .options = " -r remove directories and their contents recursively\n" + " -f ignore nonexistent files, never prompt", + .extra = "", +}; + auto is_root_path(std::string_view path) -> bool { // Remove trailing slashes for comparison std::string_view p = path; @@ -21,11 +32,14 @@ auto is_root_path(std::string_view path) -> bool { auto rm_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'r', false}, - cfbox::args::OptSpec{'f', false}, - cfbox::args::OptSpec{'i', false}, + cfbox::args::OptSpec{'r', false, "recursive"}, + cfbox::args::OptSpec{'f', false, "force"}, + cfbox::args::OptSpec{'i', false, "interactive"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool recursive = parsed.has('r'); bool force = parsed.has('f'); // -i: interactive (MVP: auto-confirm) diff --git a/src/applets/sed.cpp b/src/applets/sed.cpp index daadb45..f88e3ac 100644 --- a/src/applets/sed.cpp +++ b/src/applets/sed.cpp @@ -11,10 +11,21 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "sed", + .version = CFBOX_VERSION_STRING, + .one_line = "stream editor for filtering and transforming text", + .usage = "sed [OPTIONS] SCRIPT [FILE]...", + .options = " -e SCRIPT add the script to the commands to be executed\n" + " -n suppress automatic printing of pattern space", + .extra = "", +}; + struct Address { enum Type { None, Line, Last, Range }; Type type = None; @@ -280,6 +291,9 @@ auto sed_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'e', true}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool suppress = parsed.has('n'); std::string script; diff --git a/src/applets/sort.cpp b/src/applets/sort.cpp index 7943150..51cb20a 100644 --- a/src/applets/sort.cpp +++ b/src/applets/sort.cpp @@ -6,10 +6,22 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "sort", + .version = CFBOX_VERSION_STRING, + .one_line = "sort lines of text", + .usage = "sort [OPTIONS] [FILE]...", + .options = " -r reverse the result of comparisons\n" + " -n compare according to string numerical value\n" + " -u output only the first of an equal run", + .extra = "", +}; + struct SortOptions { bool reverse = false; bool numeric = false; @@ -65,12 +77,15 @@ auto sort_lines(std::vector& lines, const SortOptions& opts) -> voi auto sort_main(int argc, char* argv[]) -> int { auto parsed = cfbox::args::parse(argc, argv, { - cfbox::args::OptSpec{'r', false}, - cfbox::args::OptSpec{'n', false}, - cfbox::args::OptSpec{'u', false}, - cfbox::args::OptSpec{'k', true}, + cfbox::args::OptSpec{'r', false, "reverse"}, + cfbox::args::OptSpec{'n', false, "numeric-sort"}, + cfbox::args::OptSpec{'u', false, "unique"}, + cfbox::args::OptSpec{'k', true, "key"}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + SortOptions opts; opts.reverse = parsed.has('r'); opts.numeric = parsed.has('n'); diff --git a/src/applets/tail.cpp b/src/applets/tail.cpp index 9172359..5aa2e93 100644 --- a/src/applets/tail.cpp +++ b/src/applets/tail.cpp @@ -3,10 +3,21 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "tail", + .version = CFBOX_VERSION_STRING, + .one_line = "output the last part of files", + .usage = "tail [OPTIONS] [FILE]...", + .options = " -n N output the last N lines (default 10)\n" + " -c N output the last N bytes", + .extra = "", +}; + auto tail_lines(const std::vector& lines, long n, bool from_start) -> void { if (from_start) { long start = n - 1; @@ -72,6 +83,9 @@ auto tail_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'c', true}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool use_lines = true; bool use_bytes = false; bool from_start = false; diff --git a/src/applets/uniq.cpp b/src/applets/uniq.cpp index 0ab2113..a18975a 100644 --- a/src/applets/uniq.cpp +++ b/src/applets/uniq.cpp @@ -4,10 +4,22 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "uniq", + .version = CFBOX_VERSION_STRING, + .one_line = "report or omit repeated lines", + .usage = "uniq [OPTIONS] [INPUT [OUTPUT]]", + .options = " -c prefix lines by the number of occurrences\n" + " -d only print duplicate lines\n" + " -u only print unique lines", + .extra = "", +}; + struct UniqOptions { bool count = false; bool repeated = false; @@ -51,6 +63,9 @@ auto uniq_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'u', false}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + UniqOptions opts; opts.count = parsed.has('c'); opts.repeated = parsed.has('d'); diff --git a/src/applets/wc.cpp b/src/applets/wc.cpp index c3f8f68..709ed14 100644 --- a/src/applets/wc.cpp +++ b/src/applets/wc.cpp @@ -3,10 +3,23 @@ #include #include +#include #include namespace { +constexpr cfbox::help::HelpEntry HELP = { + .name = "wc", + .version = CFBOX_VERSION_STRING, + .one_line = "print newline, word, and byte counts", + .usage = "wc [OPTIONS] [FILE]...", + .options = " -l print newline counts\n" + " -w print word counts\n" + " -c print byte counts\n" + " -m print character counts (alias for -c)", + .extra = "", +}; + struct WcCounts { long lines = 0; long words = 0; @@ -50,6 +63,9 @@ auto wc_main(int argc, char* argv[]) -> int { cfbox::args::OptSpec{'m', false}, }); + if (parsed.has_long("help")) { cfbox::help::print_help(HELP); return 0; } + if (parsed.has_long("version")) { cfbox::help::print_version(HELP); return 0; } + bool show_lines = parsed.has('l'); bool show_words = parsed.has('w'); bool show_bytes = parsed.has('c'); diff --git a/src/main.cpp b/src/main.cpp index 51ba767..020f843 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,6 +3,7 @@ #include #include +#include namespace { @@ -45,6 +46,10 @@ auto main(int argc, char* argv[]) -> int { print_list(); return 0; } + if (cmd == "--version") { + cfbox::help::print_cfbox_version(); + return 0; + } if (cmd == "--help") { print_help(argv[0]); return 0; diff --git a/tests/integration/test_help.sh b/tests/integration/test_help.sh new file mode 100755 index 0000000..3f4de52 --- /dev/null +++ b/tests/integration/test_help.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail +source "$(dirname "$0")/helpers.sh" + +CFBOX="${CFBOX:-./build/cfbox}" + +pass=0 +fail=0 + +# Test --help for each applet +for applet in echo printf cat head tail wc sort uniq mkdir rm cp mv ls grep find sed init; do + if "$CFBOX" "$applet" --help >/dev/null 2>&1; then + ((++pass)) + else + echo "FAIL: $applet --help returned non-zero" + ((++fail)) + fi + + # Verify help output contains the applet name + out=$("$CFBOX" "$applet" --help 2>&1) + if [[ "$out" == *"$applet"* ]]; then + ((++pass)) + else + echo "FAIL: $applet --help output does not contain applet name" + ((++fail)) + fi +done + +# Test --version for each applet +for applet in echo printf cat head tail wc sort uniq mkdir rm cp mv ls grep find sed init; do + if "$CFBOX" "$applet" --version >/dev/null 2>&1; then + ((++pass)) + else + echo "FAIL: $applet --version returned non-zero" + ((++fail)) + fi + + out=$("$CFBOX" "$applet" --version 2>&1) + if [[ "$out" == "cfbox $applet"* ]]; then + ((++pass)) + else + echo "FAIL: $applet --version output unexpected: $out" + ((++fail)) + fi +done + +echo "--- help tests: $pass passed, $fail failed ---" +if [[ $fail -gt 0 ]]; then exit 1; fi diff --git a/tests/unit/test_args.cpp b/tests/unit/test_args.cpp index baec307..084cec7 100644 --- a/tests/unit/test_args.cpp +++ b/tests/unit/test_args.cpp @@ -130,3 +130,122 @@ TEST(ArgsTest, ValueFlagMissingValue) { ASSERT_TRUE(r.get('n').has_value()); EXPECT_EQ(r.get('n').value(), ""); } + +// ── long option: bool flag ─────────────────────────────────── + +TEST(ArgsTest, LongBoolFlag) { + char a0[] = "prog", a1[] = "--recursive"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {OptSpec{'r', false, "recursive"}}); + EXPECT_TRUE(r.has('r')); + EXPECT_TRUE(r.has_long("recursive")); +} + +TEST(ArgsTest, LongBoolFlagHasAny) { + char a0[] = "prog", a1[] = "--recursive"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {OptSpec{'r', false, "recursive"}}); + EXPECT_TRUE(r.has_any('r', "recursive")); +} + +TEST(ArgsTest, ShortFlagHasAny) { + char a0[] = "prog", a1[] = "-r"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {OptSpec{'r', false, "recursive"}}); + EXPECT_TRUE(r.has_any('r', "recursive")); +} + +// ── long option: value via = ────────────────────────────────── + +TEST(ArgsTest, LongValueEquals) { + char a0[] = "prog", a1[] = "--name=foo"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {OptSpec{'n', true, "name"}}); + EXPECT_TRUE(r.has('n')); + ASSERT_TRUE(r.get('n').has_value()); + EXPECT_EQ(r.get('n').value(), "foo"); + EXPECT_TRUE(r.has_long("name")); + ASSERT_TRUE(r.get_long("name").has_value()); + EXPECT_EQ(r.get_long("name").value(), "foo"); +} + +// ── long option: value via separate arg ─────────────────────── + +TEST(ArgsTest, LongValueSeparate) { + char a0[] = "prog", a1[] = "--name", a2[] = "bar"; + char* argv[] = {a0, a1, a2}; + auto r = parse(3, argv, {OptSpec{'n', true, "name"}}); + EXPECT_TRUE(r.has('n')); + ASSERT_TRUE(r.get('n').has_value()); + EXPECT_EQ(r.get('n').value(), "bar"); +} + +// ── unregistered long option (--help, --version) ───────────── + +TEST(ArgsTest, UnregisteredLongOption) { + char a0[] = "prog", a1[] = "--help"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {}); + EXPECT_TRUE(r.has_long("help")); + EXPECT_FALSE(r.get_long("help").has_value()); +} + +TEST(ArgsTest, UnregisteredLongOptionWithValue) { + char a0[] = "prog", a1[] = "--version"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {}); + EXPECT_TRUE(r.has_long("version")); +} + +// ── long + short mixed ─────────────────────────────────────── + +TEST(ArgsTest, LongAndShortMixed) { + char a0[] = "prog", a1[] = "-r", a2[] = "--force", a3[] = "file"; + char* argv[] = {a0, a1, a2, a3}; + auto r = parse(4, argv, {OptSpec{'r', false, "recursive"}, OptSpec{'f', false, "force"}}); + EXPECT_TRUE(r.has('r')); + EXPECT_TRUE(r.has_long("force")); + EXPECT_TRUE(r.has('f')); + ASSERT_EQ(r.positional().size(), 1u); + EXPECT_EQ(r.positional()[0], "file"); +} + +// ── get_any works for both forms ───────────────────────────── + +TEST(ArgsTest, GetAnyLongForm) { + char a0[] = "prog", a1[] = "--mode=fast"; + char* argv[] = {a0, a1}; + auto r = parse(2, argv, {OptSpec{'m', true, "mode"}}); + ASSERT_TRUE(r.get_any('m', "mode").has_value()); + EXPECT_EQ(r.get_any('m', "mode").value(), "fast"); +} + +TEST(ArgsTest, GetAnyShortForm) { + char a0[] = "prog", a1[] = "-m", a2[] = "fast"; + char* argv[] = {a0, a1, a2}; + auto r = parse(3, argv, {OptSpec{'m', true, "mode"}}); + ASSERT_TRUE(r.get_any('m', "mode").has_value()); + EXPECT_EQ(r.get_any('m', "mode").value(), "fast"); +} + +// ── double dash still stops with long option specs ──────────── + +TEST(ArgsTest, DoubleDashStopsWithLongOptions) { + char a0[] = "prog", a1[] = "--", a2[] = "--recursive"; + char* argv[] = {a0, a1, a2}; + auto r = parse(3, argv, {OptSpec{'r', false, "recursive"}}); + EXPECT_FALSE(r.has('r')); + EXPECT_FALSE(r.has_long("recursive")); + ASSERT_EQ(r.positional().size(), 1u); + EXPECT_EQ(r.positional()[0], "--recursive"); +} + +// ── OptSpec backward compatibility: no long_name still works ─ + +TEST(ArgsTest, OptSpecNoLongNameBackCompat) { + char a0[] = "prog", a1[] = "-n", a2[] = "hello"; + char* argv[] = {a0, a1, a2}; + auto r = parse(3, argv, {OptSpec{'n', false}}); + EXPECT_TRUE(r.has('n')); + EXPECT_EQ(r.positional()[0], "hello"); +} diff --git a/tests/unit/test_cp.cpp b/tests/unit/test_cp.cpp index 2cdbc81..b43d8b1 100644 --- a/tests/unit/test_cp.cpp +++ b/tests/unit/test_cp.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_CP using namespace cfbox::test; @@ -63,3 +66,5 @@ TEST(CpTest, SourceNotExist) { char* argv[] = {a0, a1, a2}; EXPECT_NE(cp_main(3, argv), 0); } + +#endif // CFBOX_ENABLE_CP diff --git a/tests/unit/test_find.cpp b/tests/unit/test_find.cpp index 71222b2..cbdc7f0 100644 --- a/tests/unit/test_find.cpp +++ b/tests/unit/test_find.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_FIND using namespace cfbox::test; @@ -84,3 +87,5 @@ TEST(FindTest, NameAndTypeCombined) { EXPECT_NE(out.find("data.txt"), std::string::npos); EXPECT_EQ(out.find("docs.txt"), std::string::npos); } + +#endif // CFBOX_ENABLE_FIND diff --git a/tests/unit/test_grep.cpp b/tests/unit/test_grep.cpp index e36dd4f..7a2a5d0 100644 --- a/tests/unit/test_grep.cpp +++ b/tests/unit/test_grep.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_GREP using namespace cfbox::test; @@ -99,3 +102,5 @@ TEST(GrepTest, MissingPattern) { capture_stdout([&]{ rc = grep_main(1, argv); return 0; }); EXPECT_EQ(rc, 2); } + +#endif // CFBOX_ENABLE_GREP diff --git a/tests/unit/test_help.cpp b/tests/unit/test_help.cpp new file mode 100644 index 0000000..3d72181 --- /dev/null +++ b/tests/unit/test_help.cpp @@ -0,0 +1,105 @@ +#include +#include +#include + +#include +#include + +#include "test_capture.hpp" + +TEST(HelpTest, PrintHelpContainsName) { + constexpr cfbox::help::HelpEntry entry = { + .name = "testapp", + .version = "1.0", + .one_line = "a test applet", + .usage = "testapp [OPTIONS]", + .options = " -v verbose", + .extra = "", + }; + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_help(entry); + return 0; + }); + EXPECT_NE(out.find("testapp"), std::string::npos); + EXPECT_NE(out.find("a test applet"), std::string::npos); + EXPECT_NE(out.find("testapp [OPTIONS]"), std::string::npos); + EXPECT_NE(out.find("-v"), std::string::npos); + EXPECT_NE(out.find("--help"), std::string::npos); + EXPECT_NE(out.find("--version"), std::string::npos); +} + +TEST(HelpTest, PrintHelpWithExtra) { + constexpr cfbox::help::HelpEntry entry = { + .name = "foo", + .version = "0.1", + .one_line = "does foo", + .usage = "foo", + .options = "", + .extra = "Note: foo is experimental", + }; + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_help(entry); + return 0; + }); + EXPECT_NE(out.find("experimental"), std::string::npos); +} + +TEST(HelpTest, PrintVersion) { + constexpr cfbox::help::HelpEntry entry = { + .name = "echo", + .version = "0.0.1", + .one_line = "", + .usage = "", + .options = "", + .extra = "", + }; + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_version(entry); + return 0; + }); + EXPECT_NE(out.find("cfbox echo 0.0.1"), std::string::npos); +} + +TEST(HelpTest, PrintCfboxVersion) { + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_cfbox_version(); + return 0; + }); + EXPECT_NE(out.find("cfbox"), std::string::npos); +} + +TEST(HelpTest, ColorDisabledOutput) { + cfbox::term::set_color_enabled(false); + constexpr cfbox::help::HelpEntry entry = { + .name = "test", + .version = "1.0", + .one_line = "test", + .usage = "test", + .options = "", + .extra = "", + }; + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_help(entry); + return 0; + }); + EXPECT_EQ(out.find("\033["), std::string::npos); + cfbox::term::reset_color_enabled(); +} + +TEST(HelpTest, ColorEnabledOutput) { + cfbox::term::set_color_enabled(true); + constexpr cfbox::help::HelpEntry entry = { + .name = "test", + .version = "1.0", + .one_line = "test", + .usage = "test", + .options = "", + .extra = "", + }; + auto out = cfbox::test::capture_stdout([&]()->int { + cfbox::help::print_help(entry); + return 0; + }); + EXPECT_NE(out.find("\033["), std::string::npos); + cfbox::term::reset_color_enabled(); +} diff --git a/tests/unit/test_ls.cpp b/tests/unit/test_ls.cpp index bb6ea19..732019e 100644 --- a/tests/unit/test_ls.cpp +++ b/tests/unit/test_ls.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_LS using namespace cfbox::test; @@ -71,3 +74,5 @@ TEST(LsTest, SingleFileNoDirectory) { auto out = capture_stdout([&]{ return ls_main(2, argv); }); EXPECT_EQ(out, "only.txt\n"); } + +#endif // CFBOX_ENABLE_LS diff --git a/tests/unit/test_mkdir.cpp b/tests/unit/test_mkdir.cpp index 112fa96..c82d60b 100644 --- a/tests/unit/test_mkdir.cpp +++ b/tests/unit/test_mkdir.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_MKDIR using namespace cfbox::test; @@ -52,3 +55,5 @@ TEST(MkdirTest, MultipleDirs) { EXPECT_TRUE(std::filesystem::exists(tmp.path / "dir1")); EXPECT_TRUE(std::filesystem::exists(tmp.path / "dir2")); } + +#endif // CFBOX_ENABLE_MKDIR diff --git a/tests/unit/test_mv.cpp b/tests/unit/test_mv.cpp index bcabe38..653f3b5 100644 --- a/tests/unit/test_mv.cpp +++ b/tests/unit/test_mv.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_MV using namespace cfbox::test; @@ -49,3 +52,5 @@ TEST(MvTest, MissingOperands) { char* argv[] = {a0}; EXPECT_NE(mv_main(1, argv), 0); } + +#endif // CFBOX_ENABLE_MV diff --git a/tests/unit/test_rm.cpp b/tests/unit/test_rm.cpp index b0436b3..1615a57 100644 --- a/tests/unit/test_rm.cpp +++ b/tests/unit/test_rm.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_RM using namespace cfbox::test; @@ -59,3 +62,5 @@ TEST(RmTest, MissingOperand) { char* argv[] = {a0}; EXPECT_NE(rm_main(1, argv), 0); } + +#endif // CFBOX_ENABLE_RM diff --git a/tests/unit/test_sed.cpp b/tests/unit/test_sed.cpp index 24f34ab..7c3097c 100644 --- a/tests/unit/test_sed.cpp +++ b/tests/unit/test_sed.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_SED using namespace cfbox::test; @@ -108,3 +111,5 @@ TEST(SedTest, MissingScript) { capture_stdout([&]{ rc = sed_main(1, argv); return 0; }); EXPECT_NE(rc, 0); } + +#endif // CFBOX_ENABLE_SED diff --git a/tests/unit/test_sort.cpp b/tests/unit/test_sort.cpp index 913a5e5..c4669ec 100644 --- a/tests/unit/test_sort.cpp +++ b/tests/unit/test_sort.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_SORT using namespace cfbox::test; @@ -75,3 +78,5 @@ TEST(SortTest, EmptyFile) { auto out = capture_stdout([&]{ return sort_main(2, argv); }); EXPECT_EQ(out, ""); } + +#endif // CFBOX_ENABLE_SORT diff --git a/tests/unit/test_term.cpp b/tests/unit/test_term.cpp new file mode 100644 index 0000000..e89a224 --- /dev/null +++ b/tests/unit/test_term.cpp @@ -0,0 +1,48 @@ +#include + +#include + +TEST(TermTest, ColorDisabledReturnsEmpty) { + cfbox::term::set_color_enabled(false); + EXPECT_TRUE(cfbox::term::red().empty()); + EXPECT_TRUE(cfbox::term::green().empty()); + EXPECT_TRUE(cfbox::term::bold().empty()); + EXPECT_TRUE(cfbox::term::reset().empty()); + cfbox::term::reset_color_enabled(); +} + +TEST(TermTest, ColorEnabledReturnsCodes) { + cfbox::term::set_color_enabled(true); + EXPECT_EQ(cfbox::term::red(), "\033[31m"); + EXPECT_EQ(cfbox::term::green(), "\033[32m"); + EXPECT_EQ(cfbox::term::yellow(), "\033[33m"); + EXPECT_EQ(cfbox::term::blue(), "\033[34m"); + EXPECT_EQ(cfbox::term::magenta(), "\033[35m"); + EXPECT_EQ(cfbox::term::cyan(), "\033[36m"); + EXPECT_EQ(cfbox::term::bold(), "\033[1m"); + EXPECT_EQ(cfbox::term::dim(), "\033[2m"); + EXPECT_EQ(cfbox::term::underline(), "\033[4m"); + EXPECT_EQ(cfbox::term::reset(), "\033[0m"); + cfbox::term::reset_color_enabled(); +} + +TEST(TermTest, ColoredWhenDisabled) { + cfbox::term::set_color_enabled(false); + EXPECT_EQ(cfbox::term::colored("hello", cfbox::term::red()), "hello"); + cfbox::term::reset_color_enabled(); +} + +TEST(TermTest, ColoredWhenEnabled) { + cfbox::term::set_color_enabled(true); + auto result = cfbox::term::colored("hello", "\033[31m"); + EXPECT_EQ(result, "\033[31mhello\033[0m"); + cfbox::term::reset_color_enabled(); +} + +TEST(TermTest, SetOverrideWorks) { + cfbox::term::set_color_enabled(true); + EXPECT_TRUE(cfbox::term::color_enabled()); + cfbox::term::set_color_enabled(false); + EXPECT_FALSE(cfbox::term::color_enabled()); + cfbox::term::reset_color_enabled(); +} diff --git a/tests/unit/test_uniq.cpp b/tests/unit/test_uniq.cpp index 62d91b7..448b248 100644 --- a/tests/unit/test_uniq.cpp +++ b/tests/unit/test_uniq.cpp @@ -1,6 +1,9 @@ #include #include #include "test_capture.hpp" +#include + +#if CFBOX_ENABLE_UNIQ using namespace cfbox::test; @@ -63,3 +66,5 @@ TEST(UniqTest, EmptyFile) { auto out = capture_stdout([&]{ return uniq_main(2, argv); }); EXPECT_EQ(out, ""); } + +#endif // CFBOX_ENABLE_UNIQ diff --git a/tests/unit/test_utf8.cpp b/tests/unit/test_utf8.cpp new file mode 100644 index 0000000..395ae1c --- /dev/null +++ b/tests/unit/test_utf8.cpp @@ -0,0 +1,114 @@ +#include + +#include + +TEST(Utf8Test, IsContinuation) { + EXPECT_TRUE(cfbox::utf8::is_continuation(0x80)); + EXPECT_TRUE(cfbox::utf8::is_continuation(0xBF)); + EXPECT_FALSE(cfbox::utf8::is_continuation(0x7F)); + EXPECT_FALSE(cfbox::utf8::is_continuation(0xC0)); + EXPECT_FALSE(cfbox::utf8::is_continuation(0x00)); +} + +TEST(Utf8Test, DecodeAscii) { + auto r = cfbox::utf8::decode("hello", 0); + EXPECT_EQ(r.code_point, U'h'); + EXPECT_EQ(r.bytes_consumed, 1); +} + +TEST(Utf8Test, DecodeTwoByte) { + // U+00E9 = é = 0xC3 0xA9 + auto r = cfbox::utf8::decode("\xC3\xA9", 0); + EXPECT_EQ(r.code_point, U'\u00E9'); + EXPECT_EQ(r.bytes_consumed, 2); +} + +TEST(Utf8Test, DecodeThreeByteCJK) { + // U+4E2D = 中 = 0xE4 0xB8 0xAD + auto r = cfbox::utf8::decode("\xE4\xB8\xAD", 0); + EXPECT_EQ(r.code_point, U'\u4E2D'); + EXPECT_EQ(r.bytes_consumed, 3); +} + +TEST(Utf8Test, DecodeFourByteEmoji) { + // U+1F600 = 😀 = 0xF0 0x9F 0x98 0x80 + auto r = cfbox::utf8::decode("\xF0\x9F\x98\x80", 0); + EXPECT_EQ(r.code_point, U'\U0001F600'); + EXPECT_EQ(r.bytes_consumed, 4); +} + +TEST(Utf8Test, DecodeInvalidContinuation) { + auto r = cfbox::utf8::decode("\x80", 0); + EXPECT_EQ(r.code_point, char32_t(0xFFFD)); + EXPECT_EQ(r.bytes_consumed, 1); +} + +TEST(Utf8Test, CountCodePointsAscii) { + EXPECT_EQ(cfbox::utf8::count_code_points("hello"), 5u); + EXPECT_EQ(cfbox::utf8::count_code_points(""), 0u); +} + +TEST(Utf8Test, CountCodePointsMixed) { + // "café" = c a f é (4 code points) + const char cafe[] = "caf\xc3\xa9"; + EXPECT_EQ(cfbox::utf8::count_code_points(cafe), 4u); +} + +TEST(Utf8Test, CountCodePointsCJK) { + // "中文" = 2 code points + const char zh[] = "\xE4\xB8\xAD\xE6\x96\x87"; + EXPECT_EQ(cfbox::utf8::count_code_points(zh), 2u); +} + +TEST(Utf8Test, CharWidthAscii) { + EXPECT_EQ(cfbox::utf8::char_width(U'A'), 1); + EXPECT_EQ(cfbox::utf8::char_width(U' '), 1); +} + +TEST(Utf8Test, CharWidthControl) { + EXPECT_EQ(cfbox::utf8::char_width(U'\n'), 0); + EXPECT_EQ(cfbox::utf8::char_width(U'\t'), 0); + EXPECT_EQ(cfbox::utf8::char_width(char32_t(0x7F)), 0); // DEL +} + +TEST(Utf8Test, CharWidthCJK) { + EXPECT_EQ(cfbox::utf8::char_width(U'\u4E2D'), 2); // 中 + EXPECT_EQ(cfbox::utf8::char_width(U'\u6587'), 2); // 文 +} + +TEST(Utf8Test, CharWidthCombining) { + EXPECT_EQ(cfbox::utf8::char_width(U'\u0300'), 0); // combining grave accent +} + +TEST(Utf8Test, DisplayWidthAscii) { + EXPECT_EQ(cfbox::utf8::display_width("abc"), 3u); +} + +TEST(Utf8Test, DisplayWidthMixed) { + // "中abc" = 2 + 1 + 1 + 1 = 5 + const char s[] = "\xE4\xB8\xAD" + "abc"; + EXPECT_EQ(cfbox::utf8::display_width(s), 5u); +} + +TEST(Utf8Test, TruncateWidthAscii) { + EXPECT_EQ(cfbox::utf8::truncate_width("abcdef", 3), "abc"); + EXPECT_EQ(cfbox::utf8::truncate_width("abc", 5), "abc"); +} + +TEST(Utf8Test, TruncateWidthCJK) { + // "中文abc" — truncate to width 4 should give "中文" (width 4) + const char input[] = "\xE4\xB8\xAD\xE6\x96\x87" + "abc"; + const char expected[] = "\xE4\xB8\xAD\xE6\x96\x87"; + EXPECT_EQ(cfbox::utf8::truncate_width(input, 4), expected); +} + +TEST(Utf8Test, TruncateWidthDoesNotSplitCodePoint) { + // "中abc" — truncate to width 3 should give "中a" (2+1=3), not split the CJK char + const char input[] = "\xE4\xB8\xAD" + "abc"; + const char expected[] = "\xE4\xB8\xAD" + "a"; + EXPECT_EQ(cfbox::utf8::truncate_width(input, 3), expected); +} From 5fdb8e948991e42044bf007480b09be6cbab415d Mon Sep 17 00:00:00 2001 From: Charliechen114514 <725610365@qq.com> Date: Mon, 20 Apr 2026 11:24:19 +0800 Subject: [PATCH 2/2] ci: all branch runs same test --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa39016..5238c39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,9 +184,8 @@ jobs: chmod +x "$wrapper" CFBOX="$wrapper" bash tests/integration/run_all.sh - # ── QEMU system-mode emulation tests (main / release-* only) ───── + # ── QEMU system-mode emulation tests ───── qemu-system-test: - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-') needs: cross-compile runs-on: ubuntu-latest strategy: