Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ future_not_send = "allow"

[workspace.dependencies]
allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc", "std"] }
anstream = "0.6.21"
anyhow = "1.0.98"
assert2 = "0.4.0"
assertables = "9.8.1"
Expand Down
47 changes: 47 additions & 0 deletions crates/pty_terminal/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,53 @@ impl PtyReader {
self.parser.lock().unwrap().screen().contents()
}

/// Returns the screen contents row-by-row with inline ANSI SGR escapes
/// preserved — useful for snapshot tests that need to assert colour/style.
///
/// Rows are produced via [`vt100::Screen::rows_formatted`], which emits
/// only the SGR attribute escapes (no cursor positioning, no
/// screen-erase sequences), so the output is platform-stable. Trailing
/// fully-empty rows are dropped; remaining rows are joined with `\n`.
///
/// Bare SGR-reset sequences (`\x1b[m`) are also stripped: Unix PTYs emit
/// them between styled spans and at the end of styled runs, but Windows
/// `ConPTY` consolidates the byte stream and elides those resets. Stripping
/// them produces identical output on all platforms while preserving the
/// non-reset SGR transitions that the test actually cares about.
///
/// # Panics
///
/// Panics if the parser lock is poisoned.
#[expect(
clippy::significant_drop_tightening,
reason = "vt100::Screen::rows_formatted yields borrowed iterators that need the guard alive"
)]
#[must_use]
pub fn screen_contents_formatted(&self) -> Vec<u8> {
const RESET: &[u8] = b"\x1b[m";
let guard = self.parser.lock().unwrap();
let screen = guard.screen();
let cols = screen.size().1;
let rows: Vec<Vec<u8>> = screen
.rows_formatted(0, cols)
.map(|mut row| {
while let Some(idx) = row.windows(RESET.len()).position(|w| w == RESET) {
row.drain(idx..idx + RESET.len());
}
row
})
.collect();
let last_non_empty = rows.iter().rposition(|r| !r.is_empty()).map_or(0, |i| i + 1);
let mut out = Vec::new();
for (i, row) in rows[..last_non_empty].iter().enumerate() {
if i > 0 {
out.push(b'\n');
}
out.extend_from_slice(row);
}
out
}

/// Drains and returns all unhandled OSC sequences received since the last call.
///
/// Each entry is a list of byte-vector parameters from a single OSC sequence
Expand Down
7 changes: 7 additions & 0 deletions crates/pty_terminal_test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ impl Reader {
contents
}

/// Returns the screen contents with inline ANSI SGR escape codes preserved.
/// Useful for snapshot tests that need to assert colour or style attributes.
#[must_use]
pub fn screen_contents_formatted(&self) -> Vec<u8> {
self.pty.get_ref().screen_contents_formatted()
}

/// Reads from the PTY until a milestone with the given name is encountered.
///
/// Returns the terminal screen contents at the moment the milestone is detected.
Expand Down
2 changes: 2 additions & 0 deletions crates/vite_task/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ rust-version.workspace = true
workspace = true

[dependencies]
anstream = { workspace = true }
anyhow = { workspace = true }
async-trait = { workspace = true }
wincode = { workspace = true, features = ["derive"] }
Expand All @@ -28,6 +29,7 @@ rusqlite = { workspace = true, features = ["bundled"] }
rustc-hash = { workspace = true }
serde = { workspace = true, features = ["derive", "rc"] }
serde_json = { workspace = true }
supports-color = { workspace = true }
thiserror = { workspace = true }
tar = { workspace = true }
tokio = { workspace = true, features = [
Expand Down
72 changes: 55 additions & 17 deletions crates/vite_task/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use clap::Parser as _;
use once_cell::sync::OnceCell;
pub use reporter::ExitStatus;
use reporter::{
GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
ColorSupport, GroupedReporterBuilder, InterleavedReporterBuilder, LabeledReporterBuilder,
SummaryReporterBuilder,
summary::{LastRunSummary, ReadSummaryError, format_full_summary},
};
Expand Down Expand Up @@ -313,20 +313,36 @@ impl<'a> Session<'a> {
let workspace_path = self.workspace_path();
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());

let inner: Box<dyn reporter::GraphExecutionReporterBuilder> =
match run_command.flags.log {
crate::cli::LogMode::Interleaved => Box::new(
InterleavedReporterBuilder::new(Arc::clone(&workspace_path), writer),
),
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
)),
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
)),
};
// Detect color support once at the point where reporters are
// constructed. The reporters and their pipe writers then strip
// ANSI escapes from cached/replayed output if the terminal
// can't render them. Detect per-stream so a redirected stdout
// doesn't trigger stripping of an interactive stderr.
let color_support = ColorSupport {
stdout: stdout_supports_color(),
stderr: stderr_supports_color(),
};

let inner: Box<dyn reporter::GraphExecutionReporterBuilder> = match run_command
.flags
.log
{
crate::cli::LogMode::Interleaved => Box::new(InterleavedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Labeled => Box::new(LabeledReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
crate::cli::LogMode::Grouped => Box::new(GroupedReporterBuilder::new(
Arc::clone(&workspace_path),
writer,
color_support,
)),
};

let builder = Box::new(SummaryReporterBuilder::new(
inner,
Expand All @@ -335,6 +351,7 @@ impl<'a> Session<'a> {
run_command.flags.verbose,
Some(self.make_summary_writer()),
self.program_name.clone(),
color_support,
));
// Don't let SIGINT/CTRL_C kill the runner. Child tasks receive
// the signal directly from the terminal driver and handle it
Expand Down Expand Up @@ -668,8 +685,11 @@ impl<'a> Session<'a> {
let cache = self.cache()?;

// Create a plain (standalone) reporter — no graph awareness, no summary
let plain_reporter =
reporter::PlainReporter::new(silent_if_cache_hit, Box::new(std::io::stdout()));
let plain_reporter = reporter::PlainReporter::new(
silent_if_cache_hit,
Box::new(std::io::stdout()),
ColorSupport { stdout: stdout_supports_color(), stderr: stderr_supports_color() },
);

// Execute the spawn directly using the free function, bypassing the graph pipeline
let outcome = execute::execute_spawn(
Expand Down Expand Up @@ -770,3 +790,21 @@ impl<'a> Session<'a> {
.await
}
}

/// Whether stdout supports ANSI color output for the current process. Honors
/// `NO_COLOR`/`FORCE_COLOR` and detects TTY capability via the `supports-color`
/// crate. Result is cached for the process lifetime.
fn stdout_supports_color() -> bool {
use std::sync::OnceLock;
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stdout).is_some())
Comment thread
wan9chi marked this conversation as resolved.
}

/// Whether stderr supports ANSI color output. Detected independently from
/// stdout so a redirected stdout (non-TTY) does not strip ANSI from a stderr
/// that is still an interactive terminal.
fn stderr_supports_color() -> bool {
use std::sync::OnceLock;
static CACHE: OnceLock<bool> = OnceLock::new();
*CACHE.get_or_init(|| supports_color::on(supports_color::Stream::Stderr).is_some())
}
24 changes: 18 additions & 6 deletions crates/vite_task/src/session/reporter/grouped/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

use std::{cell::RefCell, io::Write, process::ExitStatus as StdExitStatus, rc::Rc, sync::Arc};

use owo_colors::Style;
use owo_colors::{OwoColorize as _, Style};
use vite_path::AbsolutePath;
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};

use super::{
ColorizeExt, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion,
format_command_with_cache_status, format_task_label, write_leaf_trailing_output,
format_command_with_cache_status, format_task_label, maybe_strip_writer,
write_leaf_trailing_output,
};
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};

Expand All @@ -23,8 +24,15 @@ pub struct GroupedReporterBuilder {
}

impl GroupedReporterBuilder {
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
Self { workspace_path, writer }
/// `color_support.stderr` is unused: grouped mode collapses every child
/// stream into a single buffer that is later flushed through the main
/// writer (assumed to be stdout), so the stdout flag drives stripping.
pub fn new(
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn Write>,
color_support: ColorSupport,
) -> Self {
Self { workspace_path, writer: maybe_strip_writer(writer, color_support.stdout) }
}
}

Expand Down Expand Up @@ -152,7 +160,11 @@ mod tests {
let task = spawn_task("build");
let item = &task.items[0];

let builder = Box::new(GroupedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
let builder = Box::new(GroupedReporterBuilder::new(
test_path(),
Box::new(std::io::sink()),
ColorSupport::uniform(false),
));
let mut reporter = builder.build();
let mut leaf = reporter.new_leaf_execution(&item.execution_item_display, leaf_kind(item));
let stdio_config = leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata));
Expand Down
40 changes: 31 additions & 9 deletions crates/vite_task/src/session/reporter/interleaved/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,29 @@ use vite_path::AbsolutePath;
use vite_task_plan::{ExecutionItemDisplay, LeafExecutionKind};

use super::{
ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder, LeafExecutionReporter,
PipeWriters, StdioConfig, StdioSuggestion, format_command_with_cache_status,
write_leaf_trailing_output,
ColorSupport, ExitStatus, GraphExecutionReporter, GraphExecutionReporterBuilder,
LeafExecutionReporter, PipeWriters, StdioConfig, StdioSuggestion,
format_command_with_cache_status, maybe_strip_writer, write_leaf_trailing_output,
};
use crate::session::event::{CacheStatus, CacheUpdateStatus, ExecutionError};

pub struct InterleavedReporterBuilder {
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn Write>,
color_support: ColorSupport,
}

impl InterleavedReporterBuilder {
pub fn new(workspace_path: Arc<AbsolutePath>, writer: Box<dyn Write>) -> Self {
Self { workspace_path, writer }
pub fn new(
workspace_path: Arc<AbsolutePath>,
writer: Box<dyn Write>,
color_support: ColorSupport,
) -> Self {
Self {
workspace_path,
writer: maybe_strip_writer(writer, color_support.stdout),
color_support,
}
}
}

Expand All @@ -28,13 +37,15 @@ impl GraphExecutionReporterBuilder for InterleavedReporterBuilder {
Box::new(InterleavedGraphReporter {
writer: Rc::new(RefCell::new(self.writer)),
workspace_path: self.workspace_path,
color_support: self.color_support,
})
}
}

struct InterleavedGraphReporter {
writer: Rc<RefCell<Box<dyn Write>>>,
workspace_path: Arc<AbsolutePath>,
color_support: ColorSupport,
}

impl GraphExecutionReporter for InterleavedGraphReporter {
Expand All @@ -54,6 +65,7 @@ impl GraphExecutionReporter for InterleavedGraphReporter {
workspace_path: Arc::clone(&self.workspace_path),
stdio_suggestion,
started: false,
color_support: self.color_support,
})
}

Expand All @@ -70,6 +82,7 @@ struct InterleavedLeafReporter {
workspace_path: Arc<AbsolutePath>,
stdio_suggestion: StdioSuggestion,
started: bool,
color_support: ColorSupport,
}

impl LeafExecutionReporter for InterleavedLeafReporter {
Expand All @@ -86,8 +99,14 @@ impl LeafExecutionReporter for InterleavedLeafReporter {
StdioConfig {
suggestion: self.stdio_suggestion,
writers: PipeWriters {
stdout_writer: Box::new(std::io::stdout()),
stderr_writer: Box::new(std::io::stderr()),
stdout_writer: maybe_strip_writer(
Box::new(std::io::stdout()),
self.color_support.stdout,
),
stderr_writer: maybe_strip_writer(
Box::new(std::io::stderr()),
self.color_support.stderr,
),
},
}
}
Expand Down Expand Up @@ -126,8 +145,11 @@ mod tests {
display: &ExecutionItemDisplay,
leaf_kind: &LeafExecutionKind,
) -> StdioSuggestion {
let builder =
Box::new(InterleavedReporterBuilder::new(test_path(), Box::new(std::io::sink())));
let builder = Box::new(InterleavedReporterBuilder::new(
test_path(),
Box::new(std::io::sink()),
ColorSupport::uniform(false),
));
let mut reporter = builder.build();
let mut leaf = reporter.new_leaf_execution(display, leaf_kind);
leaf.start(CacheStatus::Disabled(CacheDisabledReason::NoCacheMetadata)).suggestion
Expand Down
Loading
Loading