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