Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions core/runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ futures = "0.3.32"
futures-lite.workspace = true
http = { workspace = true, optional = true }
reqwest = { workspace = true, optional = true }
comfy-table.workspace = true
rustc-hash = { workspace = true, features = ["std"] }
serde_json = { workspace = true, optional = true }
url = { workspace = true, optional = true }
Expand Down
92 changes: 90 additions & 2 deletions core/runtime/src/console/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@
//! [spec]: https://console.spec.whatwg.org/
//! [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Console

mod table;
#[cfg(test)]
pub(crate) mod tests;

pub use table::TableData;

use boa_engine::JsVariant;
use boa_engine::property::Attribute;
use boa_engine::{
Context, JsArgs, JsData, JsError, JsResult, JsString, JsSymbol, js_str, js_string,
Context, JsArgs, JsData, JsError, JsNativeError, JsResult, JsString, JsSymbol, js_str,
js_string,
native_function::NativeFunction,
object::{JsObject, ObjectInitializer},
value::{JsValue, Numeric},
};
use boa_gc::{Finalize, Trace};
use comfy_table::{Cell, Table};
use rustc_hash::FxHashMap;
use std::{
cell::RefCell, collections::hash_map::Entry, fmt::Write as _, io::Write, rc::Rc,
Expand Down Expand Up @@ -83,6 +88,29 @@ pub trait Logger: Trace {
/// # Errors
/// Returning an error will throw an exception in JavaScript.
fn error(&self, msg: String, state: &ConsoleState, context: &mut Context) -> JsResult<()>;

/// Log tabular data (`console.table`). The default implementation renders
/// the table with `comfy-table` and passes the result to [`Logger::log`].
///
/// # Errors
/// Returning an error will throw an exception in JavaScript.
fn table(&self, data: TableData, state: &ConsoleState, context: &mut Context) -> JsResult<()> {
let mut table = Table::new();
table.load_preset(comfy_table::presets::UTF8_FULL);
table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic);
table.set_header(&data.col_names);

for row in &data.rows {
let cells: Vec<Cell> = data
.col_names
.iter()
.map(|name| Cell::new(row.get(name).cloned().unwrap_or_default()))
.collect();
table.add_row(cells);
}

self.log(table.to_string(), state, context)
}
}

/// The default implementation for logging from the console.
Expand Down Expand Up @@ -434,10 +462,15 @@ impl Console {
0,
)
.function(
console_method(Self::dir, state, logger.clone()),
console_method(Self::dir, state.clone(), logger.clone()),
js_string!("dirxml"),
0,
)
.function(
console_method(Self::table, state, logger.clone()),
js_string!("table"),
0,
)
.build()
}

Expand Down Expand Up @@ -917,4 +950,59 @@ impl Console {
)?;
Ok(JsValue::undefined())
}

/// `console.table(tabularData, properties)`
///
/// Prints a table with the columns of the properties of `tabularData`
/// (or a subset given by `properties`) and rows of `tabularData`.
/// Falls back to `console.log` if the data cannot be parsed as tabular.
///
/// More information:
/// - [MDN documentation][mdn]
/// - [WHATWG `console` specification][spec]
///
/// [spec]: https://console.spec.whatwg.org/#table
/// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/console/table_static
fn table(
_: &JsValue,
args: &[JsValue],
console: &Self,
logger: &impl Logger,
context: &mut Context,
) -> JsResult<JsValue> {
let tabular_data = args.get_or_undefined(0);

// Non-objects fall back to console.log.
let Some(obj) = tabular_data.as_object() else {
return Self::log(&JsValue::undefined(), args, console, logger, context);
};

// Validate the optional `properties` argument (must be an array if present).
let properties = match args.get(1) {
Some(props) if !props.is_undefined() => {
let obj =
props.as_object().ok_or_else(|| {
JsError::from_native(JsNativeError::typ().with_message(
"The \"properties\" argument must be an instance of Array",
))
})?;
if !obj.is_array() {
return Err(JsError::from_native(JsNativeError::typ().with_message(
"The \"properties\" argument must be an instance of Array",
)));
}
Some(obj.clone())
}
_ => None,
};

let data = table::build_table_data(&obj, properties.as_ref(), context)?;

match data {
Some(td) => logger.table(td, &console.state, context)?,
None => return Self::log(&JsValue::undefined(), args, console, logger, context),
}

Ok(JsValue::undefined())
}
}
214 changes: 214 additions & 0 deletions core/runtime/src/console/table.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Data extraction helpers for `console.table()`.
//!
//! This module converts a JS value into [`TableData`] that the [`Logger`]
//! backend can render however it likes (terminal box-drawing, HTML, etc.).

use boa_engine::builtins::object::OrdinaryObject;
use boa_engine::object::builtins::{JsMap, JsSet};
use boa_engine::{Context, JsError, JsResult, JsValue, js_string, object::JsObject};
use rustc_hash::{FxHashMap, FxHashSet};

/// The column name used for row indices.
const INDEX_COL: &str = "(index)";
/// The column name used for iteration indices (Map/Set).
const ITER_INDEX_COL: &str = "(iteration index)";
/// The column name used for primitive (non-object) values.
const VALUE_COL: &str = "Values";
/// The column name used for Map keys.
const KEY_COL: &str = "Key";

/// Structured data for `console.table()`, passed to the [`super::Logger`] so
/// each backend can render it in the most appropriate way.
#[derive(Debug, Clone)]
pub struct TableData {
/// Column headers, always starting with `"(index)"` or `"(iteration index)"`.
pub col_names: Vec<String>,
/// Each row is a map from column name to cell value.
pub rows: Vec<FxHashMap<String, String>>,
}

/// Try to build [`TableData`] from the first argument to `console.table()`.
///
/// Returns `Ok(None)` when the data is not tabular (primitive, or empty
/// object/array) so the caller can fall back to `console.log`.
pub(super) fn build_table_data(
obj: &JsObject,
properties: Option<&JsObject>,
context: &mut Context,
) -> JsResult<Option<TableData>> {
// Map/Set have a fixed column layout and ignore the `properties` filter,
// matching Node.js behaviour.
let (mut data, is_collection) = if let Ok(map) = JsMap::from_object(obj.clone()) {
(extract_map_rows(&map)?, true)
} else if let Ok(set) = JsSet::from_object(obj.clone()) {
(extract_set_rows(&set)?, true)
} else {
(extract_rows(obj, context)?, false)
};

if data.rows.is_empty() {
return Ok(None);
}

// Only apply the properties filter to plain objects/arrays, not Map/Set.
if !is_collection && let Some(props) = properties {
data.col_names = filter_columns(&data.col_names, props, context)?;
}

Ok(Some(data))
}

/// Extracts rows from a `Map`, using `(iteration index)`, `Key`, and `Values`
/// columns to match Node.js/Chrome behaviour.
fn extract_map_rows(map: &JsMap) -> JsResult<TableData> {
let col_names = vec![
ITER_INDEX_COL.to_string(),
KEY_COL.to_string(),
VALUE_COL.to_string(),
];
let mut rows = Vec::new();
let mut index = 0usize;

map.for_each_native(|key, value| {
let mut row = FxHashMap::default();
row.insert(ITER_INDEX_COL.to_string(), index.to_string());
row.insert(KEY_COL.to_string(), display_cell_value(&key));
row.insert(VALUE_COL.to_string(), display_cell_value(&value));
rows.push(row);
index += 1;
Ok(())
})?;

Ok(TableData { col_names, rows })
}

/// Extracts rows from a `Set`, using `(iteration index)` and `Values` columns.
fn extract_set_rows(set: &JsSet) -> JsResult<TableData> {
let col_names = vec![ITER_INDEX_COL.to_string(), VALUE_COL.to_string()];
let mut rows = Vec::new();
let mut index = 0usize;

set.for_each_native(|value| {
let mut row = FxHashMap::default();
row.insert(ITER_INDEX_COL.to_string(), index.to_string());
row.insert(VALUE_COL.to_string(), display_cell_value(&value));
rows.push(row);
index += 1;
Ok(())
})?;

Ok(TableData { col_names, rows })
}

/// Extracts rows and column names from a JS object/array.
///
/// Only considers enumerable own string-keyed properties, matching
/// browser behaviour (equivalent to `Object.keys()`, e.g. excludes `length` on arrays).
fn extract_rows(obj: &JsObject, context: &mut Context) -> JsResult<TableData> {
let keys = enumerable_keys(obj, context)?;
let mut col_names = vec![INDEX_COL.to_string()];
let mut seen_cols: FxHashSet<String> = FxHashSet::default();
seen_cols.insert(INDEX_COL.to_string());
let mut rows = Vec::new();

for index_str in &keys {
let val = obj.get(js_string!(index_str.as_str()), context)?;
let mut row = FxHashMap::default();
row.insert(INDEX_COL.to_string(), index_str.clone());

if let Some(val_obj) = val.as_object() {
let inner_keys = enumerable_keys(&val_obj, context)?;
for col in &inner_keys {
if seen_cols.insert(col.clone()) {
col_names.push(col.clone());
}
let cell = val_obj.get(js_string!(col.as_str()), context)?;
row.insert(col.clone(), display_cell_value(&cell));
}
} else {
if seen_cols.insert(VALUE_COL.to_string()) {
col_names.push(VALUE_COL.to_string());
}
row.insert(VALUE_COL.to_string(), display_cell_value(&val));
}

rows.push(row);
}

Ok(TableData { col_names, rows })
}

/// Formats a JS value for display inside a table cell.
///
/// Objects and arrays are rendered on a single line (e.g. `{ nested: true }`
/// instead of multi-line pretty-print), matching Node.js/Chrome behaviour
/// for nested values in `console.table`.
fn display_cell_value(val: &JsValue) -> String {
let raw = val.display().to_string();
// If the display spans multiple lines, collapse to single-line.
if raw.contains('\n') {
raw.split('\n').map(str::trim).collect::<Vec<_>>().join(" ")
} else {
raw
}
}

/// Returns the enumerable own string-keyed property names of `obj`,
/// equivalent to `Object.keys(obj)`.
fn enumerable_keys(obj: &JsObject, context: &mut Context) -> JsResult<Vec<String>> {
let keys_val = OrdinaryObject::keys(
&JsValue::undefined(),
&[JsValue::from(obj.clone())],
context,
)?;
let Some(keys_obj) = keys_val.as_object() else {
return Err(JsError::from_native(
boa_engine::JsNativeError::typ().with_message("Object.keys did not return an object"),
));
};
let length = keys_obj
.get(js_string!("length"), context)?
.to_length(context)?;
let mut result = Vec::with_capacity(usize::try_from(length).unwrap_or(0));
for i in 0..length {
let val = keys_obj.get(i, context)?;
result.push(val.to_string(context)?.to_std_string_escaped());
}
Ok(result)
}

/// Builds a column list from the `properties` array.
///
/// The returned list uses the **filter's order** (not discovery order),
/// and includes properties that don't exist in the data (they render as
/// empty cells). The index column is always first. Duplicates are ignored.
/// This matches Node.js behaviour.
fn filter_columns(
all_cols: &[String],
properties: &JsObject,
context: &mut Context,
) -> JsResult<Vec<String>> {
let length = properties
.get(js_string!("length"), context)?
.to_length(context)?;

let mut result = Vec::new();
let mut seen = FxHashSet::default();

// Always include the index column first.
if let Some(idx_col) = all_cols.first() {
result.push(idx_col.clone());
seen.insert(idx_col.clone());
}

// Add columns in the order specified by the properties array.
for i in 0..length {
let val = properties.get(i, context)?;
let col = val.to_string(context)?.to_std_string_escaped();
if seen.insert(col.clone()) {
result.push(col);
}
}

Ok(result)
}
Loading