Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions scripts/regressions/stdout-color-decided-from-stderr-tty.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Regression: commands that write their colourised rows to stdout decided
# whether to colour by probing *stderr*. Run from a terminal with stdout
# redirected - `mt info jq > out.txt` - and raw ANSI landed in the file.
#
# Only a real terminal on stderr can reproduce it, so the run happens under
# script(1): the child gets a pty, and the inner redirect sends stdout to a
# file while stderr stays on the pty. That is the exact user-facing shape.
#
# The second half proves the forced policy still reaches the stdout path:
# CLICOLOR_FORCE=1 must colourise even when stdout is a plain file.
#
# Hermetic: a seeded keg in a throwaway prefix, MALT_OFFLINE=1, no network.
#
# Usage: scripts/regressions/stdout-color-decided-from-stderr-tty.sh
# Requirements: built `malt` at $MALT_BIN or zig-out/bin/malt, sqlite3 on PATH.

set -euo pipefail

ROOT=$(cd "$(dirname "$0")/../.." && pwd)
BIN="${MALT_BIN:-$ROOT/zig-out/bin/malt}"
[[ -x "$BIN" ]] || {
echo "build malt first: zig build" >&2
exit 2
}
command -v sqlite3 >/dev/null 2>&1 || {
echo "this regression needs sqlite3 on PATH" >&2
exit 2
}

PREFIX="$(mktemp -d)"
OUTDIR="$PREFIX/out"
mkdir -p "$PREFIX/db" "$OUTDIR"
export MALT_PREFIX="$PREFIX"
export MALT_NO_EMOJI=1
export MALT_OFFLINE=1
trap 'rm -rf "$PREFIX"' EXIT

pass() { printf ' \xe2\x9c\x93 %s\n' "$*"; }
fail() {
printf ' \xe2\x9c\x97 %s\n' "$*" >&2
exit 1
}

ESC=$'\033['

DB="$PREFIX/db/malt.db"

# Let malt bootstrap the schema, then seed one keg plus the bin symlink
# `mt which` resolves through.
"$BIN" list --quiet >/dev/null 2>&1 || true
[[ -f "$DB" ]] || fail "DB was not initialised by mt list"
sqlite3 "$DB" <<SQL
INSERT INTO kegs (name, full_name, version, revision, store_sha256, cellar_path, install_reason)
VALUES ('jq', 'jq', '1.7.1', 0, 'sha_jq', '$PREFIX/Cellar/jq/1.7.1', 'direct');
-- A dependent, so \`mt uses jq\` renders a real row instead of the empty notice.
INSERT INTO kegs (name, full_name, version, revision, store_sha256, cellar_path, install_reason)
VALUES ('gojq', 'gojq', '0.12.0', 0, 'sha_gojq', '$PREFIX/Cellar/gojq/0.12.0', 'direct');
INSERT INTO dependencies (keg_id, dep_name, dep_type)
SELECT id, 'jq', 'runtime' FROM kegs WHERE name = 'gojq';
SQL
mkdir -p "$PREFIX/Cellar/jq/1.7.1/bin" "$PREFIX/bin"
: >"$PREFIX/Cellar/jq/1.7.1/bin/jq"
ln -s "$PREFIX/Cellar/jq/1.7.1/bin/jq" "$PREFIX/bin/jq"

# Every subcommand here builds a stdout writer and styles what it writes.
CMDS=(
"info jq"
"which jq"
"list"
"search --installed jq"
"uses jq"
)

slug() { printf '%s' "$1" | tr -c 'a-z0-9' '_'; }

# script(1) hands the child a fresh pty and records it; the inner `>` peels
# stdout off that pty, leaving stderr on it. shellcheck cannot see the inner
# expansion.
# shellcheck disable=SC2016
run_under_pty() {
local cmd="$1" out="$2" tty_log="$3"
MT_BIN="$BIN" MT_CMD="$cmd" MT_OUT="$out" \
script -q "$tty_log" sh -c '$MT_BIN $MT_CMD >"$MT_OUT"' </dev/null >/dev/null 2>&1 || true
}

for cmd in "${CMDS[@]}"; do
out="$OUTDIR/$(slug "$cmd").txt"
run_under_pty "$cmd" "$out" /dev/null
[[ -s "$out" ]] || fail "mt $cmd wrote nothing to the redirected stdout"
grep -qF "$ESC" "$out" &&
fail "mt $cmd leaked ANSI into a redirected stdout (stderr was the terminal)"
pass "mt $cmd: redirected stdout stayed plain"
done

# The other half of the divergence, and the reason the checks above are not
# vacuous: in the same run, stderr is still a terminal and must KEEP its
# colour. Without this, silencing colour on both streams would pass.
run_under_pty "which definitely-not-installed" "$OUTDIR/miss.txt" "$OUTDIR/miss.tty"
grep -qF "$ESC" "$OUTDIR/miss.tty" ||
fail "stderr lost its colour while stdout was redirected; both streams went plain"
pass "stderr kept its colour in the same run"

# The forced policy is stream-independent: no terminal anywhere, colour anyway.
for cmd in "${CMDS[@]}"; do
out="$OUTDIR/forced_$(slug "$cmd").txt"
# shellcheck disable=SC2086 # $cmd carries its own arguments; splitting is the point
CLICOLOR_FORCE=1 "$BIN" $cmd >"$out" 2>/dev/null || true
grep -qF "$ESC" "$out" ||
fail "CLICOLOR_FORCE=1 did not colourise mt $cmd on a piped stdout"
pass "mt $cmd: CLICOLOR_FORCE=1 colourised the redirected stdout"
done

printf '\n\xe2\x9c\x94 stdout colour follows the stdout stream\n'
2 changes: 1 addition & 1 deletion src/cli/doctor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -586,7 +586,7 @@ pub fn emitFixHintIfNeeded(fix_requested: bool) void {
fn writeStyledDetail(text: []const u8) void {
var line_buf: [1024]u8 = undefined;
const line = std.fmt.bufPrint(&line_buf, " - {s}\n", .{text}) catch return;
if (color.isColorEnabled()) {
if (color.isColorEnabledForStderr()) {
output.writeStderrAll(color.SemanticStyle.detail.code());
output.writeStderrAll(line);
output.writeStderrAll(color.Style.reset.code());
Expand Down
2 changes: 1 addition & 1 deletion src/cli/doctor/render.zig
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ pub fn printCheck(name: []const u8, status: CheckStatus, detail: ?[]const u8) vo
var w: std.Io.Writer = .fixed(&buf);
// Truncation of an oversized row is acceptable - `buffered()` still emits what fit.
renderCheckRow(&w, status, name, detail, .{
.color = color.isColorEnabled(),
.color = color.isColorEnabledForStderr(),
.emoji = color.isEmojiEnabled(),
}) catch {};
output.writeStderrAll(w.buffered());
Expand Down
2 changes: 1 addition & 1 deletion src/cli/info.zig
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ pub fn execute(ctx: *const AppCtx, allocator: std.mem.Allocator, args: []const [
// Flush on teardown; stdout closed by a broken pipe is normal shell usage.
defer stdout.flush() catch {};

const colorize = !json_mode and color.isColorEnabled();
const colorize = !json_mode and color.isColorEnabledFor(.stdout);

// `--cask`/`--formula` are inclusive selectors, like `search`: each
// flag opts its kind in, and "both set" reads the same as "neither set"
Expand Down
4 changes: 2 additions & 2 deletions src/cli/list.zig
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ fn pinnedUnionSql(show_formula: bool, show_cask: bool, tap_filter: bool) ?[:0]co

/// Emit the leading cyan bullet + space, honouring `NO_COLOR`.
fn writeBulletPrefix(stdout: *std.Io.Writer) void {
if (color.isColorEnabled()) {
if (color.isColorEnabledFor(.stdout)) {
stdout.writeAll(color.SemanticStyle.info.code()) catch return;
stdout.writeAll(" ▸ ") catch return;
stdout.writeAll(color.Style.reset.code()) catch return;
Expand All @@ -313,7 +313,7 @@ fn writeStyledSpan(
body: []const u8,
close: []const u8,
) void {
const use_color = color.isColorEnabled();
const use_color = color.isColorEnabledFor(.stdout);
if (use_color) stdout.writeAll(style_code) catch return;
stdout.writeAll(open) catch return;
stdout.writeAll(body) catch return;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/migrate.zig
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ fn emitBrewUninstallHint(
var line: std.ArrayList(u8) = .empty;
defer line.deinit(allocator);

const use_color = color.isColorEnabled();
const use_color = color.isColorEnabledForStderr();
const prefix: []const u8 = if (color.isEmojiEnabled()) " ▸ " else " > ";
if (use_color) try line.appendSlice(allocator, color.SemanticStyle.detail.code());
try line.appendSlice(allocator, prefix);
Expand Down
6 changes: 3 additions & 3 deletions src/cli/outdated/render.zig
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ pub fn writeJsonArray(
/// Match the `mt list` / `mt search` row shape: cyan bullet, plain
/// name, dimmed `(installed)`, warn-coloured `≠ latest`, and an
/// optional dim `[kind]` tag for casks. Honours `NO_COLOR` / pipes
/// automatically via `color.isColorEnabled()`.
/// automatically via `color.isColorEnabledFor(.stdout)`.
fn writeEntry(stdout: *std.Io.Writer, e: OutdatedEntry, kind_tag: ?[]const u8) void {
if (output.isQuiet()) {
stdout.writeAll(e.name) catch return;
Expand All @@ -112,7 +112,7 @@ fn isDowngrade(installed: []const u8, latest: []const u8) bool {
}

fn writeBullet(stdout: *std.Io.Writer) void {
if (color.isColorEnabled()) {
if (color.isColorEnabledFor(.stdout)) {
stdout.writeAll(color.SemanticStyle.info.code()) catch return;
stdout.writeAll(" \xe2\x96\xb8 ") catch return;
stdout.writeAll(color.Style.reset.code()) catch return;
Expand All @@ -128,7 +128,7 @@ fn writeStyledSpan(
body: []const u8,
close: []const u8,
) void {
const use_color = color.isColorEnabled();
const use_color = color.isColorEnabledFor(.stdout);
if (use_color) stdout.writeAll(style_code) catch return;
stdout.writeAll(open) catch return;
stdout.writeAll(body) catch return;
Expand Down
8 changes: 4 additions & 4 deletions src/cli/purge/report.zig
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub const Reporter = struct {
fn writeItem(text: []const u8) void {
if (output.isQuiet()) return; // raw stderr writes don't honour --quiet on their own
const bullet: []const u8 = if (color.isEmojiEnabled()) " • " else " - ";
if (color.isColorEnabled()) {
if (color.isColorEnabledForStderr()) {
output.writeStderrAll(color.SemanticStyle.detail.code());
output.writeStderrAll(bullet);
output.writeStderrAll(color.Style.reset.code());
Expand All @@ -148,7 +148,7 @@ fn writeMore(remaining: usize) void {
" … {d} more (use --verbose to show all)",
.{remaining},
) catch return;
if (color.isColorEnabled()) {
if (color.isColorEnabledForStderr()) {
output.writeStderrAll(color.SemanticStyle.detail.code());
output.writeStderrAll(msg);
output.writeStderrAll(color.Style.reset.code());
Expand Down Expand Up @@ -190,10 +190,10 @@ fn writeRule() void {
if (output.isQuiet()) return;
var buf: [rule_width]u8 = undefined;
@memset(buf[0..], '-');
if (color.isColorEnabled()) output.writeStderrAll(color.SemanticStyle.detail.code());
if (color.isColorEnabledForStderr()) output.writeStderrAll(color.SemanticStyle.detail.code());
output.writeStderrAll(" ");
output.writeStderrAll(buf[0..rule_width]);
if (color.isColorEnabled()) output.writeStderrAll(color.Style.reset.code());
if (color.isColorEnabledForStderr()) output.writeStderrAll(color.Style.reset.code());
output.writeStderrAll("\n");
}

Expand Down
2 changes: 1 addition & 1 deletion src/cli/rollback.zig
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ fn printListing(
if (output.isJson()) {
try writeListJson(&aw.writer, name, entries);
} else {
try writeListHuman(&aw.writer, name, entries, color.isColorEnabled());
try writeListHuman(&aw.writer, name, entries, color.isColorEnabledFor(.stdout));
}
output.writeStdoutAll(aw.written());
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/search.zig
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ test "loadInstalledKind returns every installed name for the kind, sorted" {

/// Write a single search result with the same ▸ prefix style used by `list`.
fn writeResult(stdout: *std.Io.Writer, name: []const u8, kind: []const u8) void {
const use_color = color.isColorEnabled();
const use_color = color.isColorEnabledFor(.stdout);
if (use_color) stdout.writeAll(color.SemanticStyle.info.code()) catch return;
stdout.writeAll(" \xe2\x96\xb8 ") catch return;
if (use_color) stdout.writeAll(color.Style.reset.code()) catch return;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/uses.zig
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub fn writeHuman(stdout: *std.Io.Writer, target: []const u8, dependents: [][]co
stdout.writeAll(line) catch return;
return;
}
const use_color = color.isColorEnabled();
const use_color = color.isColorEnabledFor(.stdout);
for (dependents) |d| {
if (use_color) stdout.writeAll(color.SemanticStyle.info.code()) catch return;
stdout.writeAll(" \xe2\x96\xb8 ") catch return;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/which.zig
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ pub fn execute(ctx: *const AppCtx, _: std.mem.Allocator, args: []const []const u
if (output.isJson()) {
try encodeJson(stdout, res);
} else {
try encodeHuman(stdout, res, color.isColorEnabled());
try encodeHuman(stdout, res, color.isColorEnabledFor(.stdout));
}
}

Expand Down
Loading
Loading