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)
+[](https://github.com/adia-dev/chroma-zig/actions/workflows/zig_test.yml)
+[](https://ziglang.org/)
+[](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.
+
+
+
+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.
-
+## 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");
+}