Skip to main content

nodejs/stdlib/
console.rs

1//! Node `console` module (`require('console')`), sharing the exact rendering the
2//! global `console.*` uses: every argument is run through `JsHost::console_format`
3//! (strings verbatim, everything else via `util.inspect`) and space-joined — the
4//! same pipeline `builtins::print_line` drives — so module output is identical to
5//! the global. `log`/`info`/`debug` go to stdout; `error`/`warn`/`trace`/`assert`
6//! to stderr. `count`, `group` and `time` keep per-thread state here (a monotonic
7//! `Instant` backs the timers, so `timeEnd` reports a real elapsed duration).
8
9use crate::host::{with_host, JsObj};
10use fusevm::Value;
11use indexmap::IndexMap;
12use std::cell::{Cell, RefCell};
13use std::collections::HashMap;
14use std::io::IsTerminal;
15use std::time::Instant;
16
17pub const METHODS: &[&str] = &[
18    "log",
19    "info",
20    "debug",
21    "error",
22    "warn",
23    "dir",
24    "dirxml",
25    "trace",
26    "assert",
27    "count",
28    "countReset",
29    "group",
30    "groupCollapsed",
31    "groupEnd",
32    "time",
33    "timeEnd",
34    "timeLog",
35    "table",
36    "clear",
37    "timeStamp",
38    "profile",
39    "profileEnd",
40];
41
42/// The instance method names for a `console.Console` object (`@@native = "Console"`),
43/// wired by the parent `mod.rs`. Identical to the free-function surface.
44pub const CONSOLE_METHODS: &[&str] = METHODS;
45
46thread_local! {
47    /// Current `console.group` nesting depth (2 spaces per level).
48    static GROUP_DEPTH: Cell<usize> = const { Cell::new(0) };
49    /// `console.count` label → invocation tally.
50    static COUNTS: RefCell<HashMap<String, u64>> = RefCell::new(HashMap::new());
51    /// `console.time` label → start instant.
52    static TIMERS: RefCell<HashMap<String, Instant>> = RefCell::new(HashMap::new());
53    /// Active output sink `(stdout, stderr)` for a `Console` instance call. When
54    /// set, `emit` writes formatted lines to these streams instead of the process
55    /// std streams, so a `new Console({stdout, stderr})` honors custom writables.
56    static SINK: RefCell<Option<(Value, Value)>> = const { RefCell::new(None) };
57}
58
59pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
60    Some(match method {
61        "log" | "info" | "debug" | "dirxml" => {
62            emit(&format_args(args), false);
63            Ok(Value::Undef)
64        }
65        "error" | "warn" => {
66            emit(&format_args(args), true);
67            Ok(Value::Undef)
68        }
69        // `console.dir` renders its first argument through the same inspector,
70        // ignoring the (rarely-used) options argument.
71        "dir" => {
72            let s = with_host(|h| h.inspect(&args.first().cloned().unwrap_or(Value::Undef)));
73            emit(&s, false);
74            Ok(Value::Undef)
75        }
76        // `console.trace` prints a "Trace:"-prefixed message to stderr. A full
77        // captured stack is not attached here (no cheap synchronous stack source
78        // at this layer); the message content matches Node.
79        "trace" => {
80            let msg = format_args(args);
81            let line = if msg.is_empty() {
82                "Trace".to_string()
83            } else {
84                format!("Trace: {msg}")
85            };
86            emit(&line, true);
87            Ok(Value::Undef)
88        }
89        // `console.assert(cond, ...msg)`: on a falsy condition, write
90        // "Assertion failed" (plus any message) to stderr; otherwise no output.
91        "assert" => {
92            let ok = with_host(|h| h.truthy(&args.first().cloned().unwrap_or(Value::Undef)));
93            if !ok {
94                let msg = format_args(&args[1.min(args.len())..]);
95                let line = if msg.is_empty() {
96                    "Assertion failed".to_string()
97                } else {
98                    format!("Assertion failed: {msg}")
99                };
100                emit(&line, true);
101            }
102            Ok(Value::Undef)
103        }
104        "count" => {
105            let label = label_arg(args, "default");
106            let n = COUNTS.with(|c| {
107                let mut m = c.borrow_mut();
108                let e = m.entry(label.clone()).or_insert(0);
109                *e += 1;
110                *e
111            });
112            emit(&format!("{label}: {n}"), false);
113            Ok(Value::Undef)
114        }
115        "countReset" => {
116            let label = label_arg(args, "default");
117            COUNTS.with(|c| c.borrow_mut().remove(&label));
118            Ok(Value::Undef)
119        }
120        // `group`/`groupCollapsed` print their label (if any) then indent all
121        // subsequent output one level; `groupEnd` pops a level.
122        "group" | "groupCollapsed" => {
123            if !args.is_empty() {
124                emit(&format_args(args), false);
125            }
126            GROUP_DEPTH.with(|d| d.set(d.get() + 1));
127            Ok(Value::Undef)
128        }
129        "groupEnd" => {
130            GROUP_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
131            Ok(Value::Undef)
132        }
133        "time" => {
134            let label = label_arg(args, "default");
135            TIMERS.with(|t| t.borrow_mut().insert(label, Instant::now()));
136            Ok(Value::Undef)
137        }
138        "timeEnd" | "timeLog" => {
139            let label = label_arg(args, "default");
140            let elapsed = TIMERS.with(|t| {
141                let m = t.borrow();
142                m.get(&label).map(|start| start.elapsed())
143            });
144            match elapsed {
145                Some(d) => {
146                    let ms = d.as_secs_f64() * 1000.0;
147                    // Any extra args after the label are appended, matching Node.
148                    let extra = if args.len() > 1 {
149                        format!(" {}", format_args(&args[1..]))
150                    } else {
151                        String::new()
152                    };
153                    emit(&format!("{label}: {ms:.3}ms{extra}"), false);
154                    if method == "timeEnd" {
155                        TIMERS.with(|t| t.borrow_mut().remove(&label));
156                    }
157                }
158                None => emit(
159                    &format!("Warning: No such label '{label}' for console.{method}()"),
160                    true,
161                ),
162            }
163            Ok(Value::Undef)
164        }
165        // `console.table(data[, properties])`: render an ASCII box table. Non-tabular
166        // input (a primitive, a function, …) falls back to `console.log`.
167        "table" => {
168            match render_table(args) {
169                Some(t) => emit(&t, false),
170                None => emit(&format_args(args), false),
171            }
172            Ok(Value::Undef)
173        }
174        // `console.clear()`: emit the clear-screen sequence only to a TTY (a no-op
175        // when output is redirected), matching Node.
176        "clear" => {
177            let is_tty = SINK.with(|s| s.borrow().is_some()) || std::io::stdout().is_terminal();
178            if is_tty {
179                emit("\u{1b}[2J\u{1b}[0f", false);
180            }
181            Ok(Value::Undef)
182        }
183        // Devtools-only timeline hooks — no timeline here, so no-ops (as in Node
184        // when not under an inspector).
185        "timeStamp" | "profile" | "profileEnd" => Ok(Value::Undef),
186        _ => return None,
187    })
188}
189
190/// `new console.Console(stdout[, stderr])` or `new console.Console({stdout, stderr})`
191/// → an object tagged `@@native = "Console"` carrying its target streams. Parent
192/// `mod.rs` wires construction and `instance_call`.
193pub fn construct(args: &[Value]) -> Result<Value, String> {
194    let first = args.first().cloned().unwrap_or(Value::Undef);
195    // Options form: a plain object exposing a `stdout` property.
196    let is_options = with_host(|h| match h.get(&first) {
197        Some(JsObj::Object(m)) => m.contains_key("stdout"),
198        _ => false,
199    });
200    let (stdout, stderr) = if is_options {
201        let out = crate::builtins::get_property(&first, "stdout").unwrap_or(Value::Undef);
202        let err = match crate::builtins::get_property(&first, "stderr") {
203            Ok(Value::Undef) | Err(_) => out.clone(),
204            Ok(v) => v,
205        };
206        (out, err)
207    } else {
208        let err = args
209            .get(1)
210            .cloned()
211            .filter(|v| !matches!(v, Value::Undef))
212            .unwrap_or_else(|| first.clone());
213        (first, err)
214    };
215    Ok(with_host(|h| {
216        let mut m = IndexMap::new();
217        m.insert("@@native".into(), h.new_str("Console"));
218        m.insert("@@stdout".into(), stdout);
219        m.insert("@@stderr".into(), stderr);
220        h.new_object(m)
221    }))
222}
223
224/// Dispatch a method on a `Console` instance: install the instance's streams as the
225/// active output sink, run the same formatting/state logic the free functions use,
226/// then restore the previous sink.
227pub fn instance_call(recv: &Value, method: &str, args: Vec<Value>) -> Result<Value, String> {
228    let streams = with_host(|h| match h.get(recv) {
229        Some(JsObj::Object(m)) => Some((
230            m.get("@@stdout").cloned().unwrap_or(Value::Undef),
231            m.get("@@stderr").cloned().unwrap_or(Value::Undef),
232        )),
233        _ => None,
234    });
235    let prev = SINK.with(|s| s.borrow_mut().take());
236    SINK.with(|s| *s.borrow_mut() = streams);
237    let r = call(method, &args).unwrap_or(Ok(Value::Undef));
238    SINK.with(|s| *s.borrow_mut() = prev);
239    r
240}
241
242/// Space-join every argument through the shared console formatter (identical to
243/// the global `console.log` path in `builtins::print_line`).
244fn format_args(args: &[Value]) -> String {
245    // console.log(...args) === util.format(...args): printf substitution when the
246    // first arg is a format string, else inspect-and-join.
247    super::util::format(args)
248}
249
250/// The label argument (`args[0]`) as a string, or `fallback` when absent.
251fn label_arg(args: &[Value], fallback: &str) -> String {
252    match args.first() {
253        Some(v) => with_host(|h| h.str_of(v)),
254        None => fallback.to_string(),
255    }
256}
257
258/// Write a line with the current `console.group` indentation applied to every
259/// physical line. Routes to the active `Console` instance sink stream if one is
260/// installed, else to the process stdout/stderr.
261fn emit(line: &str, stderr: bool) {
262    let depth = GROUP_DEPTH.with(|d| d.get());
263    let out = if depth == 0 {
264        line.to_string()
265    } else {
266        let pad = "  ".repeat(depth);
267        format!("{pad}{}", line.replace('\n', &format!("\n{pad}")))
268    };
269    // A `Console` instance with a real (heap-object) stream: write through it.
270    let stream = SINK.with(|s| {
271        s.borrow().as_ref().and_then(|(o, e)| {
272            let target = if stderr { e } else { o };
273            matches!(target, Value::Obj(_)).then(|| target.clone())
274        })
275    });
276    if let Some(stream) = stream {
277        let payload = with_host(|h| h.new_str(format!("{out}\n")));
278        if crate::host::call_method(&stream, "write", vec![payload]).is_ok() {
279            return;
280        }
281    }
282    if stderr {
283        eprintln!("{out}");
284    } else {
285        println!("{out}");
286    }
287}
288
289// ── console.table ────────────────────────────────────────────────────────────
290
291/// Render `console.table(data[, properties])` as a box-drawn ASCII table, or
292/// `None` when `data` is not a tabular value (array/object) — the caller then
293/// falls back to `console.log`.
294fn render_table(args: &[Value]) -> Option<String> {
295    let data = args.first().cloned().unwrap_or(Value::Undef);
296    let restrict: Option<Vec<String>> =
297        with_host(|h| match h.get(args.get(1).unwrap_or(&Value::Undef)) {
298            Some(JsObj::Array(items)) => Some(items.iter().map(|v| h.str_of(v)).collect()),
299            _ => None,
300        });
301    // (index label, row value) for each row.
302    let entries: Vec<(String, Value)> = with_host(|h| match h.get(&data) {
303        Some(JsObj::Array(items)) => items
304            .iter()
305            .enumerate()
306            .map(|(i, v)| (i.to_string(), v.clone()))
307            .collect(),
308        Some(JsObj::Object(m)) => m
309            .iter()
310            .filter(|(k, _)| !k.starts_with("@@"))
311            .map(|(k, v)| (k.clone(), v.clone()))
312            .collect(),
313        _ => Vec::new(),
314    });
315    if with_host(|h| !matches!(h.get(&data), Some(JsObj::Array(_) | JsObj::Object(_)))) {
316        return None;
317    }
318
319    // Discover columns (union of tabular rows' keys) and whether any row is a bare
320    // value (needing the trailing "Values" column).
321    let mut columns: Vec<String> = Vec::new();
322    let mut has_values = false;
323    for (_, val) in &entries {
324        match row_keys(val) {
325            Some(keys) => {
326                for k in keys {
327                    if !columns.contains(&k) {
328                        columns.push(k);
329                    }
330                }
331            }
332            None => has_values = true,
333        }
334    }
335    if let Some(r) = &restrict {
336        columns = r.clone();
337        has_values = false;
338    }
339
340    // Header + body as a grid of already-rendered cell strings.
341    let mut header = Vec::with_capacity(columns.len() + 2);
342    header.push("(index)".to_string());
343    header.extend(columns.iter().cloned());
344    if has_values {
345        header.push("Values".to_string());
346    }
347
348    let mut rows: Vec<Vec<String>> = Vec::with_capacity(entries.len());
349    for (idx, val) in &entries {
350        let is_primitive = row_keys(val).is_none();
351        let mut row = Vec::with_capacity(header.len());
352        row.push(idx.clone());
353        for col in &columns {
354            match row_get(val, col) {
355                Some(cell) => row.push(with_host(|h| h.inspect(&cell))),
356                None => row.push(String::new()),
357            }
358        }
359        if has_values {
360            row.push(if is_primitive {
361                with_host(|h| h.inspect(val))
362            } else {
363                String::new()
364            });
365        }
366        rows.push(row);
367    }
368
369    Some(draw_table(&header, &rows))
370}
371
372/// The own tabular keys of a row value (`Some` for arrays/objects), or `None` when
373/// the row is a primitive (rendered in the "Values" column).
374fn row_keys(val: &Value) -> Option<Vec<String>> {
375    with_host(|h| match h.get(val) {
376        Some(JsObj::Array(items)) => Some((0..items.len()).map(|i| i.to_string()).collect()),
377        Some(JsObj::Object(m)) => {
378            Some(m.keys().filter(|k| !k.starts_with("@@")).cloned().collect())
379        }
380        _ => None,
381    })
382}
383
384/// Read column `key` from a row value, if present.
385fn row_get(val: &Value, key: &str) -> Option<Value> {
386    with_host(|h| match h.get(val) {
387        Some(JsObj::Array(items)) => key
388            .parse::<usize>()
389            .ok()
390            .and_then(|i| items.get(i).cloned()),
391        Some(JsObj::Object(m)) => m.get(key).cloned(),
392        _ => None,
393    })
394}
395
396/// Draw the box-drawing table from a header row and body rows.
397fn draw_table(header: &[String], rows: &[Vec<String>]) -> String {
398    let ncols = header.len();
399    let mut widths = vec![0usize; ncols];
400    for (i, cell) in header.iter().enumerate() {
401        widths[i] = cell.chars().count();
402    }
403    for row in rows {
404        for (i, cell) in row.iter().enumerate() {
405            widths[i] = widths[i].max(cell.chars().count());
406        }
407    }
408
409    let rule = |left: &str, mid: &str, right: &str| -> String {
410        let mut s = String::from(left);
411        for (i, w) in widths.iter().enumerate() {
412            if i > 0 {
413                s.push_str(mid);
414            }
415            s.push_str(&"─".repeat(w + 2));
416        }
417        s.push_str(right);
418        s
419    };
420    let render_row = |cells: &[String]| -> String {
421        let mut s = String::from("│");
422        for (i, w) in widths.iter().enumerate() {
423            let cell = cells.get(i).map(String::as_str).unwrap_or("");
424            s.push(' ');
425            s.push_str(&pad_center(cell, *w));
426            s.push_str(" │");
427        }
428        s
429    };
430
431    let mut lines = Vec::with_capacity(rows.len() + 4);
432    lines.push(rule("┌", "┬", "┐"));
433    lines.push(render_row(header));
434    lines.push(rule("├", "┼", "┤"));
435    for row in rows {
436        lines.push(render_row(row));
437    }
438    lines.push(rule("└", "┴", "┘"));
439    lines.join("\n")
440}
441
442/// Center `s` within `w` columns (extra space biased to the right, as Node does).
443fn pad_center(s: &str, w: usize) -> String {
444    let len = s.chars().count();
445    if len >= w {
446        return s.to_string();
447    }
448    let total = w - len;
449    let left = total / 2;
450    let right = total - left;
451    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
452}