Skip to main content

vivacity_core/
io.rs

1//! The verbosity of a run, as Composer's `ConsoleIO` applies it: every line
2//! vivacity writes has the level of its Composer counterpart, and is shown
3//! when the run's verbosity reaches it.
4//!
5//! - `QUIET` lines are always shown (`IOInterface::QUIET`: the unsolvable
6//!   headline, the stale-lock warning, an exception's message, and vivacity's
7//!   own reasons for stopping with exit 3);
8//! - `NORMAL` lines (`writeError` without a level, the default) disappear
9//!   under `-q`;
10//! - `VERBOSE` lines (`IOInterface::VERBOSE`) need `-v`.
11//!
12//! `-vv` and `-vvv` are never set here: those runs are handed to Composer
13//! (docs/plans/v0.21-verbosity.md), whose debug output vivacity cannot
14//! reproduce.
15//!
16//! The level is process-wide, like the `ARGV` vivacity keeps for the
17//! fallback: an embedder that runs two commands concurrently in one process
18//! shares it. A thread-local would miss lines printed from a worker thread
19//! (the lock verification of `install` runs on one). [`Scope`] sets it for one run
20//! and puts it back to `NORMAL` when dropped, panics included.
21//!
22//! Lines can be *held* ([`hold`]): what Composer writes before anything the
23//! command itself prints — while it creates the command's `Composer`
24//! instance (`RootPackageLoader::load`'s root-version warning, `Factory`'s
25//! ignored-lock warning), in `BaseCommand::initialize` (the
26//! `COMPOSER_IGNORE_PLATFORM_REQ(S)` warnings) and at the top of `execute`
27//! (`--no-suggest`'s deprecation). Every macro below writes the held lines
28//! first, in order ([`release_held`]), whatever the command's first line of
29//! its own turns out to be; a command handed to Composer before vivacity
30//! wrote anything drops them ([`drop_held`]), since the Composer it runs
31//! writes its own. The traces of `VIVACITY_TRACE` (diagnostics, not
32//! Composer's output) bypass them.
33
34use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
35use std::sync::Mutex;
36
37/// A run's verbosity, or the level of a line.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
39pub enum Verbosity {
40    Quiet = 0,
41    Normal = 1,
42    Verbose = 2,
43}
44
45static LEVEL: AtomicU8 = AtomicU8::new(Verbosity::Normal as u8);
46
47/// The current run's verbosity.
48pub fn level() -> Verbosity {
49    match LEVEL.load(Ordering::Relaxed) {
50        0 => Verbosity::Quiet,
51        2 => Verbosity::Verbose,
52        _ => Verbosity::Normal,
53    }
54}
55
56/// Whether a line of level `line` is shown in the current run.
57pub fn shows(line: Verbosity) -> bool {
58    level() >= line
59}
60
61/// Sets the verbosity for one run; back to `NORMAL` when dropped.
62#[must_use = "the verbosity is reset when the scope is dropped"]
63pub struct Scope(());
64
65impl Scope {
66    pub fn new(level: Verbosity) -> Scope {
67        LEVEL.store(level as u8, Ordering::Relaxed);
68        Scope(())
69    }
70}
71
72impl Drop for Scope {
73    fn drop(&mut self) {
74        drop_held();
75        LEVEL.store(Verbosity::Normal as u8, Ordering::Relaxed);
76    }
77}
78
79/// The held stderr lines, in the order they were held, and whether there
80/// are any (the common case — nothing held — takes no lock).
81static HELD: Mutex<Vec<String>> = Mutex::new(Vec::new());
82static HOLDING: AtomicBool = AtomicBool::new(false);
83
84/// The held lines; a panic while they were locked left nothing to repair.
85fn held() -> std::sync::MutexGuard<'static, Vec<String>> {
86    HELD.lock()
87        .unwrap_or_else(std::sync::PoisonError::into_inner)
88}
89
90/// Holds a stderr line of level `level`, after those already held, until
91/// vivacity writes its next line ([`release_held`]); nothing when the run's
92/// verbosity hides it.
93pub fn hold(level: Verbosity, line: String) {
94    if !shows(level) {
95        return;
96    }
97    held().push(line);
98    HOLDING.store(true, Ordering::Release);
99}
100
101/// Writes the held lines on stderr, once, in order. Called by every writing
102/// macro before its own line, and before vivacity starts a process whose
103/// output the user sees. The lines are written under the lock: a line
104/// another thread writes at the same moment comes after them.
105pub fn release_held() {
106    if !HOLDING.load(Ordering::Acquire) {
107        return;
108    }
109    let mut slot = held();
110    for line in slot.drain(..) {
111        eprintln!("{line}");
112    }
113    HOLDING.store(false, Ordering::Release);
114}
115
116/// Forgets the held lines: the command goes to Composer, which writes its
117/// own, or the run ends. Process-wide, like the level: an embedder running
118/// two commands at once in one process shares it.
119pub fn drop_held() {
120    if !HOLDING.load(Ordering::Acquire) {
121        return;
122    }
123    held().clear();
124    HOLDING.store(false, Ordering::Release);
125}
126
127/// `GithubActionError::emit` (src/Composer/Console/GithubActionError.php):
128/// under GitHub Actions (`GITHUB_ACTIONS` true as PHP reads an environment
129/// string, `COMPOSER_TESTS_ARE_RUNNING` not), an error annotation on STDOUT
130/// at the default level — `::error ::` and the message with `%`, `\r` and
131/// `\n` escaped (`escapeData`). Composer emits one for an unsolvable set
132/// (`Installer::doUpdate`, `extractDevPackages`, `doInstall`) and for the
133/// message of any exception (`Application::doRun`).
134pub fn github_action_error(message: &str) {
135    let truthy = |name: &str| std::env::var(name).is_ok_and(|v| !v.is_empty() && v != "0");
136    if !truthy("GITHUB_ACTIONS") || truthy("COMPOSER_TESTS_ARE_RUNNING") {
137        return;
138    }
139    crate::out!("::error ::{}", escape_data(message));
140}
141
142/// `GithubActionError::escapeData`: `%` first, so that the escapes it
143/// writes are not escaped again.
144fn escape_data(data: &str) -> String {
145    data.replace('%', "%25")
146        .replace('\r', "%0D")
147        .replace('\n', "%0A")
148}
149
150/// `Application::configureIO`'s verbosity, from the arguments (the command
151/// line after the program name) and `SHELL_VERBOSITY`, as Symfony computes
152/// it: the variable first (PHP's `(int)` cast, then exactly `-1` quiet, `1`
153/// verbose, `2` very verbose, `3` debug — anything else is normal), then the
154/// options, each of which *replaces* it: `-q`/`--quiet` wins over any `-v`;
155/// else `-vvv`/`--verbose=3`, `-vv`/`--verbose=2`, `-v`/`--verbose[=…]`.
156/// Tokens match as `ArgvInput::hasParameterOption` matches them, up to `--`:
157/// a short option by prefix (`-vvvv` is `-vvv`, `-v -v` is `-v`), a long one
158/// exactly or followed by `=`.
159/// Returns 0 quiet, 1 normal, 2 verbose, 3 very verbose, 4 debug.
160pub fn requested_verbosity<S: AsRef<str>>(args: &[S], shell_verbosity: Option<&str>) -> u8 {
161    let has = |value: &str| {
162        let leading = if value.starts_with("--") {
163            format!("{value}=")
164        } else {
165            value.to_owned()
166        };
167        args.iter()
168            .map(AsRef::as_ref)
169            .take_while(|t| *t != "--")
170            .any(|t| t == value || t.starts_with(&leading))
171    };
172    let base = match shell_verbosity.map(php_int).unwrap_or(0) {
173        -1 => 0,
174        1 => 2,
175        2 => 3,
176        3 => 4,
177        _ => 1,
178    };
179    if has("--quiet") || has("-q") {
180        0
181    } else if has("-vvv") || has("--verbose=3") {
182        4
183    } else if has("-vv") || has("--verbose=2") {
184        3
185    } else if has("-v") || has("--verbose=1") || has("--verbose") {
186        2
187    } else {
188        base
189    }
190}
191
192/// PHP's `(int)` of a string: leading whitespace, then the longest numeric
193/// prefix — an integer, or a float with a fraction or an exponent, truncated
194/// toward zero (`"1e1"` is 10, `"1.9"` is 1, `"2e0x"` is 2); 0 when there is
195/// none.
196fn php_int(s: &str) -> i64 {
197    let t = s.trim_start_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']);
198    let b = t.as_bytes();
199    let mut i = 0;
200    if matches!(b.first(), Some(b'+' | b'-')) {
201        i += 1;
202    }
203    let digits = |from: usize| b[from..].iter().take_while(|c| c.is_ascii_digit()).count();
204    let int_len = digits(i);
205    i += int_len;
206    let mut float = false;
207    if b.get(i) == Some(&b'.') {
208        let frac = digits(i + 1);
209        if int_len + frac > 0 {
210            float = true;
211            i += 1 + frac;
212        }
213    }
214    if int_len == 0 && !float {
215        return 0;
216    }
217    if matches!(b.get(i), Some(b'e' | b'E')) {
218        let mut j = i + 1;
219        if matches!(b.get(j), Some(b'+' | b'-')) {
220            j += 1;
221        }
222        let exp = digits(j);
223        if exp > 0 {
224            float = true;
225            i = j + exp;
226        }
227    }
228    let text = &t[..i];
229    if float {
230        text.parse::<f64>().map(|f| f.trunc() as i64).unwrap_or(0)
231    } else {
232        text.parse::<i64>().unwrap_or(0)
233    }
234}
235
236/// A `NORMAL` line on stderr (`$io->writeError($line)`).
237#[macro_export]
238macro_rules! say {
239    ($($arg:tt)*) => {
240        $crate::say_at!($crate::io::Verbosity::Normal, $($arg)*)
241    };
242}
243
244/// A `QUIET` line on stderr: shown even under `-q`.
245#[macro_export]
246macro_rules! say_always {
247    ($($arg:tt)*) => {
248        $crate::say_at!($crate::io::Verbosity::Quiet, $($arg)*)
249    };
250}
251
252/// A `VERBOSE` line on stderr: shown with `-v`.
253#[macro_export]
254macro_rules! say_verbose {
255    ($($arg:tt)*) => {
256        $crate::say_at!($crate::io::Verbosity::Verbose, $($arg)*)
257    };
258}
259
260/// A line on stderr at a level known at run time (a held event, a problem's
261/// part), after the held lines.
262#[macro_export]
263macro_rules! say_at {
264    ($level:expr, $($arg:tt)*) => {
265        if $crate::io::shows($level) {
266            $crate::io::release_held();
267            eprintln!($($arg)*);
268        }
269    };
270}
271
272/// A `NORMAL` line on stdout (`$io->write($line)`), after the held lines.
273#[macro_export]
274macro_rules! out {
275    ($($arg:tt)*) => {
276        if $crate::io::shows($crate::io::Verbosity::Normal) {
277            $crate::io::release_held();
278            println!($($arg)*);
279        }
280    };
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn github_annotation_escapes_like_escape_data() {
289        assert_eq!(
290            escape_data("100% sure\r\n  Problem 1\n"),
291            "100%25 sure%0D%0A  Problem 1%0A"
292        );
293        assert_eq!(escape_data("%0A"), "%250A");
294    }
295
296    // One test: the level is process-wide and cargo runs tests in threads.
297    #[test]
298    fn levels_and_symfony_parsing() {
299        assert_eq!(level(), Verbosity::Normal);
300        {
301            let _s = Scope::new(Verbosity::Quiet);
302            assert!(!shows(Verbosity::Normal));
303            assert!(shows(Verbosity::Quiet));
304        }
305        assert_eq!(level(), Verbosity::Normal);
306        {
307            let _s = Scope::new(Verbosity::Verbose);
308            assert!(shows(Verbosity::Verbose));
309        }
310        assert_eq!(level(), Verbosity::Normal);
311        let r = std::panic::catch_unwind(|| {
312            let _s = Scope::new(Verbosity::Quiet);
313            panic!("boom");
314        });
315        assert!(r.is_err());
316        assert_eq!(level(), Verbosity::Normal);
317
318        let v = |a: &[&str], e: Option<&str>| requested_verbosity(a, e);
319        assert_eq!(v(&["install"], None), 1);
320        assert_eq!(v(&["install", "-q"], None), 0);
321        assert_eq!(v(&["install", "--quiet"], None), 0);
322        assert_eq!(v(&["install", "-v"], None), 2);
323        assert_eq!(v(&["install", "--verbose"], None), 2);
324        assert_eq!(v(&["install", "--verbose=0"], None), 2);
325        assert_eq!(v(&["install", "-v", "-v"], None), 2);
326        assert_eq!(v(&["install", "-vv"], None), 3);
327        assert_eq!(v(&["install", "-vvv"], None), 4);
328        assert_eq!(v(&["install", "-vvvv"], None), 4);
329        assert_eq!(v(&["install", "--verbose=3"], None), 4);
330        assert_eq!(v(&["install", "-q", "-vvv"], None), 0);
331        assert_eq!(v(&["install", "--quietly"], None), 1);
332        // SHELL_VERBOSITY, then the options replace it.
333        assert_eq!(v(&["install"], Some("-1")), 0);
334        assert_eq!(v(&["install"], Some("1")), 2);
335        assert_eq!(v(&["install"], Some(" 1abc")), 2);
336        // PHP's cast reads a numeric prefix, exponent included.
337        assert_eq!(php_int("1e1"), 10);
338        assert_eq!(php_int("1.9"), 1);
339        assert_eq!(php_int("-1.5"), -1);
340        assert_eq!(php_int(" 2e0x"), 2);
341        assert_eq!(php_int(".5"), 0);
342        assert_eq!(php_int("1e"), 1);
343        assert_eq!(php_int("x"), 0);
344        assert_eq!(v(&["install"], Some("1e1")), 1);
345        assert_eq!(v(&["install"], Some("-2")), 1);
346        assert_eq!(v(&["install"], Some("x")), 1);
347        assert_eq!(v(&["install", "-v"], Some("3")), 2);
348        assert_eq!(v(&["install"], Some("3")), 4);
349        assert_eq!(v(&["require", "--", "-q"], None), 1);
350    }
351}