use js_sys::{Array, Object};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsValue;
pub(super) const MAX_VALUE_CHARS: usize = 80;
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
pub(crate) fn truncate(s: &str) -> String {
let n = s.chars().count();
if n <= MAX_VALUE_CHARS {
return s.to_string();
}
let budget = MAX_VALUE_CHARS.saturating_sub(1);
let head_len = budget.div_ceil(2);
let tail_len = budget - head_len;
let head: String = s.chars().take(head_len).collect();
let tail: String = s
.chars()
.rev()
.take(tail_len)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{head}…{tail}")
}
pub(crate) fn stringify(v: &JsValue) -> String {
if v.is_undefined() {
return "undefined".into();
}
if v.is_null() {
return "null".into();
}
if let Some(s) = v.as_string() {
return truncate(&format!("\"{s}\""));
}
if let Some(n) = v.as_f64() {
return n.to_string();
}
if let Some(b) = v.as_bool() {
return b.to_string();
}
if Array::is_array(v) {
let len = Array::from(v).length();
return format!("[{len} items]");
}
let raw = js_sys::JSON::stringify(v)
.ok()
.and_then(|s| s.as_string())
.unwrap_or_else(|| "?".into());
truncate(&raw)
}
pub(crate) fn raw_value(v: &JsValue) -> String {
if v.is_undefined() {
return "undefined".into();
}
if v.is_null() {
return "null".into();
}
if let Some(s) = v.as_string() {
return s;
}
if let Some(n) = v.as_f64() {
return n.to_string();
}
if let Some(b) = v.as_bool() {
return b.to_string();
}
js_sys::JSON::stringify(v)
.ok()
.and_then(|s| s.as_string())
.unwrap_or_default()
}
pub(crate) fn build_json_view(v: &JsValue, is_root: bool) -> String {
if v.is_undefined() {
return r#"<span class="__pp_jv_null" data-copy="undefined">undefined</span>"#.into();
}
if v.is_null() {
return r#"<span class="__pp_jv_null" data-copy="null">null</span>"#.into();
}
if let Some(s) = v.as_string() {
return format!(
"<span class=\"__pp_jv_string\" data-copy=\"{}\">\"{}\"</span>",
escape(&s),
escape(&s)
);
}
if let Some(n) = v.as_f64() {
let n_str = n.to_string();
return format!("<span class=\"__pp_jv_number\" data-copy=\"{n_str}\">{n_str}</span>");
}
if let Some(b) = v.as_bool() {
let b_str = b.to_string();
return format!("<span class=\"__pp_jv_bool\" data-copy=\"{b_str}\">{b_str}</span>");
}
if Array::is_array(v) {
let arr = Array::from(v);
let len = arr.length();
if len == 0 {
return r#"<span class="__pp_jv_empty">[ ]</span>"#.into();
}
let open = if is_root { " open" } else { "" };
let mut rows = String::new();
for i in 0..len {
let item = arr.get(i);
rows.push_str(&format!(
"<div class=\"__pp_jv_row\">\
<span class=\"__pp_jv_key\">{i}</span>\
<span class=\"__pp_jv_colon\">:</span> {val}\
</div>",
val = build_json_view(&item, false)
));
}
return format!(
"<details class=\"__pp_jv_group\"{open}>\
<summary class=\"__pp_jv_summary\">\
<span class=\"__pp_jv_bracket\">[</span>\
<span class=\"__pp_jv_meta\">{len} items</span>\
<span class=\"__pp_jv_bracket\">]</span>\
</summary>\
<div class=\"__pp_jv_body\">{rows}</div>\
</details>"
);
}
if v.is_object() {
let obj: Object = v.clone().unchecked_into();
let keys = Object::keys(&obj);
let n = keys.length();
if n == 0 {
return r#"<span class="__pp_jv_empty">{ }</span>"#.into();
}
let open = if is_root { " open" } else { "" };
let mut rows = String::new();
for i in 0..n {
let key = keys.get(i);
let key_str = key.as_string().unwrap_or_default();
let value = js_sys::Reflect::get(&obj, &key).unwrap_or(JsValue::UNDEFINED);
rows.push_str(&format!(
"<div class=\"__pp_jv_row\">\
<span class=\"__pp_jv_key\">{k}</span>\
<span class=\"__pp_jv_colon\">:</span> {val}\
</div>",
k = escape(&key_str),
val = build_json_view(&value, false)
));
}
return format!(
"<details class=\"__pp_jv_group\"{open}>\
<summary class=\"__pp_jv_summary\">\
<span class=\"__pp_jv_bracket\">{{</span>\
<span class=\"__pp_jv_meta\">{n} keys</span>\
<span class=\"__pp_jv_bracket\">}}</span>\
</summary>\
<div class=\"__pp_jv_body\">{rows}</div>\
</details>"
);
}
r#"<span class="__pp_jv_null">?</span>"#.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_turns_reserved_html_chars_into_entities() {
assert_eq!(escape("<div>"), "<div>");
assert_eq!(escape("a & b"), "a & b");
assert_eq!(escape("\"q\""), ""q"");
assert_eq!(escape("'apos'"), "'apos'");
assert_eq!(escape("plain text"), "plain text");
}
#[test]
fn truncate_passes_through_short_input() {
let short = "abcd";
assert_eq!(truncate(short), short);
}
#[test]
fn truncate_clamps_and_inserts_middle_ellipsis_on_long_input() {
let long: String = "a".repeat(200);
let out = truncate(&long);
assert!(out.contains('…'));
assert!(out.chars().count() <= MAX_VALUE_CHARS);
}
#[test]
fn truncate_keeps_head_and_tail_visible() {
let s =
"start_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_end";
let out = truncate(s);
assert!(out.starts_with("start"));
assert!(out.ends_with("end"));
assert!(out.contains('…'));
}
#[test]
fn truncate_is_utf8_safe() {
let long: String = "日".repeat(100);
let out = truncate(&long);
assert!(out.chars().count() <= MAX_VALUE_CHARS);
assert!(out.contains('…'));
}
}