ReviewOS

also looking at this

craft-native/craft

fix(log): keep the terminal's colour, and let logging be turned off

#81
Merged glennmichael123 wants to merge fix/log-colour-and-off into main
4 files +126 -16
packages/zig/src/bridge_log.zigmodified+4-0
Changes to packages/zig/src/bridge_log.zig
@@ -91,6 +91,10 @@ pub const LogBridge = struct {
9191 .Warning => host_log.log(.Warning, "[page] {s}", .{message}),
9292 .Error => host_log.log(.Error, "[page] {s}", .{message}),
9393 .Fatal => host_log.log(.Fatal, "[page] {s}", .{message}),
94 // `levelFor` never returns this — a page cannot ask to be silenced,
95 // only the host can — but the switch stays exhaustive so adding a
96 // level is a compile error rather than a dropped record.
97 .Off => {},
9498 }
9599
96100 bridge_error.sendResultToJS(self.allocator, "log", "{\"ok\":true}");
packages/zig/src/cli.zigmodified+2-1
Changes to packages/zig/src/cli.zig
@@ -640,7 +640,8 @@ fn printHelp() void {
640640 \\ std.log; debug-mode std.debug.print tracing is
641641 \\ not included.
642642 \\ --log-json One JSON object per record instead of a line.
643 \\ --log-level <LEVEL> debug, info, warn, error or fatal (default info).
643 \\ --log-level <LEVEL> debug, info, warn, error, fatal or off
644 \\ (default info).
644645 \\ --log-quiet Write only to --log-file, leaving stderr alone.
645646 \\ --keep-running Stay running after the last window closes.
646647 \\ The default for tray and menubar-only apps;
packages/zig/src/log.zigmodified+115-14
Changes to packages/zig/src/log.zig
@@ -9,6 +9,15 @@ pub const LogLevel = enum {
99 Warning,
1010 Error,
1111 Fatal,
12 /// Record nothing.
13 ///
14 /// Last, so the existing `level >= min_level` comparison silences
15 /// everything when it is the minimum — no branch anywhere else has to know
16 /// about it. `logging.zig` has had an `off` for as long as it has existed;
17 /// this side had no way to say the same thing, so an app that wanted
18 /// quiet had to not configure logging at all, which is not the same as
19 /// asking for it.
20 Off,
1221
1322 pub fn toString(self: LogLevel) []const u8 {
1423 return switch (self) {
@@ -17,6 +26,7 @@ pub const LogLevel = enum {
1726 .Warning => "WARN",
1827 .Error => "ERROR",
1928 .Fatal => "FATAL",
29 .Off => "OFF",
2030 };
2131 }
2232
@@ -27,6 +37,8 @@ pub const LogLevel = enum {
2737 .Warning => "\x1B[33m", // Yellow
2838 .Error => "\x1B[31m", // Red
2939 .Fatal => "\x1B[35m", // Magenta
40 // Never emitted; a record at this level is filtered before formatting.
41 .Off => "",
3042 };
3143 }
3244};
@@ -96,6 +108,12 @@ threadlocal var in_log: bool = false;
96108/// reachable from inside a log call. Captured once instead.
97109var log_io: ?std.Io = null;
98110
111/// Whether stderr is a terminal, decided once at `init`.
112///
113/// Per-record would mean an `isatty` on every line, and the answer cannot
114/// change for a running process anyway.
115var stderr_is_tty: bool = false;
116
99117pub fn init(config: LogConfig) !void {
100118 log_mutex.lock();
101119 defer log_mutex.unlock();
@@ -110,6 +128,7 @@ pub fn init(config: LogConfig) !void {
110128 current_config = config;
111129 log_file_offset = 0;
112130 log_io = io_context.get();
131 stderr_is_tty = std.Io.File.stderr().isTty(log_io.?) catch false;
113132
114133 // Take copies before anything else can free the originals.
115134 if (config.output_file) |path| {
@@ -297,28 +316,41 @@ pub fn log(
297316 }
298317
299318 output_len = formatTruncating(&output_buf, "\n", .{ timestamp, level.toString(), esc_buf[0..esc_len] }).len;
319 } else if (current_config.enable_timestamps) {
320 output_len = formatTruncating(&output_buf, "[{s}] {s} {s}\n", .{ timestamp, level.toString(), message }).len;
300321 } else {
301 // Standard output
302 const reset = "\x1B[0m";
303 const dim = "\x1B[2m";
304
305 if (current_config.enable_timestamps and current_config.enable_colors) {
306 output_len = formatTruncating(&output_buf, "{s}[{s}]{s} {s}{s}{s} {s}\n", .{ dim, timestamp, reset, level.color(), level.toString(), reset, message }).len;
307 } else if (current_config.enable_timestamps) {
308 output_len = formatTruncating(&output_buf, "[{s}] {s} {s}\n", .{ timestamp, level.toString(), message }).len;
309 } else if (current_config.enable_colors) {
310 output_len = formatTruncating(&output_buf, "{s}{s}{s} {s}\n", .{ level.color(), level.toString(), reset, message }).len;
311 } else {
312 output_len = formatTruncating(&output_buf, "{s} {s}\n", .{ level.toString(), message }).len;
313 }
322 output_len = formatTruncating(&output_buf, "{s} {s}\n", .{ level.toString(), message }).len;
314323 }
315324
316325 const output = output_buf[0..output_len];
317326
327 // Colour is formatted separately, for stderr only.
328 //
329 // Both sinks used to receive the same bytes, so a run with `--log-file`
330 // either put ANSI escapes in the file or — the workaround this replaces —
331 // turned colour off for the terminal as well, to keep the file clean. A
332 // log file full of `\x1b[2m` is unreadable to `grep`, and a terminal that
333 // loses its colours because a file was requested is a strange trade.
334 //
335 // Only when stderr is a terminal, decided once at `init` rather than per
336 // record: colour written into a redirected stderr is the same problem as
337 // colour written into the file.
338 var colour_buf: [4096]u8 = undefined;
339 var colour_len: usize = 0;
340 if (current_config.enable_colors and stderr_is_tty and !current_config.json_output) {
341 const reset = "\x1B[0m";
342 const dim = "\x1B[2m";
343 colour_len = if (current_config.enable_timestamps)
344 formatTruncating(&colour_buf, "{s}[{s}]{s} {s}{s}{s} {s}\n", .{ dim, timestamp, reset, level.color(), level.toString(), reset, message }).len
345 else
346 formatTruncating(&colour_buf, "{s}{s}{s} {s}\n", .{ level.color(), level.toString(), reset, message }).len;
347 }
348
318349 const io = log_io orelse io_context.get();
319350
320351 if (current_config.mirror_to_stderr) {
321 _ = std.Io.File.stderr().writeStreamingAll(io, output) catch {};
352 const for_stderr = if (colour_len > 0) colour_buf[0..colour_len] else output;
353 _ = std.Io.File.stderr().writeStreamingAll(io, for_stderr) catch {};
322354 }
323355
324356 // Appended at the tracked offset. `writeStreamingAll` writes from position
@@ -661,3 +693,72 @@ test "logging from inside logging drops the inner record instead of hanging" {
661693 resetInLogForTesting();
662694 try testing.expect(!in_log);
663695}
696
697test "off silences every level" {
698 // `logging.zig` has always had an `off`; this side had no way to say the
699 // same thing, so an app that wanted quiet had to not configure logging at
700 // all — which is not the same as asking for it.
701 const io = io_context.get();
702 const path = "craft-log-off-test.txt";
703 std.Io.Dir.cwd().deleteFile(io, path) catch {};
704 defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
705
706 try init(.{ .output_file = path, .min_level = .Off, .mirror_to_stderr = false });
707 log(.Debug, "no", .{});
708 log(.Info, "no", .{});
709 log(.Warning, "no", .{});
710 log(.Error, "no", .{});
711 log(.Fatal, "not even this", .{});
712 deinit();
713
714 var read_buf: [256]u8 = undefined;
715 const file = try std.Io.Dir.cwd().openFile(io, path, .{});
716 defer file.close(io);
717 const n = try file.readPositionalAll(io, &read_buf, 0);
718 try testing.expectEqual(@as(usize, 0), n);
719}
720
721test "off is the highest level, so the ordering check silences by itself" {
722 // Placed last in the enum on purpose: `level >= min_level` then does the
723 // work and no other branch has to know the concept exists.
724 const values = std.enums.values(LogLevel);
725 try testing.expectEqual(LogLevel.Off, values[values.len - 1]);
726 for (values) |level| {
727 if (level == .Off) continue;
728 try testing.expect(@backingInt(level) < @backingInt(LogLevel.Off));
729 }
730}
731
732test "the file never receives colour, even when the terminal is getting it" {
733 // Both sinks used to be handed the same bytes, so a run with --log-file
734 // either wrote ANSI escapes into the file or turned colour off for the
735 // terminal too. A log full of \x1b[2m is unreadable to grep.
736 //
737 // `stderr_is_tty` is forced, because a test process is piped and would
738 // otherwise produce no coloured line at all — the first version of this
739 // test passed against a deliberately reverted fix for exactly that reason.
740 const io = io_context.get();
741 const path = "craft-log-colour-test.txt";
742 std.Io.Dir.cwd().deleteFile(io, path) catch {};
743 defer std.Io.Dir.cwd().deleteFile(io, path) catch {};
744
745 try init(.{
746 .output_file = path,
747 .min_level = .Debug,
748 .enable_colors = true, // asked for, and still must not reach the file
749 .mirror_to_stderr = false,
750 });
751 stderr_is_tty = true; // after init, which computes it
752 defer stderr_is_tty = false;
753 log(.Error, "a red line on a terminal", .{});
754 deinit();
755
756 var read_buf: [1024]u8 = undefined;
757 const file = try std.Io.Dir.cwd().openFile(io, path, .{});
758 defer file.close(io);
759 const n = try file.readPositionalAll(io, &read_buf, 0);
760 const contents = read_buf[0..n];
761
762 try testing.expect(std.mem.indexOf(u8, contents, "a red line on a terminal") != null);
763 try testing.expect(std.mem.indexOfScalar(u8, contents, 0x1B) == null);
764}
packages/zig/src/minimal.zigmodified+5-1
Changes to packages/zig/src/minimal.zig
@@ -455,7 +455,7 @@ fn configureLogging(options: cli.WindowOptions) !void {
455455 const level: craft.Log.LogLevel = if (options.log_level) |name| blk: {
456456 break :blk parseLogLevel(name) orelse {
457457 std.debug.print(
458 "Error: unknown --log-level '{s}' (expected debug, info, warn, error or fatal)\n",
458 "Error: unknown --log-level '{s}' (expected debug, info, warn, error, fatal or off)\n",
459459 .{name},
460460 );
461461 std.process.exit(1);
@@ -484,6 +484,7 @@ fn configureLogging(options: cli.WindowOptions) !void {
484484 .Warning => .warn,
485485 .Error => .err,
486486 .Fatal => .fatal,
487 .Off => .off,
487488 },
488489 // Timestamp and colour are the outer sink's job now; doubling them
489490 // would put two of each on every line.
@@ -514,6 +515,9 @@ fn parseLogLevel(name: []const u8) ?craft.Log.LogLevel {
514515 .{ "error", craft.Log.LogLevel.Error },
515516 .{ "err", craft.Log.LogLevel.Error },
516517 .{ "fatal", craft.Log.LogLevel.Fatal },
518 .{ "off", craft.Log.LogLevel.Off },
519 .{ "none", craft.Log.LogLevel.Off },
520 .{ "silent", craft.Log.LogLevel.Off },
517521 };
518522 inline for (table) |entry| {
519523 if (std.ascii.eqlIgnoreCase(name, entry[0])) return entry[1];