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