Skip to main content

ferrijs_std/node/
inspect.rs

1//! `util.inspect` / `util.format`: the one value renderer this runtime has.
2//!
3//! Node renders values the same way in three places — `console.log`,
4//! `util.inspect` and `util.format`'s `%o` / `%O` / `%j` specifiers — so
5//! there is one implementation here, used by the runtime's `console`
6//! global and by the `util` module next door.
7//!
8//! Written here, not vendored from llrt (upstream's `util` has no
9//! inspect at all and its console formatter is not reusable).
10
11use std::fmt::Write as _;
12
13use rquickjs::function::This;
14use rquickjs::{Function, Object, Value};
15
16/// Strip ANSI escape sequences from a string.
17///
18/// Every string that comes from JS is run through this as it is written, so
19/// page content cannot smuggle terminal control codes into the output while
20/// the renderer's own styling survives.
21#[must_use]
22pub fn strip_ansi(input: &str) -> String {
23  let mut out = String::with_capacity(input.len());
24  let mut chars = input.chars().peekable();
25  while let Some(c) = chars.next() {
26    if c == '\x1b' && chars.peek() == Some(&'[') {
27      chars.next();
28      for nc in chars.by_ref() {
29        if ('@'..='~').contains(&nc) {
30          break;
31        }
32      }
33    } else {
34      out.push(c);
35    }
36  }
37  out
38}
39
40
41/// Nesting depth at which containers render as `[Array]` / `[Object]`, matching
42/// `util.inspect`'s `depth: 2` default.
43pub const MAX_DEPTH: usize = 2;
44
45/// Node's `maxArrayLength`: elements past this are summarised as
46/// `... N more items` rather than printed.
47pub const MAX_ARRAY_LENGTH: usize = 100;
48
49/// Ceiling for an explicit `console.dir(value, { depth: null })`, which is
50/// otherwise unbounded and would happily walk a cyclic-free but enormous
51/// object graph.
52pub const MAX_DIR_DEPTH: usize = 8;
53
54const RESET: &str = "\x1b[0m";
55/// `util.inspect.styles` mapped to their SGR codes.
56const NUMBER: &str = "\x1b[33m";
57const STRING: &str = "\x1b[32m";
58const BOOLEAN: &str = "\x1b[33m";
59const UNDEFINED: &str = "\x1b[90m";
60const NULL: &str = "\x1b[1m";
61const SYMBOL: &str = "\x1b[32m";
62const DATE: &str = "\x1b[35m";
63const REGEXP: &str = "\x1b[31m";
64const SPECIAL: &str = "\x1b[36m";
65
66/// Renders JS values the way `util.inspect` does, with or without colour.
67#[derive(Clone, Copy)]
68pub struct Inspector {
69  /// Whether rendered values carry SGR colour codes.
70  pub styled: bool,
71  max_depth: usize,
72  /// Whether a string rendered at the top level prints bare. `console.log`
73  /// prints its string arguments raw; `util.inspect` (so `dir`, `%o`, `%O`,
74  /// table cells) quotes them.
75  bare_top_string: bool,
76}
77
78impl Inspector {
79  pub fn new(styled: bool) -> Self {
80    Self {
81      styled,
82      max_depth: MAX_DEPTH,
83      bare_top_string: true,
84    }
85  }
86
87  pub fn with_depth(self, max_depth: usize) -> Self {
88    Self { max_depth, ..self }
89  }
90
91  /// Quote strings even at the top level, the way `util.inspect` does.
92  pub fn quoted(self) -> Self {
93    Self {
94      bare_top_string: false,
95      ..self
96    }
97  }
98
99  /// Write `text` (already-formatted, JS-derived) under `code`, sanitising
100  /// any escape sequence the value itself carried.
101  pub fn paint(self, out: &mut String, code: &str, text: &str) {
102    let text = strip_ansi(text);
103    // An empty code is "no style" (object keys, top-level strings) — writing
104    // a bare reset there would end the enclosing colour, not start one.
105    if self.styled && !code.is_empty() {
106      out.push_str(code);
107      out.push_str(&text);
108      out.push_str(RESET);
109    } else {
110      out.push_str(&text);
111    }
112  }
113
114  /// Structural punctuation we emit ourselves — never sanitised, never styled.
115  pub fn punct(out: &mut String, text: &str) {
116    out.push_str(text);
117  }
118
119  /// Node's `util.format` core: when the first argument is a string,
120  /// `%s` / `%d` / `%i` / `%f` / `%j` / `%o` / `%O` / `%c` / `%%`
121  /// consume the following arguments; leftovers are appended
122  /// space-separated. Returns how many arguments were consumed
123  /// (including the format string itself).
124  pub fn printf(self, out: &mut String, fmt: &str, args: &[Value<'_>]) -> rquickjs::Result<usize> {
125    let mut consumed = 0usize;
126    let mut chars = fmt.chars().peekable();
127    let mut literal = String::new();
128    while let Some(c) = chars.next() {
129      if c != '%' {
130        literal.push(c);
131        continue;
132      }
133      let Some(&spec) = chars.peek() else {
134        literal.push('%');
135        break;
136      };
137      if spec == '%' {
138        chars.next();
139        literal.push('%');
140        continue;
141      }
142      if !matches!(spec, 's' | 'd' | 'i' | 'f' | 'j' | 'o' | 'O' | 'c') {
143        literal.push('%');
144        continue;
145      }
146      let Some(arg) = args.get(consumed) else {
147        // More specifiers than arguments — Node leaves them literal.
148        literal.push('%');
149        continue;
150      };
151      chars.next();
152      consumed += 1;
153      // The format string is JS-supplied too: flush it through the
154      // sanitiser before the substitution lands.
155      out.push_str(&strip_ansi(&std::mem::take(&mut literal)));
156      match spec {
157        's' => {
158          if let Some(s) = arg.as_string() {
159            self.paint(out, "", &s.to_string()?);
160          } else {
161            // Node inspects a `%s` object at depth 0, without colour.
162            Inspector::new(false).with_depth(0).value(out, arg, 0)?;
163          }
164        },
165        // Node coerces through `Number` / `parseInt` / `parseFloat`, so a
166        // numeric string converts and `'42px'` yields 42 under `%i`.
167        'd' | 'i' | 'f' => self.paint(out, NUMBER, &coerce_number(arg, spec)?),
168        'j' => {
169          // A circular structure makes `JSON.stringify` throw; Node prints
170          // `[Circular]` rather than letting the console call fail.
171          let json = arg
172            .ctx()
173            .json_stringify(arg.clone())
174            .ok()
175            .flatten()
176            .and_then(|s| s.to_string().ok());
177          match json {
178            Some(text) => self.paint(out, "", &text),
179            None if arg.is_undefined() => self.paint(out, "", "undefined"),
180            None => self.paint(out, "", "[Circular]"),
181          }
182        },
183        // `%o` inspects deeper (Node uses depth 4), `%O` uses the default.
184        'o' => self.quoted().with_depth(4).value(out, arg, 0)?,
185        'O' => self.quoted().with_depth(MAX_DEPTH).value(out, arg, 0)?,
186        // %c consumes a CSS argument and renders nothing in a terminal;
187        // the guard above filters everything else out.
188        _ => {},
189      }
190    }
191    out.push_str(&strip_ansi(&literal));
192    Ok(consumed + 1)
193  }
194
195  /// Render a whole `console.*` argument list: a leading format string
196  /// consumes what it needs, the rest is appended space-separated.
197  pub fn args(self, out: &mut String, args: &[Value<'_>]) -> rquickjs::Result<()> {
198    let mut start = 0usize;
199    if let Some(fmt) = args.first().and_then(rquickjs::Value::as_string) {
200      let fmt = fmt.to_string()?;
201      if fmt.contains('%') {
202        start = self.printf(out, &fmt, &args[1..])?;
203      }
204    }
205    for (i, v) in args.iter().enumerate().skip(start) {
206      if i > 0 || start > 0 {
207        out.push(' ');
208      }
209      self.value(out, v, 0)?;
210    }
211    Ok(())
212  }
213
214  /// Node-ish console value renderer: top-level strings unquoted (quoted
215  /// with `'` inside containers, like `util.inspect`), arrays as
216  /// `[ 1, 2 ]`, objects as `{ a: 1, b: 2 }`, `Map(n) { k => v }`,
217  /// `Set(n) { v }`, Dates as ISO strings, RegExp as `/src/flags`,
218  /// `[Function: name]`, `Symbol(desc)`, `123n` bigints, `name: message`
219  /// (+ stack) for Error values, and `[Array]` / `[Object]` past
220  /// `max_depth` nesting.
221  #[allow(clippy::too_many_lines)]
222  pub fn value(self, out: &mut String, value: &Value<'_>, depth: usize) -> rquickjs::Result<()> {
223    use rquickjs::Type;
224
225    match value.type_of() {
226      Type::String => {
227        if let Some(s) = value.as_string() {
228          let s = s.to_string()?;
229          if depth == 0 && self.bare_top_string {
230            self.paint(out, "", &s);
231          } else {
232            // Inside containers Node quotes strings, escaping control
233            // characters and picking a quote the body does not contain.
234            self.paint(out, STRING, &quote_js_string(&s));
235          }
236        }
237      },
238      Type::Int => self.paint(out, NUMBER, &value.as_int().unwrap_or_default().to_string()),
239      Type::Bool => self.paint(out, BOOLEAN, &value.as_bool().unwrap_or_default().to_string()),
240      Type::Float => self.paint(out, NUMBER, &value.as_float().unwrap_or_default().to_string()),
241      Type::BigInt => {
242        if let Some(b) = value.clone().into_big_int() {
243          self.paint(out, NUMBER, &format!("{}n", b.clone().to_i64()?));
244        }
245      },
246      Type::Array => {
247        let Some(array) = value.as_array() else { return Ok(()) };
248        if depth > self.max_depth {
249          self.paint(out, SPECIAL, "[Array]");
250          return Ok(());
251        }
252        if array.is_empty() {
253          Self::punct(out, "[]");
254          return Ok(());
255        }
256        Self::punct(out, "[ ");
257        let len = array.len();
258        for (i, element) in array.iter::<Value<'_>>().take(MAX_ARRAY_LENGTH).enumerate() {
259          if i > 0 {
260            Self::punct(out, ", ");
261          }
262          self.value(out, &element?, depth + 1)?;
263        }
264        // Node's `maxArrayLength`: the tail is summarised, never printed.
265        if len > MAX_ARRAY_LENGTH {
266          let more = len - MAX_ARRAY_LENGTH;
267          let plural = if more == 1 { "item" } else { "items" };
268          Self::punct(out, &format!(", ... {more} more {plural}"));
269        }
270        Self::punct(out, " ]");
271      },
272      Type::Exception => {
273        if let Some(ex) = value.as_exception() {
274          let name = ex.get::<_, String>("name").unwrap_or_else(|_| "Error".to_string());
275          let mut rendered = name;
276          if let Some(message) = ex.message() {
277            rendered.push_str(": ");
278            rendered.push_str(&message);
279          }
280          // Node prints the stack under the message; keep it at top level
281          // only so nested Errors don't explode container output.
282          if depth == 0 {
283            if let Some(stack) = ex.stack().filter(|s| !s.is_empty()) {
284              rendered.push('\n');
285              rendered.push_str(&stack);
286            }
287          }
288          self.paint(out, REGEXP, &rendered);
289        }
290      },
291      Type::Object => {
292        if depth > self.max_depth {
293          self.paint(out, SPECIAL, "[Object]");
294          return Ok(());
295        }
296        let Some(object) = value.as_object() else { return Ok(()) };
297        if self.special_object(out, object, depth)? {
298          return Ok(());
299        }
300        // `Foo { a: 1 }` for a class instance, `[Object: null prototype] {}`
301        // for one made with `Object.create(null)` — both are how Node warns
302        // that this is not a plain object literal.
303        match constructor_name(object) {
304          Some(name) if name != "Object" => {
305            self.paint(out, "", &name);
306            Self::punct(out, " ");
307          },
308          None => {
309            self.paint(out, SPECIAL, "[Object: null prototype]");
310            Self::punct(out, " ");
311          },
312          Some(_) => {},
313        }
314        let mut wrote_any = false;
315        for (i, prop) in object.props::<String, Value<'_>>().enumerate() {
316          let (key, val) = prop?;
317          if i == 0 {
318            Self::punct(out, "{ ");
319            wrote_any = true;
320          } else {
321            Self::punct(out, ", ");
322          }
323          self.paint(out, "", &key);
324          Self::punct(out, ": ");
325          self.value(out, &val, depth + 1)?;
326        }
327        Self::punct(out, if wrote_any { " }" } else { "{}" });
328      },
329      Type::Symbol => {
330        if let Some(symbol) = value.as_symbol() {
331          let description = symbol
332            .description()?
333            .as_string()
334            .map(rquickjs::String::to_string)
335            .transpose()?
336            .unwrap_or_default();
337          self.paint(out, SYMBOL, &format!("Symbol({description})"));
338        }
339      },
340      Type::Function | Type::Constructor => {
341        let name = value
342          .as_object()
343          .and_then(|f| f.get::<_, String>("name").ok())
344          .filter(|n| !n.is_empty());
345        match name {
346          Some(name) => self.paint(out, SPECIAL, &format!("[Function: {name}]")),
347          None => self.paint(out, SPECIAL, "[Function (anonymous)]"),
348        }
349      },
350      // A promise is its own `Type`, so it never reached the object arm and
351      // used to render as an empty string — hiding the most common console
352      // mistake there is, logging a promise instead of awaiting it.
353      Type::Promise => {
354        let Some(promise) = value.as_promise() else {
355          return Ok(());
356        };
357        Self::punct(out, "Promise { ");
358        match promise.state() {
359          rquickjs::promise::PromiseState::Pending => self.paint(out, SPECIAL, "<pending>"),
360          rquickjs::promise::PromiseState::Resolved => match promise.result::<Value<'_>>() {
361            Some(Ok(inner)) => self.value(out, &inner, depth + 1)?,
362            _ => self.paint(out, SPECIAL, "<pending>"),
363          },
364          rquickjs::promise::PromiseState::Rejected => {
365            self.paint(out, REGEXP, "<rejected>");
366            Self::punct(out, " ");
367            // Reading the result of a rejected promise SETS the pending
368            // exception; `catch` takes it back off the context, so inspecting
369            // a rejection leaves no residue for the caller to trip over.
370            if let Some(Err(_)) = promise.result::<Value<'_>>() {
371              let reason = value.ctx().catch();
372              self.value(out, &reason, depth + 1)?;
373            }
374          },
375        }
376        Self::punct(out, " }");
377      },
378      Type::Null => self.paint(out, NULL, "null"),
379      Type::Undefined | Type::Uninitialized => self.paint(out, UNDEFINED, "undefined"),
380      _ => {},
381    }
382    Ok(())
383  }
384
385  /// Render Date / RegExp / Map / Set the way Node's `util.inspect` does
386  /// (`2026-01-01T00:00:00.000Z`, `/ab+c/i`, `Map(1) { 'a' => 1 }`,
387  /// `Set(2) { 1, 2 }`). Returns `false` when `object` is none of those
388  /// so the caller falls through to plain-object rendering. Detection is
389  /// by constructor name — cheap, and correct for anything built from
390  /// the real globals.
391  pub fn special_object(self, out: &mut String, object: &Object<'_>, depth: usize) -> rquickjs::Result<bool> {
392    let ctor_name: String = object
393      .get::<_, Object<'_>>("constructor")
394      .and_then(|c| c.get::<_, String>("name"))
395      .unwrap_or_default();
396    match ctor_name.as_str() {
397      "Date" => {
398        // toISOString throws on Invalid Date — match Node's rendering.
399        let iso = object
400          .get::<_, Function<'_>>("toISOString")
401          .and_then(|f| f.call::<_, String>((This(object.clone()),)));
402        match iso {
403          Ok(s) => self.paint(out, DATE, &s),
404          Err(_) => self.paint(out, DATE, "Invalid Date"),
405        }
406        Ok(true)
407      },
408      "RegExp" => {
409        let source: String = object.get("source").unwrap_or_default();
410        let flags: String = object.get("flags").unwrap_or_default();
411        self.paint(out, REGEXP, &format!("/{source}/{flags}"));
412        Ok(true)
413      },
414      kind @ ("WeakMap" | "WeakSet") => {
415        Self::punct(out, kind);
416        Self::punct(out, " { ");
417        self.paint(out, SPECIAL, "<items unknown>");
418        Self::punct(out, " }");
419        Ok(true)
420      },
421      kind @ ("Int8Array" | "Uint8Array" | "Uint8ClampedArray" | "Int16Array" | "Uint16Array" | "Int32Array"
422      | "Uint32Array" | "Float32Array" | "Float64Array" | "BigInt64Array" | "BigUint64Array") => {
423        let len: usize = object.get("length").unwrap_or_default();
424        Self::punct(out, &format!("{kind}({len})"));
425        if len == 0 {
426          Self::punct(out, " []");
427          return Ok(true);
428        }
429        Self::punct(out, " [ ");
430        for i in 0..len.min(MAX_ARRAY_LENGTH) {
431          if i > 0 {
432            Self::punct(out, ", ");
433          }
434          let element: Value<'_> = object.get(i as u32)?;
435          self.value(out, &element, depth + 1)?;
436        }
437        if len > MAX_ARRAY_LENGTH {
438          let more = len - MAX_ARRAY_LENGTH;
439          let plural = if more == 1 { "item" } else { "items" };
440          Self::punct(out, &format!(", ... {more} more {plural}"));
441        }
442        Self::punct(out, " ]");
443        Ok(true)
444      },
445      "ArrayBuffer" | "SharedArrayBuffer" => {
446        let len: usize = object.get("byteLength").unwrap_or_default();
447        Self::punct(out, &format!("{ctor_name} {{ byteLength: "));
448        self.paint(out, NUMBER, &len.to_string());
449        Self::punct(out, " }");
450        Ok(true)
451      },
452      kind @ ("Map" | "Set") => {
453        let size: usize = object.get("size").unwrap_or_default();
454        Self::punct(out, &format!("{kind}({size})"));
455        if size == 0 {
456          Self::punct(out, " {}");
457          return Ok(true);
458        }
459        if depth > self.max_depth {
460          return Ok(true);
461        }
462        // Drive the JS iterator so insertion order is preserved.
463        let entries: rquickjs::Result<Function<'_>> = object.get("entries");
464        let values: rquickjs::Result<Function<'_>> = object.get("values");
465        let iter_fn = if kind == "Map" { entries } else { values };
466        let Ok(iter_fn) = iter_fn else { return Ok(true) };
467        let iterator: Object<'_> = iter_fn.call((This(object.clone()),))?;
468        let next_fn: Function<'_> = iterator.get("next")?;
469        Self::punct(out, " { ");
470        let mut first = true;
471        loop {
472          let step: Object<'_> = next_fn.call((This(iterator.clone()),))?;
473          if step.get::<_, bool>("done").unwrap_or(true) {
474            break;
475          }
476          if !first {
477            Self::punct(out, ", ");
478          }
479          first = false;
480          let entry: Value<'_> = step.get("value")?;
481          if kind == "Map" {
482            let Some(pair) = entry.as_array() else { continue };
483            self.value(out, &pair.get::<Value<'_>>(0)?, depth + 1)?;
484            Self::punct(out, " => ");
485            self.value(out, &pair.get::<Value<'_>>(1)?, depth + 1)?;
486          } else {
487            self.value(out, &entry, depth + 1)?;
488          }
489        }
490        Self::punct(out, " }");
491        Ok(true)
492      },
493      _ => Ok(false),
494    }
495  }
496}
497
498/// The object's constructor name, or `None` for a null-prototype object.
499/// Reading `constructor` off a null-prototype object yields nothing, which is
500/// exactly the case Node marks as `[Object: null prototype]`.
501fn constructor_name(object: &Object<'_>) -> Option<String> {
502  let prototype = object.get::<_, Value<'_>>("__proto__").ok()?;
503  if prototype.is_null() || prototype.is_undefined() {
504    return None;
505  }
506  object
507    .get::<_, Object<'_>>("constructor")
508    .and_then(|c| c.get::<_, String>("name"))
509    .ok()
510    .filter(|n| !n.is_empty())
511}
512
513/// Quote a string the way `util.inspect` does: prefer single quotes, fall back
514/// to double then backtick when the body contains the previous choice, and
515/// escape backslashes and control characters so one value cannot break the
516/// surrounding rendering across lines.
517fn quote_js_string(text: &str) -> String {
518  let quote = if !text.contains('\'') {
519    '\''
520  } else if !text.contains('"') {
521    '"'
522  } else {
523    '`'
524  };
525  let mut out = String::with_capacity(text.len() + 2);
526  out.push(quote);
527  for c in text.chars() {
528    match c {
529      '\\' => out.push_str("\\\\"),
530      '\n' => out.push_str("\\n"),
531      '\r' => out.push_str("\\r"),
532      '\t' => out.push_str("\\t"),
533      c if c == quote => {
534        out.push('\\');
535        out.push(c);
536      },
537      c if (c as u32) < 0x20 => {
538        // `write!` into the buffer instead of allocating a throwaway
539        // String per control character.
540        let _ = write!(out, "\\x{:02x}", c as u32);
541      },
542      c => out.push(c),
543    }
544  }
545  out.push(quote);
546  out
547}
548
549/// Coerce a `%d` / `%i` / `%f` argument the way Node does: `%d` through
550/// `Number`, `%i` through `parseInt`, `%f` through `parseFloat` — so a numeric
551/// string converts and `'42px'` yields 42 under `%i`. BigInt keeps its `n`
552/// suffix and Symbol is `NaN`, neither of which the global functions accept.
553fn coerce_number(arg: &Value<'_>, spec: char) -> rquickjs::Result<String> {
554  use rquickjs::Type;
555
556  match arg.type_of() {
557    Type::BigInt => {
558      if let Some(b) = arg.clone().into_big_int() {
559        return Ok(format!("{}n", b.to_i64()?));
560      }
561      return Ok("NaN".to_string());
562    },
563    Type::Symbol => return Ok("NaN".to_string()),
564    _ => {},
565  }
566  let global = match spec {
567    'i' => "parseInt",
568    'f' => "parseFloat",
569    _ => "Number",
570  };
571  let Ok(convert) = arg.ctx().globals().get::<_, Function<'_>>(global) else {
572    return Ok("NaN".to_string());
573  };
574  let converted: f64 = convert.call((arg.clone(),)).unwrap_or(f64::NAN);
575  if converted.is_nan() {
576    return Ok("NaN".to_string());
577  }
578  Ok(converted.to_string())
579}