diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3835911 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + 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 }} + steps: + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - run: zig build test + + 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 }} + + quality: + name: Formatting and compile stress + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - run: zig fmt --check . + - run: zig build benchmark -Doptimize=ReleaseFast diff --git a/.gitignore b/.gitignore index a6ec0ec..df0f0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ .zig-cache/ zig-cache/ zig-out/ +zig-pkg/ build/ build-*/ docgen_tmp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f762689 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 @adia-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 1f707e6..a8a7a58 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,266 @@ -# Chroma Logger for Zig +# Chroma Logger -Chroma Logger is an advanced logging library for Zig projects, designed to bring color and clarity to your application's logging output. Utilizing the Chroma library for colorized output, Chroma Logger enhances the visibility and readability of logs, making debugging and monitoring a breeze. +[![CI](https://github.com/adia-dev/chroma-logger-zig/actions/workflows/ci.yml/badge.svg)](https://github.com/adia-dev/chroma-logger-zig/actions/workflows/ci.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-logger +Chroma Logger is a configurable, comptime-first ANSI logger for Zig. It plugs +directly into `std.log`, adds readable timestamps, levels, and scopes, and uses +[Chroma](https://github.com/adia-dev/chroma-zig) to compile message styles into +ANSI and plain-text variants without runtime parsing or allocation. -## Table of Contents +[![Chroma Logger live terminal demo](docs/assets/chroma-logger-demo.gif)](docs/assets/chroma-logger-demo.mp4) -- [Features](#features) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) - - [Installation](#installation) -- [Usage](#usage) - - [Setting Up](#setting-up) - - [Logging Example](#logging-example) -- [Contributing](#contributing) -- [License](#license) -- [Acknowledgments](#acknowledgments) +The animation above is a real `zig build run` session captured by VHS. Click it +for the MP4 version. ## Features -- **Colorized Logging**: Leverage the full spectrum of ANSI colors, including 256-color and true color support, to differentiate log levels and messages. -- **Flexible Configuration**: Control log levels and formatting with ease, allowing for detailed debugging sessions or streamlined production logs. -- **Seamless Integration**: Designed to work effortlessly within the Zig ecosystem, integrating directly into your Zig build system. +- Drop-in `std.Options.logFn` integration. +- Typed compile-time configuration, including direct `.zon` imports. +- Automatic ANSI or plain output with `NO_COLOR` and `CLICOLOR_FORCE` support. +- Configurable timestamps, fixed UTC offsets, labels, scopes, spacing, and + level width. +- Typed themes for timestamps, scopes, and every log level. +- Chroma semantic styles and custom formatting grammar inside messages. +- A deterministic writer API for files, memory buffers, tests, and custom + application sinks. +- Atomic stderr writes protected by Zig's debug-output lock. +- Clear compile-time failures for malformed logger and Chroma configuration. +- Tested on Linux, macOS, and Windows with Zig 0.16.0. + +## Installation + +Add the package to `build.zig.zon`: + +```sh +zig fetch --save=chroma_logger https://github.com/adia-dev/chroma-logger-zig/archive/refs/tags/v0.2.0.tar.gz +``` + +Import its module in `build.zig`: + +```zig +const logger_dep = b.dependency("chroma_logger", .{ + .target = target, + .optimize = optimize, +}); +exe.root_module.addImport( + "chroma-logger", + logger_dep.module("chroma-logger"), +); +``` -The logger comes in different styles, each with its own use case: +Chroma Logger is a Zig module, so there is no runtime library to link. -- `Time based`: Logs are colorized based on the time of day. -
- Time based logger -
ChromaLogger.timeBasedLog
-
+## Quick start -- `Level based`: Logs are colorized based on the log level. -
- Default based logger -
ChromaLogger.log
-
+Create a configured logger type and give its `log` function to `std.log`: -- `Default based`: Logs are displayed in the default color. -
- Default based logger -
ChromaLogger.defaultLog
-
+```zig +const std = @import("std"); +const chroma_logger = @import("chroma-logger"); -## Getting Started +const AppLogger = chroma_logger.Logger(.{}); -### Prerequisites +pub const std_options: std.Options = .{ + .log_level = .debug, + .logFn = AppLogger.log, +}; -Ensure you have Zig version 0.12.0-dev.2701+d18f52197 or newer installed on your system. +pub fn main() void { + std.log.info("Server listening on {#bold,bright-green}{s}{#reset}", .{ + "http://127.0.0.1:8080", + }); -### Installation + const database_log = std.log.scoped(.database); + database_log.warn("Query took {#bright-yellow}{d}ms{#reset}", .{240}); +} +``` -1. Fetch the project using `zig fetch` +The default layout looks like this in plain output: -```bash -zig fetch --save https://github.com/adia-dev/chroma-logger-zig/archive/refs/tags/v0.1.0.tar.gz +```text +12:34:56 INFO Server listening on http://127.0.0.1:8080 +12:34:56 WARN [database] Query took 240ms ``` -Or manually paste this in your `build.zig.zon` +For the default configuration, `chroma_logger.log` is a shorter equivalent to +`chroma_logger.Logger(.{}).log`. + +## Configure from ZON + +Configuration remains typed and compile-time checked when it lives in a file: + +```zig +const AppLogger = chroma_logger.Logger(@import("logger.zon")); +``` ```zig -.dependencies = .{ - // other deps... - .@"chroma-logger" = .{ - .url = "https://github.com/adia-dev/chroma-logger-zig/archive/refs/tags/v0.1.0.tar.gz", - .hash = "1220359dd4fb54e367f2aa310b2cf75227aec8f05b254ef93f3bafef34ee2aa39d0b", +// logger.zon +.{ + .utc_offset_minutes = 60, + .layout = .{ + .timestamp = .date_time, + .scope = .non_default, + .separator = " ", + }, + .labels = .{ + .err = "FAIL", + .info = "READY", }, - // ... -}, + .theme = .{ + .err = .{ + .foreground = .{ .rgb = .{ .r = 255, .g = 92, .b = 117 } }, + .effects = .{.bold}, + }, + .info = .{ + .foreground = .{ .rgb = .{ .r = 80, .g = 250, .b = 123 } }, + .effects = .{.bold}, + }, + }, + .message_format = .{ + .styles = .{ + .{ + .name = "value", + .style = .{ + .foreground = .{ .bright = .cyan }, + .effects = .{.bold}, + }, + }, + }, + }, +} ``` -2. **Build the project**: +The file is imported by Zig during compilation. Chroma Logger never opens or +parses a configuration file while the application is running. See +[`examples/logger.zon`](examples/logger.zon) for the complete demo theme. -Using the Zig build system, compile the library and example application: +## Configuration reference -```bash -zig build -``` +| Field | Default | Purpose | +| --- | --- | --- | +| `color` | `.auto` | Select automatic, always-ANSI, or never-ANSI output | +| `utc_offset_minutes` | `0` | Apply a fixed offset to UTC timestamps | +| `layout.timestamp` | `.time` | Use `.none`, `.time`, or `.date_time` | +| `layout.show_level` | `true` | Include the level label | +| `layout.scope` | `.non_default` | Use `.never`, `.non_default`, or `.always` | +| `layout.separator` | `" "` | Separate timestamp, level, scope, and message | +| `layout.scope_open` / `scope_close` | `"["` / `"]"` | Delimit scope names | +| `layout.level_width` | `5` | Right-pad short level labels by UTF-8 byte length | +| `labels` | Standard names | Rename `ERROR`, `WARN`, `INFO`, and `DEBUG` | +| `theme` | Built-in palette | Style timestamps, scopes, and levels with typed Chroma styles | +| `message_format` | Chroma defaults | Configure semantic styles and message grammar | -This command compiles the static library and an example executable demonstrating the Chroma Logger in action. +Fixed UTC offsets are explicit and deterministic. Chroma Logger does not read +the host timezone database or attempt daylight-saving conversion. -## Usage +## What happens at comptime? -To integrate Chroma Logger into your Zig project, include it as a package in your `build.zig` file and use it to log messages in various colors and formats: +| Operation | Phase | Runtime allocation or parsing? | +| --- | --- | --- | +| Import and type-check logger or ZON configuration | Comptime | No | +| Validate labels, layout, theme, and Chroma grammar | Comptime | No | +| Parse Chroma directives in each message | Comptime | No | +| Generate ANSI and plain message variants | Comptime | No | +| Generate ANSI fragments for levels, scopes, and timestamps | Comptime | No | +| Apply `std.log` level filtering | Comptime | No | +| Read the clock and apply the fixed UTC offset | Runtime | No allocation | +| Select ANSI or plain output from terminal capability | Runtime | One boolean branch | +| Substitute Zig formatting arguments | Runtime | Handled by `std.fmt` | +| Lock and write one complete line | Runtime | Buffered, no allocation | -### Setting Up +When timestamps are disabled, the clock read and date conversion are removed +from that logger at compile time. -In your `build.zig`: +## Color behavior -```zig -const std = @import("std"); +`color = .auto` uses Zig's detected stderr terminal mode. It produces plain +text for redirection and `NO_COLOR`, honors `CLICOLOR_FORCE`, and enables ANSI +escape support on modern Windows terminals when possible. -pub fn build(b: *std.Build) void { - ... - const chroma_logger_dep = b.dependency("chroma-logger", .{}); - ... - exe.root_module.addImport("chroma-logger", chroma_logger_dep.module("chroma-logger")); - ... -} -``` +Use `.always` for controlled environments such as a terminal recording, or +`.never` for logs intended exclusively for files and parsers. -### Logging Example +## Custom writers and deterministic tests -In your application: +Every configured logger exposes `write` in addition to the `std.log` adapter: ```zig -const std = @import("std"); -const ChromaLogger = @import("chroma-logger"); +const TestLogger = chroma_logger.Logger(.{ + .layout = .{ .timestamp = .none }, +}); + +var bytes: [256]u8 = undefined; +var writer: std.Io.Writer = .fixed(&bytes); + +try TestLogger.write( + &writer, + 0, // Unix seconds supplied by the caller + false, // plain output + .info, + .database, + "connected in {d}ms", + .{12}, +); +``` -// LogFns: -// - ChromaLogger.defaultLog -// - ChromaLogger.timeBasedLog -// - ChromaLogger.log +This path returns writer errors instead of swallowing them. The `std.log` +adapter follows Zig's conventional `void` signature and ignores stderr write +failures after safely releasing the lock. -pub const std_options: std.Options = .{ - .logFn = ChromaLogger.log, - .log_level = .debug, -}; +## Migration from 0.1 -pub fn main() !void { - std.log.info("Application started successfully", .{}); - std.log.err("Failed to load configuration", .{}); -} +Version 0.2 is a redesign for Zig 0.16 and Chroma 0.2. + +| Previous API | Version 0.2 | +| --- | --- | +| `ChromaLogger.log` | `chroma_logger.log` or `Logger(.{}).log` | +| `ChromaLogger.timeBasedLog` | `Logger(.{ .layout = .{ .timestamp = .date_time } }).log` | +| `ChromaLogger.defaultLog` | Configure `timestamp`, `show_level`, and `scope` off | +| `{red}` inside a message | `{#red}` | +| `{241}` inside a message | `{#fg:241}` | +| Hard-coded UTC+1 timestamp | Configurable `utc_offset_minutes` | + +Scopes are no longer discarded, and automatic mode no longer writes ANSI +codes into redirected output. + +## Live example and video + +Run the complete themed example: + +```sh +zig build run ``` -This example demonstrates how to use Chroma Logger to log messages in various colors and formats. The `log` function from the `ChromaLogger` package is used to log messages, and the `std.log` package is used to log messages in the standard Zig format. +Use `NO_COLOR=1 zig build run` to inspect its precomputed plain variant. -## Contributing +The terminal animation is generated from the real command by +[`docs/demo.tape`](docs/demo.tape): -We welcome contributions of all kinds from the community! Whether it's adding new features, improving documentation, or fixing bugs, please feel free to make a pull request. +```sh +vhs validate docs/demo.tape +vhs docs/demo.tape +``` -## License +The tape produces both +[`chroma-logger-demo.gif`](docs/assets/chroma-logger-demo.gif) and +[`chroma-logger-demo.mp4`](docs/assets/chroma-logger-demo.mp4). + +## Development -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. +Use Zig 0.16.0: -## Acknowledgments +```sh +zig build +zig build run +zig build test +zig build benchmark -Doptimize=ReleaseFast +zig fmt --check . +``` + +CI repeats tests on Linux, macOS, and Windows and cross-builds common targets. + +## License -- [Chroma](https://github.com/adia-dev/chroma-zig) - The underlying library providing colorized output for Chroma Logger. +Chroma Logger is available under the [MIT License](LICENSE). diff --git a/benchmarks/compile.zig b/benchmarks/compile.zig new file mode 100644 index 0000000..b0a72d5 --- /dev/null +++ b/benchmarks/compile.zig @@ -0,0 +1,31 @@ +const std = @import("std"); +const chroma_logger = @import("chroma-logger"); + +const BenchmarkLogger = chroma_logger.Logger(.{ + .message_format = .{ + .styles = &.{.{ + .name = "value", + .style = .{ + .foreground = .{ .rgb = .{ .r = 255, .g = 184, .b = 108 } }, + .effects = &.{.bold}, + }, + }}, + }, +}); + +test "compile many unique logger messages" { + var buffer: [64 * 1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + inline for (0..128) |index| { + try BenchmarkLogger.write( + &writer, + 1_709_210_096, + true, + .info, + .benchmark, + std.fmt.comptimePrint("event {d}: {{#value}}value={{d}}{{#reset}}", .{index}), + .{index}, + ); + } +} diff --git a/build.zig b/build.zig index 4aba497..a7a25c4 100644 --- a/build.zig +++ b/build.zig @@ -1,106 +1,115 @@ const std = @import("std"); -// Although this function looks imperative, note that its job is to -// declaratively construct a build graph that will be executed by an external -// runner. pub fn build(b: *std.Build) void { - // Standard target options allows the person running `zig build` to choose - // what target to build for. Here we do not override the defaults, which - // means any target is allowed, and the default is native. Other options - // for restricting supported target set are available. const target = b.standardTargetOptions(.{}); - - // Standard optimization options allow the person running `zig build` to select - // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not - // set a preferred release mode, allowing the user to decide how to optimize. const optimize = b.standardOptimizeOption(.{}); - const lib = b.addStaticLibrary(.{ - .name = "chroma-logger", - // In this case the main source file is merely a path, however, in more - // complicated build scripts, this could be a generated file. - .root_source_file = .{ .src_path = .{ .owner = b, .sub_path = "src/lib.zig" } }, + const chroma = b.dependency("chroma", .{ .target = target, .optimize = optimize, - }); - - const chroma_module = b - .dependency("chroma", .{ .target = target, .optimize = optimize }) - .module("chroma"); + }).module("chroma"); - _ = b.addModule("chroma-logger", .{ - .root_source_file = .{ .src_path = .{ .owner = b, .sub_path = "src/lib.zig" } }, + const logger = b.addModule("chroma-logger", .{ + .root_source_file = b.path("src/lib.zig"), + .target = target, + .optimize = optimize, .imports = &.{ - .{ .name = "chroma", .module = chroma_module }, + .{ .name = "chroma", .module = chroma }, }, }); - lib.root_module.addImport("chroma", chroma_module); - - // This declares intent for the library to be installed into the standard - // location when the user invokes the "install" step (the default step when - // running `zig build`). - b.installArtifact(lib); - - const exe = b.addExecutable(.{ - .name = "chroma-logger", - .root_source_file = .{ .src_path = .{ .owner = b, .sub_path = "src/main.zig" } }, + const example_module = b.createModule(.{ + .root_source_file = b.path("examples/main.zig"), .target = target, .optimize = optimize, + .imports = &.{ + .{ .name = "chroma-logger", .module = logger }, + }, }); - exe.root_module.addImport("chroma", chroma_module); - - // This declares intent for the executable to be installed into the - // standard location when the user invokes the "install" step (the default - // step when running `zig build`). - b.installArtifact(exe); - - // This *creates* a Run step in the build graph, to be executed when another - // step is evaluated that depends on it. The next line below will establish - // such a dependency. - const run_cmd = b.addRunArtifact(exe); - - // By making the run step depend on the install step, it will be run from the - // installation directory rather than directly from within the cache directory. - // This is not necessary, however, if the application depends on other installed - // files, this ensures they will be present and in the expected location. - run_cmd.step.dependOn(b.getInstallStep()); - - // This allows the user to pass arguments to the application in the build - // command itself, like this: `zig build run -- arg1 arg2 etc` - if (b.args) |args| { - run_cmd.addArgs(args); - } - - // This creates a build step. It will be visible in the `zig build --help` menu, - // and can be selected like this: `zig build run` - // This will evaluate the `run` step rather than the default, which is "install". - const run_step = b.step("run", "Run the app"); - run_step.dependOn(&run_cmd.step); - - // Creates a step for unit testing. This only builds the test executable - // but does not run it. - const lib_unit_tests = b.addTest(.{ - .root_source_file = .{ .src_path = .{ .owner = b, .sub_path = "src/lib.zig" } }, + const example = b.addExecutable(.{ + .name = "chroma-logger-demo", + .root_module = example_module, + }); + 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 live logger demo"); + run_step.dependOn(&run_command.step); + + const library_tests = b.addTest(.{ .root_module = logger }); + 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 example tests"); + test_step.dependOn(&run_library_tests.step); + test_step.dependOn(&run_example_tests.step); + + addCompileErrorTest( + b, + test_step, + logger, + target, + optimize, + "tests/compile_errors/invalid_offset.zig", + "error: chroma-logger config: utc_offset_minutes must be between -1439 and 1439", + ); + addCompileErrorTest( + b, + test_step, + logger, + target, + optimize, + "tests/compile_errors/newline_label.zig", + "error: chroma-logger config: info label cannot contain a newline", + ); + addCompileErrorTest( + b, + test_step, + logger, + target, + optimize, + "tests/compile_errors/unknown_message_style.zig", + "error: chroma: unknown directive 'missing' at byte 2", + ); + + const benchmark_module = b.createModule(.{ + .root_source_file = b.path("benchmarks/compile.zig"), .target = target, .optimize = optimize, + .imports = &.{ + .{ .name = "chroma-logger", .module = logger }, + }, }); + 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 logger stress fixture"); + benchmark_step.dependOn(&benchmark_compile.step); +} - const exe_unit_tests = b.addTest(.{ - .root_source_file = .{ .src_path = .{ .owner = b, .sub_path = "src/main.zig" } }, +fn addCompileErrorTest( + b: *std.Build, + test_step: *std.Build.Step, + logger: *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, + .imports = &.{ + .{ .name = "chroma-logger", .module = logger }, + }, }); - - const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); - - // Similar to creating the run step earlier, this exposes a `test` step to - // the `zig build --help` menu, providing a way for the user to request - // running the 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 6a66e64..f0e6a6b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,37 +1,23 @@ .{ .name = .chroma_logger, - // 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 = 0x59fdb3f3c96cce27, - - // This field is optional. - // This is currently advisory only; Zig does not yet do anything - // with this value. - .minimum_zig_version = "0.13.0", - - // This field is optional. - // Each dependency must either provide a `url` and `hash`, or a `path`. - // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. - // Once all dependencies are fetched, `zig build` no longer requires - // internet connectivity. + .minimum_zig_version = "0.16.0", .dependencies = .{ .chroma = .{ - .url = "https://github.com/adia-dev/chroma-zig/archive/refs/tags/v0.14.x.tar.gz", - .hash = "122038e074b99abfc27844394394351d911c45671ec02b9d9eb009c6565041f82212", + .url = "https://github.com/adia-dev/chroma-zig/archive/refs/tags/v0.2.0.tar.gz", + .hash = "chroma-0.2.0-dA-RkQf-AQANW_vTJeRiLMCoxwy5Urr2Pd-93p75vVIB", }, }, .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", + "LICENSE", + "README.md", + "benchmarks", + "docs", + "examples", "src", - //"LICENSE", - //"README.md", + "tests", }, } diff --git a/docs/assets/chroma-logger-demo.gif b/docs/assets/chroma-logger-demo.gif new file mode 100644 index 0000000..130ff86 Binary files /dev/null and b/docs/assets/chroma-logger-demo.gif differ diff --git a/docs/assets/chroma-logger-demo.mp4 b/docs/assets/chroma-logger-demo.mp4 new file mode 100644 index 0000000..6bed68d Binary files /dev/null and b/docs/assets/chroma-logger-demo.mp4 differ diff --git a/docs/demo.tape b/docs/demo.tape new file mode 100644 index 0000000..e19349c --- /dev/null +++ b/docs/demo.tape @@ -0,0 +1,33 @@ +Require zig + +Output docs/assets/chroma-logger-demo.gif +Output docs/assets/chroma-logger-demo.mp4 + +Set Shell "zsh" +Set FontFamily "JetBrainsMono Nerd Font Mono, Menlo, monospace" +Set FontSize 16 +Set LetterSpacing 0 +Set LineHeight 1.2 +Set Width 1180 +Set Height 650 +Set Padding 24 +Set Margin 24 +Set MarginFill "#11111b" +Set BorderRadius 14 +Set WindowBar "Colorful" +Set WindowBarSize 38 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 30ms + +Hide +Type "export PS1='$ '" +Enter +Type "unset NO_COLOR; export CLICOLOR_FORCE=1" +Enter +Type "clear" +Enter +Show + +Type "zig build run" +Enter +Sleep 7s diff --git a/examples/logger.zon b/examples/logger.zon new file mode 100644 index 0000000..f397f18 --- /dev/null +++ b/examples/logger.zon @@ -0,0 +1,74 @@ +.{ + .color = .auto, + .utc_offset_minutes = 0, + .layout = .{ + .timestamp = .time, + .show_level = true, + .scope = .non_default, + .separator = " ", + .scope_open = "[", + .scope_close = "]", + .level_width = 5, + }, + .labels = .{ + .err = "ERROR", + .warn = "WARN", + .info = "INFO", + .debug = "DEBUG", + }, + .theme = .{ + .timestamp = .{ + .foreground = .{ .rgb = .{ .r = 105, .g = 112, .b = 134 } }, + }, + .err = .{ + .foreground = .{ .rgb = .{ .r = 255, .g = 92, .b = 117 } }, + .effects = .{.bold}, + }, + .warn = .{ + .foreground = .{ .rgb = .{ .r = 255, .g = 203, .b = 107 } }, + .effects = .{.bold}, + }, + .info = .{ + .foreground = .{ .rgb = .{ .r = 99, .g = 213, .b = 255 } }, + .effects = .{.bold}, + }, + .debug = .{ + .foreground = .{ .rgb = .{ .r = 199, .g = 146, .b = 234 } }, + .effects = .{.bold}, + }, + .scope = .{ + .foreground = .{ .rgb = .{ .r = 130, .g = 139, .b = 165 } }, + }, + }, + .message_format = .{ + .styles = .{ + .{ + .name = "brand", + .style = .{ + .foreground = .{ .rgb = .{ .r = 142, .g = 110, .b = 255 } }, + .effects = .{.bold}, + }, + }, + .{ + .name = "success", + .style = .{ + .foreground = .{ .rgb = .{ .r = 80, .g = 250, .b = 123 } }, + .effects = .{.bold}, + }, + }, + .{ + .name = "value", + .style = .{ + .foreground = .{ .rgb = .{ .r = 255, .g = 184, .b = 108 } }, + .effects = .{.bold}, + }, + }, + .{ + .name = "muted", + .style = .{ + .foreground = .{ .rgb = .{ .r = 98, .g = 104, .b = 128 } }, + }, + }, + }, + }, +} diff --git a/examples/main.zig b/examples/main.zig new file mode 100644 index 0000000..e7a0d9d --- /dev/null +++ b/examples/main.zig @@ -0,0 +1,54 @@ +const std = @import("std"); +const chroma_logger = @import("chroma-logger"); + +const DemoLogger = chroma_logger.Logger(@import("logger.zon")); +const api_log = std.log.scoped(.api); +const worker_log = std.log.scoped(.worker); + +pub const std_options: std.Options = .{ + .log_level = .debug, + .logFn = DemoLogger.log, +}; + +pub fn main(init: std.process.Init) !void { + const io = init.io; + + std.log.info("{#brand}CHROMA LOGGER 0.2{#reset} live demo", .{}); + try pause(io, 550); + + std.log.debug("Loaded typed configuration at {#value}comptime{#reset}", .{}); + try pause(io, 400); + + api_log.info("Listening on {#value}http://127.0.0.1:8080{#reset}", .{}); + try pause(io, 450); + + worker_log.debug("Discovered {#value}{d}{#reset} queued jobs", .{4}); + try pause(io, 400); + + worker_log.warn("Cache is cold; the first build may be slower", .{}); + try pause(io, 500); + + inline for (.{ 25, 50, 75, 100 }) |progress| { + worker_log.info("Processing assets {#muted}··········{#reset} {#value}{d}%{#reset}", .{progress}); + try pause(io, 280); + } + + api_log.err("Health check timed out after {#value}{d}ms{#reset}", .{750}); + try pause(io, 550); + + api_log.warn("Retrying with the fallback transport", .{}); + try pause(io, 500); + + api_log.info("{#success}✓ Service recovered{#reset} in {#value}{d}ms{#reset}", .{128}); + try pause(io, 500); + + std.log.info("{#success}✓ Demo complete{#reset} — ANSI and plain output were generated at comptime", .{}); +} + +fn pause(io: std.Io, milliseconds: i64) !void { + try io.sleep(.fromMilliseconds(milliseconds), .awake); +} + +test "demo logger configuration compiles" { + try std.testing.expect(chroma_logger.resolveColor(.always, false)); +} diff --git a/src/datetime.zig b/src/datetime.zig new file mode 100644 index 0000000..dea5915 --- /dev/null +++ b/src/datetime.zig @@ -0,0 +1,94 @@ +const std = @import("std"); + +pub const DateTime = struct { + year: i64, + month: u8, + day: u8, + hour: u8, + minute: u8, + second: u8, + + /// Convert Unix seconds to a Gregorian UTC date and time. + pub fn fromUnix(seconds: i64) DateTime { + const days = @divFloor(seconds, std.time.s_per_day); + const seconds_in_day = @mod(seconds, std.time.s_per_day); + const date = civilFromDays(days); + + return .{ + .year = date.year, + .month = date.month, + .day = date.day, + .hour = @intCast(@divFloor(seconds_in_day, std.time.s_per_hour)), + .minute = @intCast(@divFloor(@mod(seconds_in_day, std.time.s_per_hour), std.time.s_per_min)), + .second = @intCast(@mod(seconds_in_day, std.time.s_per_min)), + }; + } +}; + +const Date = struct { + year: i64, + month: u8, + day: u8, +}; + +// Gregorian calendar conversion adapted from Howard Hinnant's civil calendar +// algorithm. Floor division keeps dates before the Unix epoch correct. +fn civilFromDays(days_since_epoch: i64) Date { + const shifted = days_since_epoch + 719_468; + const era = @divFloor(shifted, 146_097); + const day_of_era = shifted - era * 146_097; + const year_of_era = @divFloor( + day_of_era - @divFloor(day_of_era, 1_460) + @divFloor(day_of_era, 36_524) - @divFloor(day_of_era, 146_096), + 365, + ); + var year = year_of_era + era * 400; + const day_of_year = day_of_era - (365 * year_of_era + @divFloor(year_of_era, 4) - @divFloor(year_of_era, 100)); + const month_prime = @divFloor(5 * day_of_year + 2, 153); + const day = day_of_year - @divFloor(153 * month_prime + 2, 5) + 1; + const month = month_prime + (if (month_prime < 10) @as(i64, 3) else -9); + year += if (month <= 2) 1 else 0; + + return .{ + .year = year, + .month = @intCast(month), + .day = @intCast(day), + }; +} + +test "DateTime converts the Unix epoch" { + try std.testing.expectEqual(DateTime{ + .year = 1970, + .month = 1, + .day = 1, + .hour = 0, + .minute = 0, + .second = 0, + }, DateTime.fromUnix(0)); +} + +test "DateTime converts leap days and time of day" { + try std.testing.expectEqual(DateTime{ + .year = 2024, + .month = 2, + .day = 29, + .hour = 12, + .minute = 34, + .second = 56, + }, DateTime.fromUnix(1_709_210_096)); +} + +test "DateTime supports dates before the Unix epoch" { + try std.testing.expectEqual(DateTime{ + .year = 1969, + .month = 12, + .day = 31, + .hour = 23, + .minute = 59, + .second = 59, + }, DateTime.fromUnix(-1)); +} + +test "DateTime preserves the full Unix timestamp year range" { + try std.testing.expect(DateTime.fromUnix(std.math.maxInt(i64)).year > 292_000_000_000); + try std.testing.expect(DateTime.fromUnix(std.math.minInt(i64)).year < -292_000_000_000); +} diff --git a/src/lib.zig b/src/lib.zig index 68a9cc2..3209227 100644 --- a/src/lib.zig +++ b/src/lib.zig @@ -1,60 +1,324 @@ -// TODO: make a private util function that is configurable that all other log function inherit from, LogBuilder sort of -// TODO: make it configurable, json file maybe ?? env variable would be great but comptime complicated +//! Configurable, comptime-first logging for Zig applications. +//! +//! `Logger` produces a `std.log` compatible function from a compile-time +//! configuration. Chroma directives in messages are parsed into ANSI and +//! plain variants at compile time; runtime work is limited to timestamps, +//! argument formatting, terminal selection, and writing the line. const std = @import("std"); -const Time = @import("./time/time.zig").Time; const chroma = @import("chroma"); +const DateTime = @import("datetime.zig").DateTime; -pub fn timeBasedLog(comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime message: []const u8, args: anytype) void { - _ = scope; +pub const ColorPolicy = chroma.terminal.ColorPolicy; +pub const Style = chroma.Style; +pub const Color = chroma.Color; +pub const Effect = chroma.Effect; - const timestamp = std.time.timestamp(); - const offset_in_seconds = 3600; - // TODO: make the offset configurable - const time = Time.from_unix_timestamp(timestamp + @as(i64, offset_in_seconds)); +/// Timestamp displayed before each log line. +pub const TimestampMode = enum { + none, + time, + date_time, +}; - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - const stderr = std.io.getStdErr().writer(); +/// Controls when the `std.log` scope is displayed. +pub const ScopeMode = enum { + never, + non_default, + always, +}; - // TODO: search if it is possible to merge these into a single print call - const formatted_timestamp = comptime chroma.format("{247}{d:0>4}-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2} {reset}"); - nosuspend stderr.print(formatted_timestamp, .{ time.year, time.month, time.day, time.hour, time.minute, time.second }) catch return; - nosuspend stderr.print("{s} ", .{level_to_string(level)}) catch return; - const formatted_message = comptime chroma.format(message); - nosuspend stderr.print(formatted_message ++ "\n", args) catch return; +/// Human-readable layout of a log line. +pub const Layout = struct { + timestamp: TimestampMode = .time, + show_level: bool = true, + scope: ScopeMode = .non_default, + separator: []const u8 = " ", + scope_open: []const u8 = "[", + scope_close: []const u8 = "]", + level_width: usize = 5, +}; + +/// Text used for Zig's four built-in log levels. +pub const Labels = struct { + err: []const u8 = "ERROR", + warn: []const u8 = "WARN", + info: []const u8 = "INFO", + debug: []const u8 = "DEBUG", +}; + +/// Typed Chroma styles for logger-owned fields. +pub const Theme = struct { + timestamp: Style = .{ + .foreground = .{ .bright = .black }, + .effects = &.{.dim}, + }, + err: Style = .{ + .foreground = .{ .bright = .red }, + .effects = &.{.bold}, + }, + warn: Style = .{ + .foreground = .{ .bright = .yellow }, + .effects = &.{.bold}, + }, + info: Style = .{ + .foreground = .{ .bright = .cyan }, + .effects = &.{.bold}, + }, + debug: Style = .{ + .foreground = .{ .bright = .magenta }, + .effects = &.{.bold}, + }, + scope: Style = .{ + .foreground = .{ .bright = .black }, + }, +}; + +/// Compile-time logger configuration. +pub const Config = struct { + /// Automatic terminal detection, forced ANSI, or forced plain output. + color: ColorPolicy = .auto, + /// Fixed offset from UTC. This deliberately avoids hidden timezone I/O. + utc_offset_minutes: i16 = 0, + layout: Layout = .{}, + labels: Labels = .{}, + theme: Theme = .{}, + /// Chroma syntax and semantic styles accepted inside log messages. + message_format: chroma.Config = .{}, +}; + +/// Build a `std.log` compatible logger from compile-time configuration. +pub fn Logger(comptime config: Config) type { + comptime validateConfig(config); + + const MessageFormatter = chroma.Formatter(config.message_format); + const timestamp_style = comptime styleFragment(config.theme.timestamp); + const scope_style = comptime styleFragment(config.theme.scope); + const reset = comptime chroma.render("{#reset}"); + + return struct { + /// `std.Options.logFn` implementation using locked stderr. + pub fn log( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime message: []const u8, + args: anytype, + ) void { + const io = std.Options.debug_io; + const previous_protection = io.swapCancelProtection(.blocked); + defer _ = io.swapCancelProtection(previous_protection); + + var buffer: [256]u8 = undefined; + const locked = std.debug.lockStderr(&buffer); + defer std.debug.unlockStderr(); + + const terminal = locked.terminal(); + const use_color = resolveColor(config.color, terminal.mode == .escape_codes); + const timestamp = if (config.layout.timestamp == .none) + 0 + else + std.Io.Clock.real.now(io).toSeconds(); + + write( + terminal.writer, + timestamp, + use_color, + level, + scope, + message, + args, + ) catch {}; + } + + /// Render one deterministic log line to any Zig writer. + /// + /// Supplying the timestamp and color decision makes this API suitable + /// for tests, files, memory buffers, and application-owned sinks. + pub fn write( + writer: *std.Io.Writer, + unix_seconds: i64, + use_color: bool, + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime message: []const u8, + args: anytype, + ) std.Io.Writer.Error!void { + var has_prefix = false; + + if (config.layout.timestamp != .none) { + try writeTimestamp(writer, unix_seconds, use_color, timestamp_style, reset, config); + has_prefix = true; + } + + if (config.layout.show_level) { + try writeSeparator(writer, &has_prefix, config.layout.separator); + const label = levelLabel(config.labels, level); + try writeStyled(writer, levelStyle(config.theme, level), reset, label, use_color); + if (label.len < config.layout.level_width) { + try writer.splatByteAll(' ', config.layout.level_width - label.len); + } + } + + if (showScope(config.layout.scope, scope)) { + try writeSeparator(writer, &has_prefix, config.layout.separator); + try writeRendered(writer, scope_style, use_color); + try writer.writeAll(config.layout.scope_open); + try writer.writeAll(@tagName(scope)); + try writer.writeAll(config.layout.scope_close); + if (scope_style.ansi.len != 0) try writeRendered(writer, reset, use_color); + } + + try writeSeparator(writer, &has_prefix, config.layout.separator); + const rendered_message = comptime MessageFormatter.render(message); + if (use_color) { + try writer.print(rendered_message.ansi, args); + } else { + try writer.print(rendered_message.plain, args); + } + try writer.writeByte('\n'); + } + }; } -pub fn defaultLog(comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime message: []const u8, args: anytype) void { - _ = level; - _ = scope; +/// Default logger with time, level, automatic color, and non-default scopes. +pub const default = Logger(.{}); - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - const stderr = std.io.getStdErr().writer(); +/// Drop-in default `std.Options.logFn` implementation. +pub const log = default.log; - const formatted_message = comptime chroma.format(message); - nosuspend stderr.print(formatted_message ++ "\n", args) catch return; +/// Resolve a color policy from a previously detected ANSI capability. +pub fn resolveColor(policy: ColorPolicy, ansi_capable: bool) bool { + return switch (policy) { + .auto => ansi_capable, + .always => true, + .never => false, + }; } -pub fn log(comptime level: std.log.Level, comptime scope: @Type(.enum_literal), comptime message: []const u8, args: anytype) void { - _ = scope; +fn writeTimestamp( + writer: *std.Io.Writer, + unix_seconds: i64, + use_color: bool, + comptime style: chroma.Rendered, + comptime reset: chroma.Rendered, + comptime config: Config, +) std.Io.Writer.Error!void { + const offset_seconds = @as(i64, config.utc_offset_minutes) * std.time.s_per_min; + const datetime = DateTime.fromUnix(unix_seconds + offset_seconds); + + try writeRendered(writer, style, use_color); + switch (config.layout.timestamp) { + .none => unreachable, + .time => try writer.print("{d:0>2}:{d:0>2}:{d:0>2}", .{ + datetime.hour, + datetime.minute, + datetime.second, + }), + .date_time => { + try writeYear(writer, datetime.year); + try writer.print("-{d:0>2}-{d:0>2} {d:0>2}:{d:0>2}:{d:0>2}", .{ + datetime.month, + datetime.day, + datetime.hour, + datetime.minute, + datetime.second, + }); + }, + } + if (style.ansi.len != 0) try writeRendered(writer, reset, use_color); +} - std.debug.lockStdErr(); - defer std.debug.unlockStdErr(); - const stderr = std.io.getStdErr().writer(); +fn writeYear(writer: *std.Io.Writer, year: i64) std.Io.Writer.Error!void { + if (year < 0) { + try writer.writeByte('-'); + try writer.print("{d:0>4}", .{@as(u64, @intCast(-year))}); + } else { + try writer.print("{d:0>4}", .{@as(u64, @intCast(year))}); + } +} - // TODO: search if it is possible to merge these into a single print call - nosuspend stderr.print("{s} ", .{level_to_string(level)}) catch return; - const formatted_message = comptime chroma.format(message); - nosuspend stderr.print(formatted_message ++ "\n", args) catch return; +fn writeSeparator(writer: *std.Io.Writer, has_prefix: *bool, separator: []const u8) std.Io.Writer.Error!void { + if (has_prefix.*) try writer.writeAll(separator); + has_prefix.* = true; } -fn level_to_string(comptime level: std.log.Level) []const u8 { +fn writeStyled( + writer: *std.Io.Writer, + comptime style: chroma.Rendered, + comptime reset: chroma.Rendered, + text: []const u8, + use_color: bool, +) std.Io.Writer.Error!void { + try writeRendered(writer, style, use_color); + try writer.writeAll(text); + if (style.ansi.len != 0) try writeRendered(writer, reset, use_color); +} + +fn writeRendered(writer: *std.Io.Writer, comptime rendered: chroma.Rendered, use_color: bool) std.Io.Writer.Error!void { + try writer.writeAll(rendered.select(use_color)); +} + +fn styleFragment(comptime style: Style) chroma.Rendered { + const formatter = chroma.Formatter(.{ + .styles = &.{.{ .name = "logger", .style = style }}, + .auto_reset = false, + }); + return formatter.render("{#logger}"); +} + +fn levelStyle(comptime theme: Theme, comptime level: std.log.Level) chroma.Rendered { + return styleFragment(switch (level) { + .err => theme.err, + .warn => theme.warn, + .info => theme.info, + .debug => theme.debug, + }); +} + +fn levelLabel(comptime labels: Labels, comptime level: std.log.Level) []const u8 { return switch (level) { - .err => chroma.format("{red}ERROR{reset}"), - .warn => chroma.format("{yellow}WARN{reset}"), - .info => chroma.format("{blue}INFO{reset}"), - .debug => chroma.format("{magenta}DEBUG{reset}"), + .err => labels.err, + .warn => labels.warn, + .info => labels.info, + .debug => labels.debug, + }; +} + +fn showScope(comptime mode: ScopeMode, comptime scope: @EnumLiteral()) bool { + return switch (mode) { + .never => false, + .non_default => scope != .default, + .always => true, }; } + +fn validateConfig(comptime config: Config) void { + if (config.utc_offset_minutes <= -24 * 60 or config.utc_offset_minutes >= 24 * 60) { + @compileError("chroma-logger config: utc_offset_minutes must be between -1439 and 1439"); + } + if (config.layout.level_width > 64) { + @compileError("chroma-logger config: level_width cannot exceed 64"); + } + + validateLinePart("separator", config.layout.separator, true); + validateLinePart("scope_open", config.layout.scope_open, true); + validateLinePart("scope_close", config.layout.scope_close, true); + validateLinePart("err label", config.labels.err, false); + validateLinePart("warn label", config.labels.warn, false); + validateLinePart("info label", config.labels.info, false); + validateLinePart("debug label", config.labels.debug, false); +} + +fn validateLinePart(comptime name: []const u8, comptime value: []const u8, comptime allow_empty: bool) void { + if (!allow_empty and value.len == 0) { + @compileError("chroma-logger config: " ++ name ++ " cannot be empty"); + } + if (std.mem.indexOfAny(u8, value, "\r\n") != null) { + @compileError("chroma-logger config: " ++ name ++ " cannot contain a newline"); + } +} + +test { + _ = @import("datetime.zig"); + _ = @import("tests.zig"); +} diff --git a/src/main.zig b/src/main.zig deleted file mode 100644 index 30b846e..0000000 --- a/src/main.zig +++ /dev/null @@ -1,59 +0,0 @@ -const std = @import("std"); -const Logger = @import("lib.zig"); -const chroma = @import("chroma"); - -const User = struct { - name: []const u8, - level: u8 = 0, - - pub fn format(self: User, comptime fmt: []const u8, options: std.fmt.FormatOptions, writer: anytype) !void { - _ = fmt; - _ = options; - - try std.fmt.format(writer, "User {{ name: {s}, level: {d} }}", .{ self.name, self.level }); - } - - pub fn levelUp(self: *User) void { - self.level = @addWithOverflow(self.level, 1)[0]; - } -}; - -pub const std_options: std.Options = .{ .log_level = .debug, .logFn = Logger.defaultLog }; - -pub fn main() !void { - // Debug log - typically used for detailed information useful for debugging - std.log.debug("Debugging the application start process", .{}); - - // Info log - generally useful information to log (service start-up, configuration assumptions, etc.) - std.log.info("Application {green}successfully{reset} started", .{}); - - // Warn log - something unexpected happened, but the application is still running as expected - std.log.warn("The configuration file {241}`config.json`{reset} was not found, using defaults", .{}); - - // Error log - a serious problem that indicates the application might not be able to continue running - std.log.err("Failed to connect to the database, terminating application", .{}); - - var alice = User{ .name = "Alice", .level = 1 }; - var bob = User{ .name = "Bob", .level = 2 }; - const users = [_]*User{ &alice, &bob }; - - std.log.info("{}", .{alice}); - - std.log.info("{}", .{bob}); - - const one_second_in_nano = 1_000_000_000; - - while (bob.level < 10) { - for (users) |user| { - user.levelUp(); - std.log.debug("UP +1: {}", .{user}); - } - std.time.sleep(one_second_in_nano); // Sleep for 1 second - - if (bob.level == 9) { - std.log.warn("{221}{s} is about to reach level 10 soon !", .{bob.name}); - } else if (bob.level == 10) { - std.log.info("{cyan}{s} reached level 10 !", .{bob.name}); - } - } -} diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..e6dbabb --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,212 @@ +const std = @import("std"); +const logger = @import("lib.zig"); + +test "Logger writes deterministic plain output" { + const PlainLogger = logger.Logger(.{ .color = .never }); + var buffer: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try PlainLogger.write( + &writer, + 45_296, + false, + .info, + .default, + "started on port {d}", + .{8080}, + ); + + try std.testing.expectEqualStrings( + "12:34:56 INFO started on port 8080\n", + writer.buffered(), + ); +} + +test "Logger includes non-default scopes" { + const ScopedLogger = logger.Logger(.{}); + var buffer: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try ScopedLogger.write( + &writer, + 0, + false, + .debug, + .database, + "query completed", + .{}, + ); + + try std.testing.expectEqualStrings( + "00:00:00 DEBUG [database] query completed\n", + writer.buffered(), + ); +} + +test "Logger renders message styles into ANSI and plain variants" { + const ThemedLogger = logger.Logger(.{ + .layout = .{ .timestamp = .none }, + .message_format = .{ + .styles = &.{.{ + .name = "success", + .style = .{ + .foreground = .{ .bright = .green }, + .effects = &.{.bold}, + }, + }}, + }, + }); + + var ansi_buffer: [256]u8 = undefined; + var ansi_writer: std.Io.Writer = .fixed(&ansi_buffer); + try ThemedLogger.write( + &ansi_writer, + 0, + true, + .info, + .default, + "{#success}ready{#reset} in {d}ms", + .{12}, + ); + try std.testing.expectEqualStrings( + "\x1b[96;1mINFO\x1b[0m \x1b[92;1mready\x1b[0m in 12ms\n", + ansi_writer.buffered(), + ); + + var plain_buffer: [256]u8 = undefined; + var plain_writer: std.Io.Writer = .fixed(&plain_buffer); + try ThemedLogger.write( + &plain_writer, + 0, + false, + .info, + .default, + "{#success}ready{#reset} in {d}ms", + .{12}, + ); + try std.testing.expectEqualStrings( + "INFO ready in 12ms\n", + plain_writer.buffered(), + ); +} + +test "Logger supports custom layout labels and fixed UTC offsets" { + const CustomLogger = logger.Logger(.{ + .utc_offset_minutes = 90, + .layout = .{ + .timestamp = .date_time, + .scope = .always, + .separator = " | ", + .scope_open = "(", + .scope_close = ")", + .level_width = 4, + }, + .labels = .{ .err = "FAIL" }, + }); + + var buffer: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try CustomLogger.write( + &writer, + 0, + false, + .err, + .default, + "offline", + .{}, + ); + + try std.testing.expectEqualStrings( + "1970-01-01 01:30:00 | FAIL | (default) | offline\n", + writer.buffered(), + ); +} + +test "Logger formats timestamps before the Unix epoch" { + const HistoricalLogger = logger.Logger(.{ + .layout = .{ + .timestamp = .date_time, + .show_level = false, + .scope = .never, + }, + }); + var buffer: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try HistoricalLogger.write(&writer, -1, false, .info, .default, "tick", .{}); + try std.testing.expectEqualStrings( + "1969-12-31 23:59:59 tick\n", + writer.buffered(), + ); +} + +test "resolveColor applies explicit policy before capability" { + try std.testing.expect(!logger.resolveColor(.auto, false)); + try std.testing.expect(logger.resolveColor(.auto, true)); + try std.testing.expect(logger.resolveColor(.always, false)); + try std.testing.expect(!logger.resolveColor(.never, true)); +} + +test "Logger avoids a leading separator when all prefix fields are hidden" { + const MessageOnlyLogger = logger.Logger(.{ + .layout = .{ + .timestamp = .none, + .show_level = false, + .scope = .never, + }, + }); + var buffer: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try MessageOnlyLogger.write(&writer, 0, false, .info, .default, "hello", .{}); + try std.testing.expectEqualStrings("hello\n", writer.buffered()); +} + +test "Logger accepts custom Chroma message grammar" { + const CustomSyntaxLogger = logger.Logger(.{ + .layout = .{ .timestamp = .none, .show_level = false }, + .message_format = .{ + .syntax = .{ + .marker = '@', + .item_separator = '|', + .value_separator = '=', + .channel_separator = '/', + }, + }, + }); + var buffer: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try CustomSyntaxLogger.write( + &writer, + 0, + true, + .info, + .default, + "{@fg=12/34/56|bold}custom{@reset}", + .{}, + ); + try std.testing.expectEqualStrings( + "\x1b[38;2;12;34;56;1mcustom\x1b[0m\n", + writer.buffered(), + ); +} + +test "Logger propagates custom writer failures" { + const PlainLogger = logger.Logger(.{}); + var buffer: [4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + + try std.testing.expectError( + error.WriteFailed, + PlainLogger.write(&writer, 0, false, .info, .default, "too long", .{}), + ); +} + +test "Logger function is compatible with std.Options" { + const options: std.Options = .{ + .log_level = .debug, + .logFn = logger.Logger(.{ .color = .never }).log, + }; + try std.testing.expectEqual(std.log.Level.debug, options.log_level); +} diff --git a/src/time/time.zig b/src/time/time.zig deleted file mode 100644 index aa98857..0000000 --- a/src/time/time.zig +++ /dev/null @@ -1,65 +0,0 @@ -const std = @import("std"); -const math = std.math; - -pub const Time = struct { - year: u16, - month: u8, - day: u8, - hour: u8, - minute: u8, - second: u8, - - /// Converts a Unix timestamp to a Time struct. - pub fn from_unix_timestamp(timestamp: i64) Time { - const days = @divFloor(timestamp, 86400); - const secondsInDay = @mod(timestamp, 86400); - const yearInfo = get_year(days); - const monthDayInfo = get_month_and_day(yearInfo.daysIntoYear, yearInfo.year); - - return Time{ - .year = yearInfo.year, - .month = monthDayInfo.month, - .day = monthDayInfo.day, - .hour = @intCast(@divFloor(secondsInDay, 3600)), - .minute = @intCast(@divFloor(@mod(secondsInDay, 3600), 60)), - .second = @intCast(@mod(secondsInDay, 60)), - }; - } -}; - -fn get_year(days: i64) struct { year: u16, daysIntoYear: i64 } { - var year: u16 = 1970; - var daysLeft: i64 = days; - - while (true) { - const daysInYear: i64 = if (is_leap_year(year)) 366 else 365; - if (daysLeft < daysInYear) break; - daysLeft -= daysInYear; - year += 1; - } - - return .{ .year = year, .daysIntoYear = daysLeft }; -} - -fn get_month_and_day(daysIntoYear: i64, year: u16) struct { month: u8, day: u8 } { - const daysInMonth = [_]u8{ 31, 28 + bool_to_int(is_leap_year(year)), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; - var month: u8 = 1; - var daysLeft: i64 = daysIntoYear; - - while (true) { - const daysThisMonth = daysInMonth[month - 1]; - if (daysLeft < daysThisMonth) break; - daysLeft -= daysThisMonth; - month += 1; - } - - return .{ .month = month, .day = @intCast(daysLeft + 1) }; -} - -fn is_leap_year(year: u16) bool { - return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0; -} - -fn bool_to_int(b: bool) u8 { - return if (b) 1 else 0; -} diff --git a/tests/compile_errors/invalid_offset.zig b/tests/compile_errors/invalid_offset.zig new file mode 100644 index 0000000..d7cccf3 --- /dev/null +++ b/tests/compile_errors/invalid_offset.zig @@ -0,0 +1,5 @@ +const chroma_logger = @import("chroma-logger"); + +comptime { + _ = chroma_logger.Logger(.{ .utc_offset_minutes = 1_440 }); +} diff --git a/tests/compile_errors/newline_label.zig b/tests/compile_errors/newline_label.zig new file mode 100644 index 0000000..e24fb99 --- /dev/null +++ b/tests/compile_errors/newline_label.zig @@ -0,0 +1,7 @@ +const chroma_logger = @import("chroma-logger"); + +comptime { + _ = chroma_logger.Logger(.{ + .labels = .{ .info = "INFO\n" }, + }); +} diff --git a/tests/compile_errors/unknown_message_style.zig b/tests/compile_errors/unknown_message_style.zig new file mode 100644 index 0000000..9d01251 --- /dev/null +++ b/tests/compile_errors/unknown_message_style.zig @@ -0,0 +1,17 @@ +const std = @import("std"); +const chroma_logger = @import("chroma-logger"); + +test { + const TestLogger = chroma_logger.Logger(.{}); + var buffer: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buffer); + try TestLogger.write( + &writer, + 0, + false, + .info, + .default, + "{#missing}message", + .{}, + ); +}