diff --git a/.github/workflows/zig_test.yml b/.github/workflows/zig_test.yml index 849f6c0..aa21a2b 100644 --- a/.github/workflows/zig_test.yml +++ b/.github/workflows/zig_test.yml @@ -1,35 +1,55 @@ -# This is a basic workflow to help you get started with Actions - name: CI -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the "main" branch push: - branches: ["main"] + branches: [main] pull_request: - branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab + branches: [main] workflow_dispatch: +permissions: + contents: read + jobs: test: + name: Test (${{ matrix.os }}) strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{matrix.os}} + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - uses: mlugg/setup-zig@v1 + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 with: version: 0.16.0 - run: zig build test - lint: + + cross-build: + name: Build (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-linux + - aarch64-linux + - x86_64-windows + - aarch64-macos + steps: + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + cache-key: ${{ matrix.target }} + - run: zig build -Dtarget=${{ matrix.target }} + + format: + name: Formatting runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: mlugg/setup-zig@v1 + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 with: version: 0.16.0 - run: zig fmt --check . diff --git a/README.md b/README.md index c2d9b0c..eca546c 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,280 @@ # Chroma -**Zig Version:** 0.16.0
-**License:** MIT
-**Language:** [Zig](https://ziglang.org) +[![CI](https://github.com/adia-dev/chroma-zig/actions/workflows/zig_test.yml/badge.svg)](https://github.com/adia-dev/chroma-zig/actions/workflows/zig_test.yml) +[![Zig 0.16.0](https://img.shields.io/badge/Zig-0.16.0-f7a41d?logo=zig&logoColor=white)](https://ziglang.org/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +Chroma is a comptime-first ANSI color and terminal text styling library for +Zig. It turns namespaced directives such as `{#bold,red}` into constant escape +sequences while leaving Zig fields such as `{s}` and `{d}` untouched. It is +designed for colorful CLI tools, console applications, and logs without +runtime parsing or allocation. + +![Configurable Chroma theme rendered in a terminal](docs/assets/chroma-configurable.png) + +This image is captured from the real `zig build run` output and can be +regenerated with [`docs/chroma.tape`](docs/chroma.tape) using +[VHS](https://github.com/charmbracelet/vhs). + +## Features + +- Formatting is parsed, validated, and rendered at compile time. +- ANSI and plain variants are constants; terminal selection requires only a + runtime branch. +- Standard, bright, 256-color, and 24-bit RGB colors are supported for both + foregrounds and backgrounds. +- Typed ZON themes provide reusable semantic styles and configurable grammar + characters without runtime file I/O. +- Unknown Chroma directives and malformed color values fail with focused + compile-time diagnostics. +- Terminal detection honors `NO_COLOR`, `CLICOLOR_FORCE`, redirected output, + and Windows virtual-terminal support. + +## Installation + +Add the package to `build.zig.zon`: + +```sh +zig fetch --save=chroma https://github.com/adia-dev/chroma-zig/archive/refs/tags/v0.2.0.tar.gz +``` + +Import Chroma's module in `build.zig`: + +```zig +const chroma_dep = b.dependency("chroma", .{ + .target = target, + .optimize = optimize, +}); +exe.root_module.addImport("chroma", chroma_dep.module("chroma")); +``` -Chroma is a Zig library for advanced ANSI color and text styling in terminal output. It allows developers to dynamically format strings with embedded placeholders (e.g. `{red}`, `{bold}`, `{fg:255;100;0}` for true color) and converts them into ANSI escape sequences. This makes it easy to apply complex styles, switch between foreground/background colors, and reset formatting on the fly—all at compile time. +Chroma is a Zig module rather than a runtime library, so there is no library +artifact to link. -chroma +## Runnable configurable example -## ✨ Features +The repository includes a complete consumer-style example: -- **Simple, Readable Syntax:** - Use `{red}`, `{bold}`, or `{green,bgBlue}` inline within strings for clear and maintainable code. +- [`examples/main.zig`](examples/main.zig) binds and uses a formatter. +- [`examples/chroma.zon`](examples/chroma.zon) defines semantic styles and + changes the grammar to forms such as `{@failure|bold}` and + `{@fg=255/120/50}`. -- **Comprehensive ANSI Codes:** - Support for standard colors, background colors, bold, italic, underline, dim, and even less commonly supported effects like `blink` and `reverse`. +Run it with: -- **Extended and True Color Support:** - Take advantage of ANSI 256 extended color codes and true color (24-bit) formats using syntax like `{fg:120}`, `{bg:28}`, or `{fg:255;100;0}` for fine-grained color control. +```sh +zig build run +``` -- **Compile-Time Safety:** - Chroma verifies format strings at compile time, reducing runtime errors and ensuring your formatting instructions are valid. +Set `NO_COLOR=1` to see the same example using its precomputed plain variant. -- **Reset-Friendly:** - Automatically appends `"\x1b[0m"` when necessary, ensuring that styles don’t “bleed” into subsequent output. +## What happens at comptime? -## 🚀 Getting Started +Chroma keeps parsing and configuration out of the runtime path. Environment +and terminal capability cannot be known until the program runs, so those +decisions remain deliberately small and explicit. -### Prerequisite +| Operation | Phase | Runtime allocation or parsing? | +| --- | --- | --- | +| Import typed `chroma.zon` configuration | Comptime | No | +| Validate syntax, style names, colors, and RGB channels | Comptime | No | +| Parse Chroma directives | Comptime | No | +| Generate exact-size ANSI and plain format strings | Comptime | No | +| Validate Zig format fields through `std.fmt` | Comptime | No | +| Read `NO_COLOR` and `CLICOLOR_FORCE` | Runtime, opt-in | No allocation | +| Detect TTY and enable Windows virtual-terminal processing | Runtime, opt-in | No allocation | +| Select the ANSI or plain constant | Runtime | One boolean branch | +| Substitute `{s}`, `{d}`, and other Zig arguments | Runtime | Handled by `std.fmt` | +| Write bytes to the output stream | Runtime | Handled by the application | -1. Fetch the project using `zig fetch` +## Basic use -```bash -zig fetch --save https://github.com/adia-dev/chroma-zig/archive/refs/heads/main.zip +```zig +const std = @import("std"); +const chroma = @import("chroma"); + +pub fn main() void { + std.debug.print( + chroma.format("{#bold,red}Failed:{#reset} {s}\n"), + .{"connection refused"}, + ); +} ``` -Or manually paste this in your `build.zig.zon` +Chroma reserves only fields beginning with the configured marker (`#` by +default). Other fields are preserved for `std.fmt`: ```zig -.dependencies = .{ - // other deps... - .chroma = .{ - .url = "https://github.com/adia-dev/chroma-zig/archive/refs/heads/main.zip", - .hash = "chroma-0.1.2-dA-RkbRIAACztjsL_aHnaZqu3GB53kpZziNo_TdxiLnf", - }, - // ... -}, +const fmt = chroma.format("{{literal}} {#green}{s: >12} {d}"); ``` -1. **Add Chroma to Your Zig Project:** - Include Chroma as a dependency in your `build.zig` or your `build.zig.zon`. For example: +The doubled braces also remain doubled in `fmt`; `std.fmt` performs the final +brace unescaping when it consumes the format string. + +## Directive reference + +Default directives use `{#item,item}`. Items in the same directive are applied +left to right, with later foreground and background colors taking precedence. +Chroma emits one combined SGR sequence for the resulting directive. - ```zig - const std = @import("std"); +```zig +chroma.format("{#red}standard red"); +chroma.format("{#bright-blue,bold}bright blue"); +chroma.format("{#fg:cyan,bg:bright-magenta}named colors"); +chroma.format("{#fg:120,bg:231}indexed colors"); +chroma.format("{#fg:255;100;0,bg:20;24;32}true color"); +chroma.format("{#reset}all defaults"); +chroma.format("{#fg:default,bg:default}default colors"); +``` - pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); +The basic color names are `black`, `red`, `green`, `yellow`, `blue`, +`magenta`, `cyan`, and `white`. Prefix any of them with `bright-` for the bright +variant. - const mod = b.addModule("chroma", .{ - .root_source_file = b.path("src/lib.zig"), - .target = target, - .optimize = optimize, - }); +Effects are `bold`, `dim`, `italic`, `underline`, `blink`, `reverse`, `hidden`, +and `strikethrough`. Disable them with `normal-intensity`, `no-italic`, +`no-underline`, `no-blink`, `no-reverse`, `no-hidden`, and +`no-strikethrough`. `normal-intensity` disables both bold and dim, matching SGR +code 22. - const lib = b.addLibrary(.{ - .name = "chroma", - .linkage = .static, - .root_module = mod, - }); +An automatic final reset is emitted only if a Chroma style remains active. Set +`Config.auto_reset` to `false` when style continuation is intentional. - b.installArtifact(lib); - } - ``` +## Compile-time themes -2. **Import and Use:** - After building and installing, you can import `chroma` into your Zig code: +Place a typed ZON file beside the Zig source that imports it. For example, +`chroma.zon`: ```zig -const std = @import("std"); -const chroma = @import("lib.zig"); - -pub fn main() !void { - const examples = [_]struct { fmt: []const u8, arg: ?[]const u8 }{ - // Basic color and style - .{ .fmt = "{bold,red}Bold and Red{reset}", .arg = null }, - // Combining background and foreground with styles - .{ .fmt = "{fg:cyan,bg:magenta}{underline}Cyan on Magenta underline{reset}", .arg = null }, - // Nested styles and colors - .{ .fmt = "{green}Green {bold}and Bold{reset,blue,italic} to blue italic{reset}", .arg = null }, - // Extended ANSI color with arg example - .{ .fmt = "{bg:120}Extended ANSI {s}{reset}", .arg = "Background" }, - // True color specification - .{ .fmt = "{fg:255;100;0}True Color Orange Text{reset}", .arg = null }, - // Mixed color and style formats - .{ .fmt = "{bg:28,italic}{fg:231}Mixed Background and Italic{reset}", .arg = null }, - // Unsupported/Invalid color code >= 256, Error thrown at compile time - // .{ .fmt = "{fg:999}This should not crash{reset}", .arg = null }, - // Demonstrating blink, note: may not be supported in all terminals - .{ .fmt = "{blink}Blinking Text (if supported){reset}", .arg = null }, - // Using dim and reverse video - .{ .fmt = "{dim,reverse}Dim and Reversed{reset}", .arg = null }, - // Custom message with dynamic content - .{ .fmt = "{blue,bg:magenta}User {bold}{s}{reset,0;255;0} logged in successfully.", .arg = "Charlie" }, - // Combining multiple styles and reset - .{ .fmt = "{underline,cyan}Underlined Cyan{reset} then normal", .arg = null }, - // Multiple format specifiers for complex formatting - .{ .fmt = "{fg:144,bg:52,bold,italic}Fancy {underline}Styling{reset}", .arg = null }, - // Jujutsu Kaisen !! - .{ .fmt = "{bg:72,bold,italic}Jujutsu Kaisen !!{reset}", .arg = null }, - }; - - inline for (examples) |example| { - if (example.arg) |arg| { - std.debug.print(chroma.format(example.fmt) ++ "\n", .{arg}); - } else { - std.debug.print(chroma.format(example.fmt) ++ "\n", .{}); - } - } - - std.debug.print(chroma.format("{blue}{underline}Eventually{reset}, the {red}formatting{reset} looks like {130;43;122}{s}!\n"), .{"this"}); +.{ + .styles = .{ + .{ + .name = "error", + .style = .{ + .foreground = .{ .rgb = .{ .r = 220, .g = 50, .b = 47 } }, + .effects = .{ .bold, .underline }, + }, + }, + .{ + .name = "notice", + .style = .{ + .foreground = .{ .bright = .cyan }, + .background = .{ .indexed = 236 }, + }, + }, + }, } +``` + +Bind it once to an explicit formatter type: +```zig +const chroma = @import("chroma"); +const ui = chroma.Formatter(@import("chroma.zon")); + +const failure = ui.format("{#error}Could not open {s}"); +const notice = ui.format("{#notice}Listening on port {d}"); ``` -3. **Run and Test:** - - Build your project with `zig build`. - - Run your binary and see the styled output in your terminal! +A named style can set a foreground, background, and any number of effects. +Omitted fields leave the existing state unchanged. Names must begin with an +ASCII letter, may contain letters, digits, `-`, and `_`, and cannot shadow a +built-in directive. -## 🧪 Testing +Themes can also customize the grammar inside the fixed braces: -Chroma includes a suite of unit tests to ensure reliability: +```zig +.{ + .syntax = .{ + .marker = '@', + .item_separator = '|', + .value_separator = '=', + .channel_separator = '/', + }, + .styles = .{ + .{ + .name = "error", + .style = .{ + .foreground = .{ .rgb = .{ .r = 220, .g = 50, .b = 47 } }, + }, + }, + }, +} +``` -```bash -zig build test +That formatter accepts `{@error|bold}` and `{@fg=255/100/0}`. Syntax +characters must be distinct ASCII punctuation characters other than `{` and +`}`. This keeps Chroma fields separate from ordinary `std.fmt` fields. + +## ANSI and plain output + +`render` generates both variants at compile time: + +```zig +const message = comptime chroma.render("{#red}failure:{#reset} {s}\n"); + +if (use_color) { + try writer.print(message.ansi, .{reason}); +} else { + try writer.print(message.plain, .{reason}); +} ``` -If all tests pass, you’re good to go! +Keep the explicit branch when the string contains Zig formatting fields, +because `Writer.print` requires its format argument to remain comptime-known. +For strings without fields, `message.select(use_color)` can be passed to +`writer.writeAll`. -## 🔧 Configuration +The optional detector keeps environment and platform work out of the renderer: -Chroma works out-of-the-box. For more complex scenarios (e.g., custom labels, multiple color formats), refer to `src/lib.zig` and `src/ansi.zig` for detailed code comments that explain available options and their intended usage. +```zig +const use_color = try chroma.terminal.detect( + init.io, + init.minimal.environ, + std.Io.File.stdout(), + .auto, +); +``` -## 📦 Zig Compatibility +Policies are `.auto`, `.always`, and `.never`. Automatic mode applies +`NO_COLOR`, then `CLICOLOR_FORCE`, then asks Zig whether ANSI is supported. The +same call enables Windows virtual-terminal processing when available. No +allocator is required. -Chroma targets Zig 0.16.0. The minimum supported compiler is declared in `build.zig.zon`, and CI validates this version. +## Migration from 0.1 -## 🤝 Contributing +Version 0.2 deliberately namespaces Chroma directives so Zig format fields are +never guessed from a list of known colors. -Contributions are welcome! To get involved: +| Chroma 0.1 | Chroma 0.2 | +| --- | --- | +| `{red}` | `{#red}` | +| `{bold,red}` | `{#bold,red}` | +| `{fg:120}` | `{#fg:120}` | +| `{255;100;0}` | `{#fg:255;100;0}` | +| `{bgRed}` | `{#bg:red}` | +| `{reset}` | `{#reset}` | -1. **Fork & Clone:** - Fork the repository and clone it locally. +Unknown `{#...}` directives now fail at compile time. Non-namespaced fields, +including all `std.fmt` fields, pass through unchanged. -2. **Branch & Develop:** - Create a new branch and implement your changes or new features. +## Development -3. **Test & Document:** - Run `zig build test` to ensure your changes haven’t broken anything. Update or add documentation as needed. +Use Zig 0.16.0: -4. **Pull Request:** - Submit a Pull Request describing what you changed and why. We’ll review and merge it if everything looks good. +```sh +zig build +zig build run +zig build test +time zig build benchmark +zig fmt --check . +``` -## 📝 License +The test step includes normal unit tests, a large comptime stress case, and +fixtures which must fail compilation with the expected diagnostic. CI runs the +suite natively on Linux, macOS, and Windows and performs additional cross-target +builds. -[MIT License](./LICENSE) +## License -_Chroma aims to simplify ANSI coloring in Zig, making your command-line tools, logs, and output more expressive and visually appealing._ +[MIT](./LICENSE) diff --git a/benchmarks/compile.zig b/benchmarks/compile.zig new file mode 100644 index 0000000..940689e --- /dev/null +++ b/benchmarks/compile.zig @@ -0,0 +1,12 @@ +const chroma = @import("chroma"); + +comptime { + const repetitions = 512; + const input = "{#red,bold}x{#reset}" ** repetitions; + const expected_unit = "\x1b[31;1mx\x1b[0m"; + const output = chroma.format(input); + + if (output.len != expected_unit.len * repetitions) { + @compileError("unexpected benchmark output length"); + } +} diff --git a/build.zig b/build.zig index 07d4097..3ca8e3e 100644 --- a/build.zig +++ b/build.zig @@ -4,57 +4,144 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const mod = b.addModule("chroma", .{ + const chroma = b.addModule("chroma", .{ .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, }); - const lib = b.addLibrary(.{ - .name = "chroma", - .linkage = .static, - .root_module = mod, - }); - - b.installArtifact(lib); - - const exe_mod = b.addModule("chroma-exe", .{ - .root_source_file = b.path("src/main.zig"), + const example_module = b.createModule(.{ + .root_source_file = b.path("examples/main.zig"), .target = target, .optimize = optimize, }); + example_module.addImport("chroma", chroma); - const exe = b.addExecutable(.{ - .name = "chroma", - .root_module = exe_mod, + const example = b.addExecutable(.{ + .name = "chroma-example", + .root_module = example_module, }); - - b.installArtifact(exe); - - const run_cmd = b.addRunArtifact(exe); - - run_cmd.step.dependOn(b.getInstallStep()); - - if (b.args) |args| { - run_cmd.addArgs(args); - } - - const run_step = b.step("run", "Run the app"); - run_step.dependOn(&run_cmd.step); - - const lib_unit_tests = b.addTest(.{ - .root_module = mod, + b.installArtifact(example); + + const run_command = b.addRunArtifact(example); + run_command.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_command.addArgs(args); + + const run_step = b.step("run", "Run the Chroma example"); + run_step.dependOn(&run_command.step); + + const library_tests = b.addTest(.{ .root_module = chroma }); + const run_library_tests = b.addRunArtifact(library_tests); + + const example_tests = b.addTest(.{ .root_module = example_module }); + const run_example_tests = b.addRunArtifact(example_tests); + + const test_step = b.step("test", "Run unit and compile-error tests"); + test_step.dependOn(&run_library_tests.step); + test_step.dependOn(&run_example_tests.step); + + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/unknown_directive.zig", + "error: chroma: unknown directive 'wat' at byte 2", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/missing_close.zig", + "error: chroma: missing closing '}' for Chroma directive at byte 0", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/invalid_rgb.zig", + "error: chroma: RGB colors require exactly three channels at byte 5", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/duplicate_style.zig", + "error: chroma config: duplicate style name 'brand'", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/color_overflow.zig", + "error: chroma: color channel exceeds 255 at byte 5", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/empty_channel.zig", + "error: chroma: empty numeric color channel at byte 7", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/reserved_style.zig", + "error: chroma config: style name 'red' is reserved", + ); + addCompileErrorTest( + b, + test_step, + chroma, + target, + optimize, + "tests/compile_errors/invalid_syntax.zig", + "error: chroma config: syntax characters must be distinct", + ); + + const benchmark_module = b.createModule(.{ + .root_source_file = b.path("benchmarks/compile.zig"), + .target = target, + .optimize = optimize, }); + benchmark_module.addImport("chroma", chroma); + const benchmark_compile = b.addTest(.{ .root_module = benchmark_module }); - const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); + const benchmark_step = b.step("benchmark", "Compile the large comptime benchmark fixture"); + benchmark_step.dependOn(&benchmark_compile.step); +} - const exe_unit_tests = b.addTest(.{ - .root_module = exe_mod, +fn addCompileErrorTest( + b: *std.Build, + test_step: *std.Build.Step, + chroma: *std.Build.Module, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + path: []const u8, + expected: []const u8, +) void { + const fixture_module = b.createModule(.{ + .root_source_file = b.path(path), + .target = target, + .optimize = optimize, }); + fixture_module.addImport("chroma", chroma); - const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); - - const test_step = b.step("test", "Run unit tests"); - test_step.dependOn(&run_lib_unit_tests.step); - test_step.dependOn(&run_exe_unit_tests.step); + const fixture = b.addTest(.{ .root_module = fixture_module }); + fixture.expect_errors = .{ .contains = expected }; + test_step.dependOn(&fixture.step); } diff --git a/build.zig.zon b/build.zig.zon index 51f32bc..c554889 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .chroma, // This is a [Semantic Version](https://semver.org/). // In a future version of Zig it will be used for package deduplication. - .version = "0.1.2", + .version = "0.2.0", .fingerprint = 0xfebb40f591910f74, // Tracks the earliest Zig version supported by this package. @@ -15,16 +15,14 @@ // internet connectivity. .dependencies = .{}, .paths = .{ - // This makes *all* files, recursively, included in this package. It is generally - // better to explicitly list the files and directories instead, to insure that - // fetching from tarballs, file system paths, and version control all result - // in the same contents hash. - // "", - // For example... "build.zig", - //"build.zig.zon", + "build.zig.zon", + "LICENSE", + "README.md", + "benchmarks", + "docs", + "examples", "src", - //"LICENSE", - //"README.md", + "tests", }, } diff --git a/docs/assets/chroma-configurable.png b/docs/assets/chroma-configurable.png new file mode 100644 index 0000000..bdabd61 Binary files /dev/null and b/docs/assets/chroma-configurable.png differ diff --git a/docs/chroma.tape b/docs/chroma.tape new file mode 100644 index 0000000..c68a18d --- /dev/null +++ b/docs/chroma.tape @@ -0,0 +1,33 @@ +Require zig + +Set Shell "zsh" +Set FontFamily "JetBrainsMono Nerd Font Mono, Menlo, monospace" +Set FontSize 17 +Set LetterSpacing 0 +Set LineHeight 1.15 +Set Width 1040 +Set Height 430 +Set Padding 24 +Set Margin 24 +Set MarginFill "#11111b" +Set BorderRadius 14 +Set WindowBar "Colorful" +Set WindowBarSize 38 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 25ms + +Hide +Type "export PS1='$ '" +Enter +Type "unset NO_COLOR; export CLICOLOR_FORCE=1" +Enter +Type "clear" +Enter +Show + +Type "zig build run" +Enter +Sleep 2s + +Screenshot docs/assets/chroma-configurable.png +Sleep 1s diff --git a/examples/chroma.zon b/examples/chroma.zon new file mode 100644 index 0000000..224d0b2 --- /dev/null +++ b/examples/chroma.zon @@ -0,0 +1,51 @@ +.{ + .syntax = .{ + .marker = '@', + .item_separator = '|', + .value_separator = '=', + .channel_separator = '/', + }, + .styles = .{ + .{ + .name = "brand", + .style = .{ + .foreground = .{ .rgb = .{ .r = 142, .g = 110, .b = 255 } }, + .effects = .{.bold}, + }, + }, + .{ + .name = "muted", + .style = .{ + .foreground = .{ .bright = .black }, + .effects = .{.italic}, + }, + }, + .{ + .name = "success", + .style = .{ + .foreground = .{ .bright = .green }, + .effects = .{.bold}, + }, + }, + .{ + .name = "warning", + .style = .{ + .foreground = .{ .bright = .yellow }, + .effects = .{.bold}, + }, + }, + .{ + .name = "failure", + .style = .{ + .foreground = .{ .bright = .red }, + .effects = .{.bold}, + }, + }, + .{ + .name = "label", + .style = .{ + .foreground = .{ .bright = .cyan }, + }, + }, + }, +} diff --git a/examples/main.zig b/examples/main.zig new file mode 100644 index 0000000..9edcfd0 --- /dev/null +++ b/examples/main.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const chroma = @import("chroma"); + +const ui = chroma.Formatter(@import("chroma.zon")); + +pub fn main(init: std.process.Init) !void { + const io = init.io; + const stdout_file = std.Io.File.stdout(); + const use_color = try chroma.terminal.detect( + io, + init.minimal.environ, + stdout_file, + .auto, + ); + + var output_buffer: [2048]u8 = undefined; + var file_writer = stdout_file.writer(io, &output_buffer); + const writer = &file_writer.interface; + + try printExample(writer, use_color); + try writer.flush(); +} + +fn printExample(writer: *std.Io.Writer, use_color: bool) !void { + const heading = comptime ui.render( + "{@brand}CHROMA 0.2{@reset} comptime-first terminal styling\n" ++ + "{@muted}Configured by examples/chroma.zon{@reset}\n\n", + ); + try writeRendered(writer, heading, use_color); + + const semantic_styles = comptime ui.render( + " {@success}✓ success{@reset} Build completed\n" ++ + " {@warning}⚠ warning{@reset} Configuration changed\n" ++ + " {@failure}✗ failure{@reset} Connection refused\n\n", + ); + try writeRendered(writer, semantic_styles, use_color); + + const custom_grammar = comptime ui.render( + " {@label}custom grammar{@reset} {@fg=255/120/50|bold}{s}{@reset}\n" ++ + " {@label}runtime policy{@reset} {s}\n", + ); + if (use_color) { + try writer.print(custom_grammar.ansi, .{ "{@fg=R/G/B|effect}", "ANSI selected" }); + } else { + try writer.print(custom_grammar.plain, .{ "{@fg=R/G/B|effect}", "plain text selected" }); + } +} + +fn writeRendered(writer: *std.Io.Writer, comptime rendered: chroma.Rendered, use_color: bool) !void { + try writer.writeAll(rendered.select(use_color)); +} diff --git a/src/ansi.zig b/src/ansi.zig index 45154e3..e8d5a90 100644 --- a/src/ansi.zig +++ b/src/ansi.zig @@ -1,22 +1,7 @@ -/// The `AnsiCode` enum offers a comprehensive set of ANSI escape codes for both -/// styling and coloring text in the terminal. This includes basic styles like bold -/// and italic, foreground and background colors, and special modes like blinking or -/// hidden text. It provides methods for obtaining the string name and the corresponding -/// ANSI escape code of each color or style, enabling easy and readable text formatting. -pub const AnsiCode = enum(u8) { - // Standard style codes - reset = 0, - bold, - dim, - italic, - underline, - ///Not widely supported - blink, - reverse = 7, - hidden, - - // Standard text colors - black = 30, +/// The eight portable ANSI colors. Brightness is represented separately so +/// themes can express all sixteen foreground and background colors. +pub const BasicColor = enum { + black, red, green, yellow, @@ -24,62 +9,74 @@ pub const AnsiCode = enum(u8) { magenta, cyan, white, +}; + +/// A 24-bit terminal color. +pub const Rgb = struct { + /// Red channel. + r: u8, + /// Green channel. + g: u8, + /// Blue channel. + b: u8, +}; - // Standard background colors - bgBlack = 40, - bgRed, - bgGreen, - bgYellow, - bgBlue, - bgMagenta, - bgCyan, - bgWhite, +/// A foreground or background color supported by Chroma. +pub const Color = union(enum) { + /// Restore the terminal's default foreground or background color. + default, + /// One of the eight standard ANSI colors. + basic: BasicColor, + /// One of the eight bright ANSI colors. + bright: BasicColor, + /// An entry in the ANSI 256-color palette. + indexed: u8, + /// A 24-bit true color. + rgb: Rgb, +}; + +/// Text effects which may be enabled by built-in directives or named styles. +pub const Effect = enum(u3) { + bold, + dim, + italic, + underline, + blink, + reverse, + hidden, + strikethrough, - /// Returns the string representation of the color. - /// This method makes it easy to identify a color by its name in the source code. - /// - /// Returns: - /// A slice of constant u8 bytes representing the color's name. - pub fn to_string(self: AnsiCode) []const u8 { - return @tagName(self); + /// Return the SGR parameter which enables this effect. + pub fn enableCode(effect: Effect) u8 { + return switch (effect) { + .bold => 1, + .dim => 2, + .italic => 3, + .underline => 4, + .blink => 5, + .reverse => 7, + .hidden => 8, + .strikethrough => 9, + }; } - /// Returns the ANSI escape code for the color as a string. - /// This method is used to apply the color to terminal output by embedding - /// the returned string into an output sequence. - /// - /// Returns: - /// A slice of constant u8 bytes representing the ANSI escape code for the color. - pub fn code(self: AnsiCode) []const u8 { - return switch (self) { - // Standard style codes - .reset => "0", - .bold => "1", - .dim => "2", - .italic => "3", - .underline => "4", - // Not widely supported - .blink => "5", - .reverse => "7", - .hidden => "8", - // foregroond colors - .black => "30", - .red => "31", - .green => "32", - .yellow => "33", - .blue => "34", - .magenta => "35", - .cyan => "36", - .white => "37", - // background colors - .bgBlack => "40", - .bgRed => "41", - .bgGreen => "42", - .bgYellow => "43", - .bgBlue => "44", - .bgMagenta => "45", - .bgCyan => "46", - .bgWhite => "47", + /// Return the SGR parameter which disables this effect. + pub fn disableCode(effect: Effect) u8 { + return switch (effect) { + .bold, .dim => 22, + .italic => 23, + .underline => 24, + .blink => 25, + .reverse => 27, + .hidden => 28, + .strikethrough => 29, }; } }; + +/// Return the standard SGR foreground or background parameter for a color. +pub fn basicCode(color: BasicColor, bright: bool, background: bool) u8 { + const offset: u8 = @intFromEnum(color); + if (bright) return (if (background) 100 else 90) + offset; + return (if (background) 40 else 30) + offset; +} diff --git a/src/lib.zig b/src/lib.zig index 0623dd6..fab52e3 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -1,208 +1,625 @@ -//BUG: apparently {{}} is not reflected to {} +//! Compile-time ANSI format-string rendering. +//! +//! Chroma reserves only namespaced directives such as `{#red,bold}`. All +//! other braces are preserved for a later `std.fmt` call. -/// This module provides a flexible way to format strings with ANSI color codes -/// dynamically using {colorName} placeholders within the text. It supports standard -/// ANSI colors, ANSI 256 extended colors, and true color (24-bit) formats. -/// It intelligently handles color formatting by parsing placeholders and replacing -/// them with the appropriate ANSI escape codes for terminal output. const std = @import("std"); -const AnsiCode = @import("ansi.zig").AnsiCode; -const compileAssert = @import("utils.zig").compileAssert; - -/// Provides dynamic string formatting capabilities with ANSI escape codes for both -/// color and text styling within terminal outputs. This module supports a wide range -/// of formatting options including standard ANSI colors, ANSI 256 extended color set, -/// and true color (24-bit) specifications. It parses given format strings with embedded -/// placeholders (e.g., `{color}` or `{style}`) and replaces them with the corresponding -/// ANSI escape codes. The format function is designed to be used at compile time, -/// enhancing readability and maintainability of terminal output styling in Zig applications. -/// -/// The formatting syntax supports modifiers (`fg` for foreground and `bg` for background), -/// as well as multiple formats within a single placeholder. Unrecognized placeholders -/// are output as-is, allowing for the inclusion of literal braces by doubling them (`{{` and `}}`). -// TODO: Refactor this lol -pub fn format(comptime fmt: []const u8) []const u8 { - @setEvalBranchQuota(2000000); - comptime var i: usize = 0; - comptime var output: []const u8 = ""; - comptime var at_least_one_color = false; +const ansi = @import("ansi.zig"); +const utils = @import("utils.zig"); + +pub const terminal = @import("terminal.zig"); +pub const BasicColor = ansi.BasicColor; +pub const Rgb = ansi.Rgb; +pub const Color = ansi.Color; +pub const Effect = ansi.Effect; + +/// A reusable semantic style. Omitted colors leave that color unchanged. +pub const Style = struct { + /// Foreground color to apply, or null to leave it unchanged. + foreground: ?Color = null, + /// Background color to apply, or null to leave it unchanged. + background: ?Color = null, + /// Effects to enable when the style is used. + effects: []const Effect = &.{}, +}; + +/// A style made available as a directive in a configured formatter. +pub const NamedStyle = struct { + /// Directive name, without the marker or braces. + name: []const u8, + /// Style changes applied by the directive. + style: Style, +}; + +/// Configurable characters inside Chroma's fixed `{...}` envelope. +pub const Syntax = struct { + /// Identifies Chroma fields immediately after `{`. + marker: u8 = '#', + /// Separates items in one Chroma directive. + item_separator: u8 = ',', + /// Separates `fg` or `bg` from a color value. + value_separator: u8 = ':', + /// Separates the three channels of an RGB value. + channel_separator: u8 = ';', +}; - inline while (i < fmt.len) { - const start_index = i; +/// Compile-time configuration for a formatter. +pub const Config = struct { + /// Grammar characters used by this formatter. + syntax: Syntax = .{}, + /// User-defined semantic styles. + styles: []const NamedStyle = &.{}, + /// Append reset when the rendered string ends with active formatting. + auto_reset: bool = true, +}; - // Find next '{' or '}' or end of string - inline while (i < fmt.len and fmt[i] != '{' and fmt[i] != '}') : (i += 1) {} +/// Both compile-time renderings of one Chroma format string. +pub const Rendered = struct { + /// Format string containing ANSI SGR sequences. + ansi: []const u8, + /// The same format string with Chroma directives removed. + plain: []const u8, - // Handle escaped braces '{{' or '}}' - if (i + 1 < fmt.len and fmt[i + 1] == fmt[i]) { - i += 2; // Skip both braces + /// Select a pre-rendered string without allocating or parsing at runtime. + pub fn select(rendered: Rendered, use_color: bool) []const u8 { + return if (use_color) rendered.ansi else rendered.plain; + } +}; + +/// Create a compile-time formatter with an explicit configuration. +pub fn Formatter(comptime config: Config) type { + comptime validateConfig(config); + + return struct { + /// Render a format string containing ANSI escape sequences. + pub fn format(comptime fmt: []const u8) []const u8 { + return renderSlice(config, fmt, .ansi); } - // Append text up to the next control character - if (start_index != i) { - output = output ++ fmt[start_index..i]; - continue; + /// Render ANSI and plain variants of a format string at compile time. + pub fn render(comptime fmt: []const u8) Rendered { + return .{ + .ansi = renderSlice(config, fmt, .ansi), + .plain = renderSlice(config, fmt, .plain), + }; } + }; +} - if (i >= fmt.len) break; // End of string +/// The formatter using Chroma's built-in syntax and palette. +pub const default = Formatter(.{}); - // Process color formatting - comptime compileAssert(fmt[i] == '{', "Expected '{' to start color format"); - i += 1; // Skip '{' +/// Render with Chroma's built-in syntax and palette. +pub fn format(comptime fmt: []const u8) []const u8 { + return default.format(fmt); +} - const fmt_begin = i; - inline while (i < fmt.len and fmt[i] != '}') : (i += 1) {} // Find closing '}' - const fmt_end = i; +/// Render ANSI and plain variants with Chroma's default configuration. +pub fn render(comptime fmt: []const u8) Rendered { + return default.render(fmt); +} - comptime compileAssert(i < fmt.len, "Missing closing '}' in color format"); +const OutputMode = enum { ansi, plain }; - const maybe_color_fmt = fmt[fmt_begin..fmt_end]; +const FormatState = struct { + foreground: Color = .default, + background: Color = .default, + effects: u8 = 0, - if (maybe_color_fmt.len == 0) { - // since empty, write the braces, skip the closing one - // and continue - output = output ++ "{" ++ maybe_color_fmt ++ "}"; + fn active(state: FormatState) bool { + return !isDefaultColor(state.foreground) or + !isDefaultColor(state.background) or + state.effects != 0; + } +}; + +const TagResult = struct { + state: FormatState, + force_reset: bool = false, +}; + +const CountWriter = struct { + len: usize = 0, + + fn writeAll(writer: *@This(), bytes: []const u8) void { + writer.len += bytes.len; + } +}; + +fn FixedWriter(comptime capacity: usize) type { + return struct { + buffer: *[capacity]u8, + pos: usize = 0, + + fn writeAll(writer: *@This(), bytes: []const u8) void { + @memcpy(writer.buffer[writer.pos..][0..bytes.len], bytes); + writer.pos += bytes.len; + } + }; +} + +fn renderSlice(comptime config: Config, comptime fmt: []const u8, comptime mode: OutputMode) []const u8 { + const len = comptime renderedLength(config, fmt, mode); + const bytes = comptime renderArray(config, fmt, mode, len); + return &bytes; +} + +fn renderedLength(comptime config: Config, comptime fmt: []const u8, comptime mode: OutputMode) usize { + var writer: CountWriter = .{}; + process(config, fmt, mode, &writer); + return writer.len; +} + +fn renderArray( + comptime config: Config, + comptime fmt: []const u8, + comptime mode: OutputMode, + comptime len: usize, +) [len]u8 { + var bytes: [len]u8 = undefined; + var writer: FixedWriter(len) = .{ .buffer = &bytes }; + process(config, fmt, mode, &writer); + std.debug.assert(writer.pos == len); + return bytes; +} + +fn process( + comptime config: Config, + comptime fmt: []const u8, + comptime mode: OutputMode, + writer: anytype, +) void { + // Parsing cost is proportional to input and configured style count. Avoid + // imposing the former blanket two-million branch quota on every call. + @setEvalBranchQuota(@max(2000, fmt.len * (128 + config.styles.len * 16))); + + var state: FormatState = .{}; + var i: usize = 0; + var text_start: usize = 0; + + while (i < fmt.len) { + const escaped_brace = i + 1 < fmt.len and + (fmt[i] == '{' or fmt[i] == '}') and + fmt[i + 1] == fmt[i]; + if (escaped_brace) { + i += 2; + continue; + } + + const is_tag = fmt[i] == '{' and + i + 1 < fmt.len and + fmt[i + 1] == config.syntax.marker; + if (!is_tag) { i += 1; continue; } - comptime { - var start = 0; - var end = 0; - var is_background = false; - - style_loop: while (start < maybe_color_fmt.len) { - while (end < maybe_color_fmt.len and maybe_color_fmt[end] != ',') : (end += 1) {} - - var modifier_end = start; - while (modifier_end < maybe_color_fmt.len and maybe_color_fmt[modifier_end] != ':') : (modifier_end += 1) {} - - if (modifier_end != maybe_color_fmt.len) { - if (std.mem.eql(u8, maybe_color_fmt[start..modifier_end], "bg")) { - is_background = true; - end = modifier_end + 1; - start = end; - continue :style_loop; - } else if (std.mem.eql(u8, maybe_color_fmt[start..modifier_end], "fg")) { - is_background = false; - end = modifier_end + 1; - start = end; - continue :style_loop; - } - } - - if (std.ascii.isDigit(maybe_color_fmt[start])) { - const color = parse256OrTrueColor(maybe_color_fmt[start..end], is_background); - output = output ++ color; - at_least_one_color = true; - } else { - var found = false; - for (@typeInfo(AnsiCode).@"enum".fields) |field| { - if (std.mem.eql(u8, field.name, maybe_color_fmt[start..end])) { - // HACK: this would not work if I put bgMagenta for example as a color - // TODO: fix this eheh - const color: AnsiCode = @enumFromInt(field.value + if (is_background) 10 else 0); - at_least_one_color = true; - output = output ++ "\x1b[" ++ color.code() ++ "m"; - found = true; - break; - } - } - - if (!found) { - output = output ++ "{" ++ maybe_color_fmt ++ "}"; - } - } - - end = end + 1; - start = end; - is_background = false; - } + writer.writeAll(fmt[text_start..i]); + + var close = i + 2; + while (close < fmt.len and fmt[close] != '}') : (close += 1) {} + if (close == fmt.len) { + utils.failAt("missing closing '}' for Chroma directive", i); } - i += 1; // Skip '}' + const before = state; + const tag = parseTag(config, fmt, i + 2, close, state); + state = tag.state; + if (mode == .ansi) emitTransition(writer, before, state, tag.force_reset); + + i = close + 1; + text_start = i; } - if (at_least_one_color) { - return output ++ "\x1b[0m"; + writer.writeAll(fmt[text_start..]); + if (mode == .ansi and config.auto_reset and state.active()) { + writer.writeAll("\x1b[0m"); } +} + +fn parseTag( + comptime config: Config, + comptime fmt: []const u8, + begin: usize, + end: usize, + initial: FormatState, +) TagResult { + if (begin == end) utils.failAt("empty Chroma directive", begin); + + var result: TagResult = .{ .state = initial }; + var item_start = begin; + var cursor = begin; - return output; + while (cursor <= end) : (cursor += 1) { + if (cursor != end and fmt[cursor] != config.syntax.item_separator) continue; + if (cursor == item_start) utils.failAt("empty item in Chroma directive", cursor); + + applyToken(config, fmt[item_start..cursor], item_start, &result); + item_start = cursor + 1; + } + + return result; } -// TODO: maybe keep the compile error and dedicate this function to be comptime only -fn parse256OrTrueColor(fmt: []const u8, background: bool) []const u8 { - var channels_value: [3]u8 = .{ 0, 0, 0 }; - var channels_length: [3]u8 = .{ 0, 0, 0 }; - var channel = 0; - var output: []const u8 = ""; +fn applyToken( + comptime config: Config, + token: []const u8, + offset: usize, + result: *TagResult, +) void { + if (std.mem.eql(u8, token, "reset")) { + result.state = .{}; + result.force_reset = true; + return; + } - for (fmt) |c| { - switch (c) { - '0'...'9' => { - var res = @mulWithOverflow(channels_value[channel], 10); - if (res[1] > 0) { - @compileError("Invalid number format, channel value too high >= 256, expected: {0-255} or {0-255;0-255;0-255}"); - } - channels_value[channel] = res[0]; + if (parseBasicColor(token)) |color| { + result.state.foreground = colorValue(color); + return; + } - res = @addWithOverflow(channels_value[channel], c - '0'); - if (res[1] > 0) { - @compileError("Invalid number format, channel value too high >= 256, expected: {0-255} or {0-255;0-255;0-255}"); - } - channels_value[channel] = res[0]; + if (parseEnabledEffect(token)) |effect| { + setEffect(&result.state, effect, true); + return; + } - channels_length[channel] += 1; - }, - ';' => { - channel += 1; + if (std.mem.eql(u8, token, "normal-intensity")) { + setEffect(&result.state, .bold, false); + setEffect(&result.state, .dim, false); + return; + } - if (channel >= 3) { - @compileError("Invalid number format, too many channels, expected: {0-255} or {0-255;0-255;0-255}"); - } - }, - ',' => { - break; - }, - else => { - @compileError("Invalid number format, expected: {0-255} or {0-255;0-255;0-255}"); - }, - } + if (parseDisabledEffect(token)) |effect| { + setEffect(&result.state, effect, false); + return; } - // ANSI 256 extended - if (channel == 0) { - const color: []const u8 = fmt[0..channels_length[0]]; + if (std.mem.indexOfScalar(u8, token, config.syntax.value_separator)) |separator| { + const key = token[0..separator]; + const value = token[separator + 1 ..]; + if (value.len == 0) utils.failAt("missing color value", offset + separator + 1); + + const background = if (std.mem.eql(u8, key, "fg")) + false + else if (std.mem.eql(u8, key, "bg")) + true + else + failUnknown(token, offset); + + const color = parseColorValue(config, value, offset + separator + 1); if (background) { - output = output ++ "\x1b[48;5;" ++ color ++ "m"; + result.state.background = color; } else { - output = output ++ "\x1b[38;5;" ++ color ++ "m"; + result.state.foreground = color; } + return; + } + + for (config.styles) |named| { + if (!std.mem.eql(u8, token, named.name)) continue; + applyStyle(named.style, &result.state); + return; } - // TRUECOLOR - // TODO: check for compatibility, is it possible at comptime ?? - else if (channel == 2) { - var color: []const u8 = ""; - var start = 0; - for (0..channel + 1) |c| { - const end = start + channels_length[c]; - color = color ++ fmt[start..end] ++ if (c == channel) "" else ";"; - // +1 to skip the ; - start += channels_length[c] + 1; + failUnknown(token, offset); +} + +fn parseColorValue( + comptime config: Config, + value: []const u8, + offset: usize, +) Color { + if (std.mem.eql(u8, value, "default")) return .default; + if (parseBasicColor(value)) |color| return colorValue(color); + + var separator_count: usize = 0; + for (value) |byte| { + if (byte == config.syntax.channel_separator) separator_count += 1; + } + + if (separator_count == 0) { + return .{ .indexed = parseChannel(value, offset) }; + } + if (separator_count != 2) { + utils.failAt("RGB colors require exactly three channels", offset); + } + + var channels: [3]u8 = undefined; + var channel_index: usize = 0; + var start: usize = 0; + var cursor: usize = 0; + while (cursor <= value.len) : (cursor += 1) { + if (cursor != value.len and value[cursor] != config.syntax.channel_separator) continue; + channels[channel_index] = parseChannel(value[start..cursor], offset + start); + channel_index += 1; + start = cursor + 1; + } + + return .{ .rgb = .{ .r = channels[0], .g = channels[1], .b = channels[2] } }; +} + +fn parseChannel(value: []const u8, offset: usize) u8 { + if (value.len == 0) utils.failAt("empty numeric color channel", offset); + + var number: u16 = 0; + for (value, 0..) |byte, index| { + if (!std.ascii.isDigit(byte)) utils.failAt("color channels must be decimal integers", offset + index); + number = number * 10 + (byte - '0'); + if (number > 255) utils.failAt("color channel exceeds 255", offset); + } + return @intCast(number); +} + +fn applyStyle(style: Style, state: *FormatState) void { + if (style.foreground) |color| state.foreground = color; + if (style.background) |color| state.background = color; + for (style.effects) |effect| setEffect(state, effect, true); +} + +fn emitTransition( + writer: anytype, + before: FormatState, + after: FormatState, + force_reset: bool, +) void { + if (!force_reset and statesEqual(before, after)) return; + + writer.writeAll("\x1b["); + var has_parameter = false; + + if (force_reset) { + writeParameter(writer, &has_parameter, 0); + emitColorIfActive(writer, &has_parameter, after.foreground, false); + emitColorIfActive(writer, &has_parameter, after.background, true); + for (std.enums.values(Effect)) |effect| { + if (hasEffect(after, effect)) writeParameter(writer, &has_parameter, effect.enableCode()); } - if (background) { - output = output ++ "\x1b[48;2;" ++ color ++ "m"; - } else { - output = output ++ "\x1b[38;2;" ++ color ++ "m"; + } else { + if (!std.meta.eql(before.foreground, after.foreground)) { + writeColor(writer, &has_parameter, after.foreground, false); } + if (!std.meta.eql(before.background, after.background)) { + writeColor(writer, &has_parameter, after.background, true); + } + + emitIntensityTransition(writer, &has_parameter, before, after); + inline for (.{ Effect.italic, Effect.underline, Effect.blink, Effect.reverse, Effect.hidden, Effect.strikethrough }) |effect| { + const was_enabled = hasEffect(before, effect); + const is_enabled = hasEffect(after, effect); + if (was_enabled == is_enabled) continue; + writeParameter(writer, &has_parameter, if (is_enabled) effect.enableCode() else effect.disableCode()); + } + } + + std.debug.assert(has_parameter); + writer.writeAll("m"); +} + +fn emitIntensityTransition( + writer: anytype, + has_parameter: *bool, + before: FormatState, + after: FormatState, +) void { + const before_bold = hasEffect(before, .bold); + const before_dim = hasEffect(before, .dim); + const after_bold = hasEffect(after, .bold); + const after_dim = hasEffect(after, .dim); + if (before_bold == after_bold and before_dim == after_dim) return; + + if ((before_bold and !after_bold) or (before_dim and !after_dim)) { + writeParameter(writer, has_parameter, 22); + if (after_bold) writeParameter(writer, has_parameter, Effect.bold.enableCode()); + if (after_dim) writeParameter(writer, has_parameter, Effect.dim.enableCode()); + return; + } + + if (!before_bold and after_bold) writeParameter(writer, has_parameter, Effect.bold.enableCode()); + if (!before_dim and after_dim) writeParameter(writer, has_parameter, Effect.dim.enableCode()); +} + +fn emitColorIfActive(writer: anytype, has_parameter: *bool, color: Color, background: bool) void { + if (!isDefaultColor(color)) writeColor(writer, has_parameter, color, background); +} + +fn writeColor(writer: anytype, has_parameter: *bool, color: Color, background: bool) void { + switch (color) { + .default => writeParameter(writer, has_parameter, if (background) 49 else 39), + .basic => |basic| writeParameter(writer, has_parameter, ansi.basicCode(basic, false, background)), + .bright => |basic| writeParameter(writer, has_parameter, ansi.basicCode(basic, true, background)), + .indexed => |index| { + writeParameter(writer, has_parameter, if (background) 48 else 38); + writeParameter(writer, has_parameter, 5); + writeParameter(writer, has_parameter, index); + }, + .rgb => |rgb| { + writeParameter(writer, has_parameter, if (background) 48 else 38); + writeParameter(writer, has_parameter, 2); + writeParameter(writer, has_parameter, rgb.r); + writeParameter(writer, has_parameter, rgb.g); + writeParameter(writer, has_parameter, rgb.b); + }, + } +} + +fn writeParameter(writer: anytype, has_parameter: *bool, value: u8) void { + if (has_parameter.*) writer.writeAll(";"); + has_parameter.* = true; + writeDecimal(writer, value); +} + +fn writeDecimal(writer: anytype, value: u8) void { + var buffer: [3]u8 = undefined; + var start: usize = buffer.len; + var remaining = value; + while (true) { + start -= 1; + buffer[start] = '0' + remaining % 10; + remaining /= 10; + if (remaining == 0) break; + } + writer.writeAll(buffer[start..]); +} + +const ParsedBasicColor = struct { + color: BasicColor, + bright: bool, +}; + +fn parseBasicColor(name: []const u8) ?ParsedBasicColor { + const bright_prefix = "bright-"; + const bright = std.mem.startsWith(u8, name, bright_prefix); + const base = if (bright) name[bright_prefix.len..] else name; + + const color: BasicColor = if (std.mem.eql(u8, base, "black")) + .black + else if (std.mem.eql(u8, base, "red")) + .red + else if (std.mem.eql(u8, base, "green")) + .green + else if (std.mem.eql(u8, base, "yellow")) + .yellow + else if (std.mem.eql(u8, base, "blue")) + .blue + else if (std.mem.eql(u8, base, "magenta")) + .magenta + else if (std.mem.eql(u8, base, "cyan")) + .cyan + else if (std.mem.eql(u8, base, "white")) + .white + else + return null; + + return .{ .color = color, .bright = bright }; +} + +fn colorValue(color: ParsedBasicColor) Color { + return if (color.bright) + .{ .bright = color.color } + else + .{ .basic = color.color }; +} + +fn parseEnabledEffect(name: []const u8) ?Effect { + if (std.mem.eql(u8, name, "bold")) return .bold; + if (std.mem.eql(u8, name, "dim")) return .dim; + if (std.mem.eql(u8, name, "italic")) return .italic; + if (std.mem.eql(u8, name, "underline")) return .underline; + if (std.mem.eql(u8, name, "blink")) return .blink; + if (std.mem.eql(u8, name, "reverse")) return .reverse; + if (std.mem.eql(u8, name, "hidden")) return .hidden; + if (std.mem.eql(u8, name, "strikethrough")) return .strikethrough; + return null; +} + +fn parseDisabledEffect(name: []const u8) ?Effect { + if (std.mem.eql(u8, name, "no-italic")) return .italic; + if (std.mem.eql(u8, name, "no-underline")) return .underline; + if (std.mem.eql(u8, name, "no-blink")) return .blink; + if (std.mem.eql(u8, name, "no-reverse")) return .reverse; + if (std.mem.eql(u8, name, "no-hidden")) return .hidden; + if (std.mem.eql(u8, name, "no-strikethrough")) return .strikethrough; + return null; +} + +fn setEffect(state: *FormatState, effect: Effect, enabled: bool) void { + const mask = effectMask(effect); + if (enabled) { + state.effects |= mask; } else { - @compileError("Invalid number format, check the number of channels, must be 1 or 3, expected: {0-255} or {0-255;0-255;0-255}"); + state.effects &= ~mask; } +} + +fn hasEffect(state: FormatState, effect: Effect) bool { + return state.effects & effectMask(effect) != 0; +} + +fn effectMask(effect: Effect) u8 { + return @as(u8, 1) << @intFromEnum(effect); +} + +fn statesEqual(a: FormatState, b: FormatState) bool { + return std.meta.eql(a.foreground, b.foreground) and + std.meta.eql(a.background, b.background) and + a.effects == b.effects; +} + +fn isDefaultColor(color: Color) bool { + return color == .default; +} + +fn failUnknown(token: []const u8, offset: usize) noreturn { + utils.failAt(std.fmt.comptimePrint("unknown directive '{s}'", .{token}), offset); +} + +fn validateConfig(comptime config: Config) void { + const characters = [_]u8{ + config.syntax.marker, + config.syntax.item_separator, + config.syntax.value_separator, + config.syntax.channel_separator, + }; + + inline for (characters, 0..) |character, index| { + if (!std.ascii.isPunctuation(character) or character == '{' or character == '}') { + utils.failConfig("syntax characters must be ASCII punctuation other than braces"); + } + inline for (characters[index + 1 ..]) |other| { + if (character == other) utils.failConfig("syntax characters must be distinct"); + } + } + + inline for (config.styles, 0..) |named, index| { + validateStyleName(named.name); + inline for (named.name) |character| { + if (character == config.syntax.item_separator or + character == config.syntax.value_separator or + character == config.syntax.channel_separator) + { + utils.failConfig(std.fmt.comptimePrint("style name '{s}' contains a syntax separator", .{named.name})); + } + } + if (isReservedName(named.name)) { + utils.failConfig(std.fmt.comptimePrint("style name '{s}' is reserved", .{named.name})); + } + inline for (config.styles[index + 1 ..]) |other| { + if (std.mem.eql(u8, named.name, other.name)) { + utils.failConfig(std.fmt.comptimePrint("duplicate style name '{s}'", .{named.name})); + } + } + + var seen_effects: u8 = 0; + inline for (named.style.effects) |effect| { + const mask = effectMask(effect); + if (seen_effects & mask != 0) { + utils.failConfig(std.fmt.comptimePrint("style '{s}' repeats an effect", .{named.name})); + } + seen_effects |= mask; + } + } +} + +fn validateStyleName(comptime name: []const u8) void { + if (name.len == 0 or !std.ascii.isAlphabetic(name[0])) { + utils.failConfig("style names must start with an ASCII letter"); + } + inline for (name[1..]) |character| { + if (!std.ascii.isAlphanumeric(character) and character != '-' and character != '_') { + utils.failConfig(std.fmt.comptimePrint("invalid style name '{s}'", .{name})); + } + } +} - return output; +fn isReservedName(comptime name: []const u8) bool { + return std.mem.eql(u8, name, "reset") or + std.mem.eql(u8, name, "normal-intensity") or + parseBasicColor(name) != null or + parseEnabledEffect(name) != null or + parseDisabledEffect(name) != null; } -comptime { +test { _ = @import("tests.zig"); + _ = @import("terminal.zig"); } diff --git a/src/main.zig b/src/main.zig deleted file mode 100644 index 177668e..0000000 --- a/src/main.zig +++ /dev/null @@ -1,43 +0,0 @@ -const std = @import("std"); -const chroma = @import("lib.zig"); - -pub fn main() !void { - const examples = [_]struct { fmt: []const u8, arg: ?[]const u8 }{ - // Basic color and style - .{ .fmt = "{bold,red}Bold and Red{reset}", .arg = null }, - // Combining background and foreground with styles - .{ .fmt = "{fg:cyan,bg:magenta}{underline}Cyan on Magenta underline{reset}", .arg = null }, - // Nested styles and colors - .{ .fmt = "{green}Green {bold}and Bold{reset,blue,italic} to blue italic{reset}", .arg = null }, - // Extended ANSI color with arg example - .{ .fmt = "{bg:120}Extended ANSI {s}{reset}", .arg = "Background" }, - // True color specification - .{ .fmt = "{fg:255;100;0}True Color Orange Text{reset}", .arg = null }, - // Mixed color and style formats - .{ .fmt = "{bg:28,italic}{fg:231}Mixed Background and Italic{reset}", .arg = null }, - // Unsupported/Invalid color code >= 256, Error thrown at compile time - // .{ .fmt = "{fg:999}This should not crash{reset}", .arg = null }, - // Demonstrating blink, note: may not be supported in all terminals - .{ .fmt = "{blink}Blinking Text (if supported){reset}", .arg = null }, - // Using dim and reverse video - .{ .fmt = "{dim,reverse}Dim and Reversed{reset}", .arg = null }, - // Custom message with dynamic content - .{ .fmt = "{blue,bg:magenta}User {bold}{s}{reset,0;255;0} logged in successfully.", .arg = "Charlie" }, - // Combining multiple styles and reset - .{ .fmt = "{underline,cyan}Underlined Cyan{reset} then normal", .arg = null }, - // Multiple format specifiers for complex formatting - .{ .fmt = "{fg:144,bg:52,bold,italic}Fancy {underline}Styling{reset}", .arg = null }, - // Jujutsu Kaisen !! - .{ .fmt = "{bg:72,bold,italic}Jujutsu Kaisen !!{reset}", .arg = null }, - }; - - inline for (examples) |example| { - if (example.arg) |arg| { - std.debug.print(chroma.format(example.fmt) ++ "\n", .{arg}); - } else { - std.debug.print(chroma.format(example.fmt) ++ "\n", .{}); - } - } - - std.debug.print(chroma.format("{blue}{underline}Eventually{reset}, the {red}formatting{reset} looks like {130;43;122}{s}!\n"), .{"this"}); -} diff --git a/src/terminal.zig b/src/terminal.zig new file mode 100644 index 0000000..aafd0ed --- /dev/null +++ b/src/terminal.zig @@ -0,0 +1,83 @@ +const builtin = @import("builtin"); +const std = @import("std"); + +/// Controls how terminal color capability is selected. +pub const ColorPolicy = enum { + /// Honor NO_COLOR, CLICOLOR_FORCE, and terminal capability in that order. + auto, + /// Emit ANSI even when output is redirected or environment variables opt out. + always, + /// Never emit ANSI. + never, +}; + +/// Inputs to the deterministic part of automatic color selection. +pub const AutoInputs = struct { + /// Whether NO_COLOR is present and non-empty. + no_color: bool = false, + /// Whether CLICOLOR_FORCE is present and non-empty. + clicolor_force: bool = false, + /// Whether the output stream can consume ANSI sequences. + ansi_capable: bool = false, +}; + +/// Resolve automatic color policy without reading process or terminal state. +/// This is useful to applications which already perform their own detection. +pub fn resolveAuto(inputs: AutoInputs) bool { + if (inputs.no_color) return false; + if (inputs.clicolor_force) return true; + return inputs.ansi_capable; +} + +/// Detect whether pre-rendered ANSI output should be selected for `file`. +/// +/// In automatic mode this function also asks Zig's I/O implementation to +/// enable ANSI processing when necessary, including Windows virtual-terminal +/// processing. It performs no allocation and does not retain the file. +pub fn detect( + io: std.Io, + environ: std.process.Environ, + file: std.Io.File, + policy: ColorPolicy, +) std.Io.Cancelable!bool { + switch (policy) { + .never => return false, + .always => { + file.enableAnsiEscapeCodes(io) catch {}; + return true; + }, + .auto => {}, + } + + const no_color = if (builtin.os.tag == .wasi) + false + else + environ.containsUnemptyConstant("NO_COLOR"); + if (no_color) return false; + + const force_color = if (builtin.os.tag == .wasi) + false + else + environ.containsUnemptyConstant("CLICOLOR_FORCE"); + if (force_color) { + file.enableAnsiEscapeCodes(io) catch {}; + return true; + } + + file.enableAnsiEscapeCodes(io) catch |err| switch (err) { + error.Canceled => return error.Canceled, + error.NotTerminalDevice, error.Unexpected => return false, + }; + return true; +} + +test "resolveAuto precedence" { + try std.testing.expect(!resolveAuto(.{})); + try std.testing.expect(resolveAuto(.{ .ansi_capable = true })); + try std.testing.expect(resolveAuto(.{ .clicolor_force = true })); + try std.testing.expect(!resolveAuto(.{ + .no_color = true, + .clicolor_force = true, + .ansi_capable = true, + })); +} diff --git a/src/test_theme.zon b/src/test_theme.zon new file mode 100644 index 0000000..0ec0cf1 --- /dev/null +++ b/src/test_theme.zon @@ -0,0 +1,18 @@ +.{ + .syntax = .{ + .marker = '@', + .item_separator = '|', + .value_separator = '=', + .channel_separator = '/', + }, + .styles = .{ + .{ + .name = "error", + .style = .{ + .foreground = .{ .rgb = .{ .r = 220, .g = 50, .b = 47 } }, + .background = .{ .bright = .yellow }, + .effects = .{.bold}, + }, + }, + }, +} diff --git a/src/tests.zig b/src/tests.zig index b1a8c0f..c8c244b 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -1,102 +1,152 @@ const std = @import("std"); -const AnsiCode = @import("ansi.zig").AnsiCode; const chroma = @import("lib.zig"); -// TESTS -const COLOR_OPEN = "\x1b["; -const RESET = "\x1b[0m"; +const CSI = "\x1b["; +const RESET = CSI ++ "0m"; -test "format - Red text" { - const red_hello = chroma.format("{red}Hello"); - - const expected = COLOR_OPEN ++ "31m" ++ "Hello" ++ RESET; - try std.testing.expectEqualStrings(expected, red_hello); +test "format - plain text and empty input" { + try std.testing.expectEqualStrings("Just plain text.", chroma.format("Just plain text.")); + try std.testing.expectEqualStrings("", chroma.format("")); } -test "format - Multiple colors" { - const colorful_text = chroma.format("{red}Hello{green}my name is{blue}Abdoulaye."); - - const expected = COLOR_OPEN ++ "31m" ++ "Hello" ++ COLOR_OPEN ++ "32m" ++ "my name is" ++ COLOR_OPEN ++ "34m" ++ "Abdoulaye." ++ RESET; - try std.testing.expectEqualStrings(expected, colorful_text); +test "format - standard and bright foreground colors" { + try std.testing.expectEqualStrings(CSI ++ "31mred" ++ RESET, chroma.format("{#red}red")); + try std.testing.expectEqualStrings(CSI ++ "94mblue" ++ RESET, chroma.format("{#bright-blue}blue")); } -test "format - Background color and reset" { - const bg_and_reset = chroma.format("{bgRed}Warning!{reset} Normal text."); - - const expected = COLOR_OPEN ++ "41m" ++ "Warning!" ++ RESET ++ " Normal text." ++ RESET; - try std.testing.expectEqualStrings(expected, bg_and_reset); +test "format - combined color, background, and effects use one sequence" { + const actual = chroma.format("{#bold,red,bg:blue,underline}styled"); + try std.testing.expectEqualStrings(CSI ++ "31;44;1;4mstyled" ++ RESET, actual); } -test "format - Escaped braces" { - const escaped_braces = chroma.format("{{This}} is {green}green."); - - const expected = "{{" ++ "This" ++ "}} is " ++ COLOR_OPEN ++ "32m" ++ "green." ++ RESET; - try std.testing.expectEqualStrings(expected, escaped_braces); +test "format - indexed and true colors" { + const actual = chroma.format("{#fg:120}indexed{#fg:255;100;0,bg:4;5;6}rgb"); + const expected = CSI ++ "38;5;120mindexed" ++ CSI ++ "38;2;255;100;0;48;2;4;5;6mrgb" ++ RESET; + try std.testing.expectEqualStrings(expected, actual); } -// Test "format - Unmatched braces" would cause a compile-time error: -// const unmatched_braces =chroma.format("{red}Unmatched"); -// This test is documented to ensure awareness of the behavior. - -test "format - No color codes" { - const no_color = chroma.format("Just plain text."); - - const expected = "Just plain text."; - try std.testing.expectEqualStrings(expected, no_color); +test "format - explicit reset prevents redundant trailing reset" { + const actual = chroma.format("{#red}warning{#reset} normal"); + try std.testing.expectEqualStrings(CSI ++ "31mwarning" ++ RESET ++ " normal", actual); } -test "format - Empty text" { - const red_hello = chroma.format(""); - - const expected = ""; - try std.testing.expectEqualStrings(expected, red_hello); +test "format - selective resets update tracked state" { + const actual = chroma.format("{#bold,dim,red}a{#normal-intensity}b{#fg:default}c"); + const expected = CSI ++ "31;1;2ma" ++ CSI ++ "22mb" ++ CSI ++ "39mc"; + try std.testing.expectEqualStrings(expected, actual); } -test "format - Empty format" { - const red_hello = chroma.format("{}"); - - const expected = "{}"; - try std.testing.expectEqualStrings(expected, red_hello); +test "format - individual effects can be disabled" { + const actual = chroma.format("{#italic,underline,blink,reverse,hidden,strikethrough}a{#no-italic,no-underline,no-blink,no-reverse,no-hidden,no-strikethrough}b"); + const expected = CSI ++ "3;4;5;7;8;9ma" ++ CSI ++ "23;24;25;27;28;29mb"; + try std.testing.expectEqualStrings(expected, actual); } -test "format - Inline reset" { - const inline_reset = chroma.format("{red}Colored{reset} Not colored."); - - const expected = COLOR_OPEN ++ "31m" ++ "Colored" ++ RESET ++ " Not colored." ++ RESET; - try std.testing.expectEqualStrings(expected, inline_reset); +test "format - later colors in a tag win" { + try std.testing.expectEqualStrings(CSI ++ "34mblue" ++ RESET, chroma.format("{#red,green,blue}blue")); } -test "format - Text following color codes without braces" { - const text_after_color = chroma.format("{red}Red {green}Green{reset} Reset."); - - const expected = COLOR_OPEN ++ "31m" ++ "Red " ++ COLOR_OPEN ++ "32m" ++ "Green" ++ RESET ++ " Reset." ++ RESET; - try std.testing.expectEqualStrings(expected, text_after_color); +test "format - Zig fields and escaped braces pass through unchanged" { + const actual = chroma.format("{{literal}} {s: >8} {d} {red}"); + try std.testing.expectEqualStrings("{{literal}} {s: >8} {d} {red}", actual); } -// test "format - Multiple color codes" { -// const multiple_color_codes =chroma.format("{red}{bgGreen}Red on green"); +test "render - plain output strips only Chroma directives" { + const rendered = chroma.render("{{status}} {#bold,red}failure: {s}{#reset}"); + try std.testing.expectEqualStrings("{{status}} " ++ CSI ++ "31;1mfailure: {s}" ++ RESET, rendered.ansi); + try std.testing.expectEqualStrings("{{status}} failure: {s}", rendered.plain); + try std.testing.expectEqualStrings(rendered.ansi, rendered.select(true)); + try std.testing.expectEqualStrings(rendered.plain, rendered.select(false)); +} -// const expected = COLOR_OPEN ++ "31;42m" ++ "Red on green" ++ RESET; -// try std.testing.expectEqualStrings(expected, multiple_color_codes); -// } +test "formatter - imported ZON config supplies styles and syntax" { + const ui = chroma.Formatter(@import("test_theme.zon")); + const actual = ui.format("{@error|underline}failure{@reset}"); + const expected = CSI ++ "38;2;220;50;47;103;1;4mfailure" ++ RESET; + try std.testing.expectEqualStrings(expected, actual); +} -// test "format - Multiple color codes with reset" { -// const multiple_color_codes_with_reset =chroma.format("{red}{bgGreen}Red on green{reset} Reset."); +test "formatter - named style can leave existing colors unchanged" { + const ui = chroma.Formatter(.{ + .styles = &.{.{ + .name = "emphasis", + .style = .{ .effects = &.{.bold} }, + }}, + }); + try std.testing.expectEqualStrings( + CSI ++ "32mgreen " ++ CSI ++ "1mstrong" ++ RESET, + ui.format("{#green}green {#emphasis}strong"), + ); +} -// const expected = COLOR_OPEN ++ "31;42m" ++ "Red on green" ++ RESET ++ " Reset." ++ RESET; -// try std.testing.expectEqualStrings(expected, multiple_color_codes_with_reset); -// } +test "formatter - automatic reset can be disabled" { + const no_reset = chroma.Formatter(.{ .auto_reset = false }); + try std.testing.expectEqualStrings(CSI ++ "31mred", no_reset.format("{#red}red")); +} -// test "format - Multiple color codes with inline reset" { -// const multiple_color_codes_with_inline_reset =chroma.format("{red}{bgGreen}Red on green{reset} Reset."); +test "format - all ANSI colors produce canonical foreground codes" { + const cases = .{ + .{ "black", "30" }, + .{ "red", "31" }, + .{ "green", "32" }, + .{ "yellow", "33" }, + .{ "blue", "34" }, + .{ "magenta", "35" }, + .{ "cyan", "36" }, + .{ "white", "37" }, + .{ "bright-black", "90" }, + .{ "bright-red", "91" }, + .{ "bright-green", "92" }, + .{ "bright-yellow", "93" }, + .{ "bright-blue", "94" }, + .{ "bright-magenta", "95" }, + .{ "bright-cyan", "96" }, + .{ "bright-white", "97" }, + }; + + inline for (cases) |case| { + const input = "{#" ++ case[0] ++ "}x"; + const expected = CSI ++ case[1] ++ "mx" ++ RESET; + try std.testing.expectEqualStrings(expected, chroma.format(input)); + } +} -// const expected = COLOR_OPEN ++ "31;42m" ++ "Red on green" ++ RESET ++ " Reset." ++ RESET; -// try std.testing.expectEqualStrings(expected, multiple_color_codes_with_inline_reset); -// } +test "format - all ANSI colors produce canonical background codes" { + const cases = .{ + .{ "black", "40" }, + .{ "red", "41" }, + .{ "green", "42" }, + .{ "yellow", "43" }, + .{ "blue", "44" }, + .{ "magenta", "45" }, + .{ "cyan", "46" }, + .{ "white", "47" }, + .{ "bright-black", "100" }, + .{ "bright-red", "101" }, + .{ "bright-green", "102" }, + .{ "bright-yellow", "103" }, + .{ "bright-blue", "104" }, + .{ "bright-magenta", "105" }, + .{ "bright-cyan", "106" }, + .{ "bright-white", "107" }, + }; + + inline for (cases) |case| { + const input = "{#bg:" ++ case[0] ++ "}x"; + const expected = CSI ++ case[1] ++ "mx" ++ RESET; + try std.testing.expectEqualStrings(expected, chroma.format(input)); + } +} -// test "format - Multiple color codes with inline reset and text after" { -// const multiple_color_codes_with_inline_reset_and_text_after =chroma.format("{red}{bgGreen}Red on green{reset} Reset."); +test "format - UTF-8 text remains byte-exact" { + try std.testing.expectEqualStrings( + CSI ++ "36mhéllø 世界" ++ RESET, + chroma.format("{#cyan}héllø 世界"), + ); +} -// const expected = COLOR_OPEN ++ "31;42m" ++ "Red on green" ++ RESET ++ " Reset." ++ RESET; -// try std.testing.expectEqualStrings(expected, multiple_color_codes_with_inline_reset_and_text_after); -// } +test "format - large compile-time input" { + const input = ("{#red}x{#reset}" ** 128); + const actual = chroma.format(input); + try std.testing.expectEqual(@as(usize, (CSI ++ "31mx" ++ RESET).len * 128), actual.len); +} diff --git a/src/utils.zig b/src/utils.zig index bb69d77..1558afc 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -1,8 +1,11 @@ -/// Asserts the provided condition is true; if not, it triggers a compile-time error -/// with the specified message. This utility function is designed to enforce -/// invariants and ensure correctness throughout the codebase. -pub fn compileAssert(ok: bool, msg: []const u8) void { - if (!ok) { - @compileError("Assertion failed: " ++ msg); - } +const std = @import("std"); + +/// Emit a consistently formatted compile-time parser diagnostic. +pub fn failAt(comptime message: []const u8, comptime offset: usize) noreturn { + @compileError(std.fmt.comptimePrint("chroma: {s} at byte {d}", .{ message, offset })); +} + +/// Emit a consistently formatted compile-time configuration diagnostic. +pub fn failConfig(comptime message: []const u8) noreturn { + @compileError("chroma config: " ++ message); } diff --git a/tests/compile_errors/color_overflow.zig b/tests/compile_errors/color_overflow.zig new file mode 100644 index 0000000..330eaa5 --- /dev/null +++ b/tests/compile_errors/color_overflow.zig @@ -0,0 +1,5 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.format("{#fg:256}text"); +} diff --git a/tests/compile_errors/duplicate_style.zig b/tests/compile_errors/duplicate_style.zig new file mode 100644 index 0000000..da5c3db --- /dev/null +++ b/tests/compile_errors/duplicate_style.zig @@ -0,0 +1,10 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.Formatter(.{ + .styles = &.{ + .{ .name = "brand", .style = .{} }, + .{ .name = "brand", .style = .{} }, + }, + }); +} diff --git a/tests/compile_errors/empty_channel.zig b/tests/compile_errors/empty_channel.zig new file mode 100644 index 0000000..8b390fd --- /dev/null +++ b/tests/compile_errors/empty_channel.zig @@ -0,0 +1,5 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.format("{#fg:1;;2}text"); +} diff --git a/tests/compile_errors/invalid_rgb.zig b/tests/compile_errors/invalid_rgb.zig new file mode 100644 index 0000000..309494e --- /dev/null +++ b/tests/compile_errors/invalid_rgb.zig @@ -0,0 +1,5 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.format("{#fg:1;2}text"); +} diff --git a/tests/compile_errors/invalid_syntax.zig b/tests/compile_errors/invalid_syntax.zig new file mode 100644 index 0000000..73ba55b --- /dev/null +++ b/tests/compile_errors/invalid_syntax.zig @@ -0,0 +1,12 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.Formatter(.{ + .syntax = .{ + .marker = '#', + .item_separator = ',', + .value_separator = ',', + .channel_separator = ';', + }, + }); +} diff --git a/tests/compile_errors/missing_close.zig b/tests/compile_errors/missing_close.zig new file mode 100644 index 0000000..9b2840b --- /dev/null +++ b/tests/compile_errors/missing_close.zig @@ -0,0 +1,5 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.format("{#red"); +} diff --git a/tests/compile_errors/reserved_style.zig b/tests/compile_errors/reserved_style.zig new file mode 100644 index 0000000..77ea17a --- /dev/null +++ b/tests/compile_errors/reserved_style.zig @@ -0,0 +1,7 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.Formatter(.{ + .styles = &.{.{ .name = "red", .style = .{} }}, + }); +} diff --git a/tests/compile_errors/unknown_directive.zig b/tests/compile_errors/unknown_directive.zig new file mode 100644 index 0000000..0ad3b95 --- /dev/null +++ b/tests/compile_errors/unknown_directive.zig @@ -0,0 +1,5 @@ +const chroma = @import("chroma"); + +comptime { + _ = chroma.format("{#wat}text"); +}