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
11 changes: 9 additions & 2 deletions crates/wright-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod update;

use std::io::Write;
use std::process::ExitCode;
use std::sync::Arc;

use clap::{CommandFactory, Parser};
use wright_driver::config::{InputSpec, OutputFormat, SessionConfig, SourceKind};
Expand Down Expand Up @@ -199,8 +200,11 @@ fn run_workflow(command: Command) -> ExitCode {
ConvertTargetArg::Opy => wright_driver::ConvertTarget::Opy,
ConvertTargetArg::Ostw => wright_driver::ConvertTarget::Ostw,
};
let _activity = presentation.activity();
let activity = Arc::new(presentation.activity());
session.set_progress_observer(activity.clone());
let envelope = session.convert(target);
session.clear_progress_observer();
drop(activity);
let code = envelope.exit;
present::render(&envelope, presentation);
code
Expand Down Expand Up @@ -245,8 +249,11 @@ fn run_command<T: serde::Serialize>(
run: fn(&mut wright_driver::CompilerSession) -> wright_driver::Envelope<T>,
presentation: present::Presentation,
) -> u8 {
let _activity = presentation.activity();
let activity = Arc::new(presentation.activity());
session.set_progress_observer(activity.clone());
let envelope = run(session);
session.clear_progress_observer();
drop(activity);
let code = envelope.exit;
present::render(&envelope, presentation);
code
Expand Down
106 changes: 96 additions & 10 deletions crates/wright-cli/src/present.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@
//! Actions. JSON and source artifacts bypass every human/CI renderer.

use std::io::{IsTerminal, Write};
#[cfg(test)]
use std::sync::atomic::AtomicUsize;
use std::sync::{
Arc,
Arc, Mutex,
atomic::{AtomicBool, Ordering},
};
use std::thread;
use std::time::Duration;

use wright_driver::Severity;
use wright_driver::config::OutputFormat;
use wright_driver::progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit};
use wright_driver::result::Envelope;

use crate::cli::{ColorArg, CommonArgs, OutputFormatArg, RendererArg};
Expand All @@ -32,6 +35,11 @@ pub(crate) struct Presentation {
pub(crate) struct Activity {
done: Arc<AtomicBool>,
visible: Arc<AtomicBool>,
status: Arc<Mutex<Option<ProgressEvent>>>,
output: Arc<Mutex<()>>,
#[cfg(test)]
#[allow(dead_code)]
frame: Arc<AtomicUsize>,
handle: Option<thread::JoinHandle<()>>,
}

Expand All @@ -40,31 +48,95 @@ impl Activity {
Self {
done: Arc::new(AtomicBool::new(true)),
visible: Arc::new(AtomicBool::new(false)),
status: Arc::new(Mutex::new(None)),
output: Arc::new(Mutex::new(())),
#[cfg(test)]
frame: Arc::new(AtomicUsize::new(0)),
handle: None,
}
}

fn start() -> Self {
let done = Arc::new(AtomicBool::new(false));
let visible = Arc::new(AtomicBool::new(false));
let status = Arc::new(Mutex::new(None));
let output = Arc::new(Mutex::new(()));
#[cfg(test)]
let frame = Arc::new(AtomicUsize::new(0));
write_activity_line(&output, None, None);
visible.store(true, Ordering::Release);
let thread_done = Arc::clone(&done);
let thread_visible = Arc::clone(&visible);
let thread_status = Arc::clone(&status);
let thread_output = Arc::clone(&output);
#[cfg(test)]
let thread_frame = Arc::clone(&frame);
let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(150));
if !thread_done.load(Ordering::Acquire) {
eprint!("wright: working…");
let _ = std::io::stderr().flush();
thread_visible.store(true, Ordering::Release);
thread::sleep(Duration::from_millis(60));
let mut frame = 0;
while !thread_done.load(Ordering::Acquire) {
let event = *thread_status.lock().expect("activity status lock");
write_activity_line(&thread_output, event, Some(SPINNER[frame]));
frame = (frame + 1) % SPINNER.len();
#[cfg(test)]
thread_frame.store(frame, Ordering::Release);
thread::sleep(Duration::from_millis(80));
}
});
Self {
done,
visible,
status,
output,
#[cfg(test)]
frame,
handle: Some(handle),
}
}
}

const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];

impl ProgressObserver for Activity {
fn on_progress(&self, event: ProgressEvent) {
if self.done.load(Ordering::Acquire) {
return;
}
*self.status.lock().expect("activity status lock") = Some(event);
write_activity_line(&self.output, Some(event), None);
}
}

fn write_activity_line(output: &Mutex<()>, event: Option<ProgressEvent>, spinner: Option<char>) {
let _guard = output.lock().expect("activity output lock");
let label = event
.map(progress_label)
.unwrap_or("Starting workflow".to_string());
match spinner {
Some(spinner) => eprint!("\r\x1b[2K\r {spinner} {label}…"),
None => eprint!("\r\x1b[2K\r {label}…"),
}
let _ = std::io::stderr().flush();
}

fn progress_label(event: ProgressEvent) -> String {
let label = match event.phase {
ProgressPhase::InputResolution => "Resolving input".to_string(),
ProgressPhase::ProjectLoading => "Loading project".to_string(),
ProgressPhase::Parsing => "Parsing".to_string(),
ProgressPhase::Validation => "Validating".to_string(),
ProgressPhase::Lowering => "Lowering".to_string(),
ProgressPhase::SemanticAnalysis => "Resolving semantics".to_string(),
ProgressPhase::Linting => "Running lint rules".to_string(),
ProgressPhase::Emission => "Emitting Workshop".to_string(),
ProgressPhase::Conversion => "Reconstructing source".to_string(),
};
match (event.count, event.unit) {
(Some(count), Some(ProgressUnit::Files)) => format!("{label} {count} files"),
(Some(count), Some(ProgressUnit::Rules)) => format!("{label} {count} rules"),
_ => label,
}
}

impl Drop for Activity {
fn drop(&mut self) {
self.done.store(true, Ordering::Release);
Expand Down Expand Up @@ -988,17 +1060,31 @@ mod tests {
}

#[test]
fn activity_becomes_visible_only_after_delay() {
fn activity_is_visible_immediately_and_accepts_phase_updates() {
let terminal = Presentation::resolve(
OutputFormat::Text,
RendererArg::Terminal,
ColorArg::Never,
environment(),
);
let activity = terminal.activity();
assert!(!activity.visible.load(Ordering::Acquire));
thread::sleep(Duration::from_millis(180));
assert!(activity.visible.load(Ordering::Acquire));
assert_eq!(*activity.status.lock().unwrap(), None);
activity.on_progress(ProgressEvent::with_count(
ProgressPhase::Linting,
12,
ProgressUnit::Rules,
));
assert_eq!(
*activity.status.lock().unwrap(),
Some(ProgressEvent::with_count(
ProgressPhase::Linting,
12,
ProgressUnit::Rules,
))
);
thread::sleep(Duration::from_millis(150));
assert!(activity.frame.load(Ordering::Acquire) > 0);
}

#[test]
Expand Down
120 changes: 120 additions & 0 deletions crates/wright-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ fn run_with_env(args: &[&str], variables: &[(&str, &str)]) -> std::process::Outp
command.output().expect("wright runs")
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
fn run_in_tty(args: &[&str]) -> std::process::Output {
#[cfg(target_os = "linux")]
let command = std::iter::once(wright())
.chain(args.iter().copied())
.map(|arg| {
if arg
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || "-_/.:".contains(ch))
{
arg.to_string()
} else {
format!("'{}'", arg.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ");
let mut script = Command::new("script");
#[cfg(target_os = "linux")]
script.args(["-qefc", &command, "/dev/null"]);
#[cfg(target_os = "macos")]
script.args(["-q", "/dev/null", wright()]).args(args);
script
.env("TERM", "xterm")
.output()
.expect("script is available")
}

fn run_with_stdin(args: &[&str], stdin: &str) -> std::process::Output {
let mut child = Command::new(wright())
.args(args)
Expand Down Expand Up @@ -583,6 +611,98 @@ fn stdout_stderr_separation_holds_in_both_modes() {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn tty_progress_stops_and_clears_before_final_render() {
let path =
workspace_root().join("compatibility/fixtures/real-world/overpy-pixelart/pixelart.opy");
let output = run_in_tty(&[
"analyze",
path.to_str().unwrap(),
"--kind",
"opy",
"--renderer",
"terminal",
"--color",
"never",
]);
assert!(output.status.success());
let mut transcript = output.stdout;
transcript.extend_from_slice(&output.stderr);
let transcript = String::from_utf8_lossy(&transcript);
let final_render = transcript
.find("PASS analyze")
.expect("final analyze render is present");
let before_final = &transcript[..final_render];
assert!(before_final.contains("Starting workflow"));
assert!(before_final.contains("Resolving input"));
assert!(before_final.contains("Parsing"));
assert!(before_final.contains("Resolving semantics"));
let cleared = before_final
.rfind("\x1b[2K")
.expect("activity line is cleared before final render");
assert!(cleared < final_render);
assert!(!transcript.contains("working…PASS"));

let lint = run_in_tty(&[
"lint",
path.to_str().unwrap(),
"--kind",
"opy",
"--renderer",
"terminal",
"--color",
"never",
]);
assert!(lint.status.success());
let mut lint_transcript = lint.stdout;
lint_transcript.extend_from_slice(&lint.stderr);
let lint_transcript = String::from_utf8_lossy(&lint_transcript);
assert!(lint_transcript.contains("Running lint rules"));
assert!(lint_transcript.contains(" rules…"));
}

#[test]
fn non_interactive_renderers_have_no_progress_artifacts() {
let source = std::fs::read_to_string(
workspace_root().join("compatibility/fixtures/synthetic/control-flow/source.opy"),
)
.unwrap();
let path = temp_file("basic.opy", &source);
for renderer in ["plain", "github-actions"] {
let output = run_with_env(
&[
"analyze",
path.to_str().unwrap(),
"--renderer",
renderer,
"--color",
"always",
],
&[("GITHUB_ACTIONS", "true")],
);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!combined.contains("Resolving input"),
"{renderer}: {combined}"
);
assert!(
!combined.contains("Running lint rules"),
"{renderer}: {combined}"
);
assert!(!combined.contains("⠋"), "{renderer}: {combined}");
}
let json = run(&["analyze", path.to_str().unwrap(), "--format", "json"]);
assert!(json.status.success());
assert!(json.stderr.is_empty());
assert!(!String::from_utf8_lossy(&json.stdout).contains("Resolving input"));
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

#[test]
fn explicit_locale_override_is_accepted() {
let path = temp_file("basic.txt", &corpus_workshop("synthetic/basic-rule"));
Expand Down
2 changes: 2 additions & 0 deletions crates/wright-driver/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod diag;
pub mod edit;
pub mod input;
pub mod opy;
pub mod progress;
pub mod provider_edit;
pub mod result;
pub mod service;
Expand All @@ -26,6 +27,7 @@ pub mod workshop_provider;
pub use config::{InputSpec, LintConfig, OutputFormat, SessionConfig, SourceKind};
pub use diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage};
pub use input::{ResolvedInput, sha256_hex};
pub use progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit};
pub use result::{
AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget,
Envelope, InspectResult, LintResult, RESULT_CONTRACT,
Expand Down
Loading