use crate::console::{Console, ConsoleOptions, Renderable};
#[cfg(feature = "json")]
use crate::highlighter::JSONHighlighter;
use crate::highlighter::{Highlighter, ReprHighlighter};
use crate::measure::Measurement;
use crate::segment::Segment;
use crate::style::Style;
use crate::text::{OverflowMethod, Text};
#[derive(Clone, Debug)]
pub struct Pretty {
pub text: Text,
pub no_wrap: bool,
pub overflow: Option<OverflowMethod>,
pub indent_guides: bool,
pub indent_size: usize,
pub max_length: Option<usize>,
pub max_string: Option<usize>,
pub expand_all: bool,
pub type_annotation: bool,
}
impl Pretty {
#[allow(clippy::should_implement_trait)]
pub fn from_str(value: &str) -> Self {
let hl = ReprHighlighter::new();
let text = hl.apply(value);
Pretty {
text,
no_wrap: false,
overflow: None,
indent_guides: true,
indent_size: 4,
max_length: None,
max_string: None,
expand_all: false,
type_annotation: false,
}
}
pub fn from_debug<T: std::fmt::Debug>(value: &T) -> Self {
let formatted = format!("{:#?}", value);
let hl = ReprHighlighter::new();
let text = hl.apply(&formatted);
Pretty {
text,
no_wrap: false,
overflow: None,
indent_guides: true,
indent_size: 4,
max_length: None,
max_string: None,
expand_all: false,
type_annotation: false,
}
}
#[cfg(feature = "json")]
pub fn from_json(value: &serde_json::Value) -> Self {
let formatted = serde_json::to_string_pretty(value).unwrap_or_default();
let hl = JSONHighlighter::new();
let text = hl.apply(&formatted);
Pretty {
text,
no_wrap: true,
overflow: None,
indent_guides: true,
indent_size: 2, max_length: None,
max_string: None,
expand_all: false,
type_annotation: false,
}
}
#[must_use]
pub fn with_indent_guides(mut self, guides: bool) -> Self {
self.indent_guides = guides;
self
}
#[must_use]
pub fn with_indent_size(mut self, size: usize) -> Self {
self.indent_size = size;
self
}
#[must_use]
pub fn with_no_wrap(mut self, no_wrap: bool) -> Self {
self.no_wrap = no_wrap;
self
}
#[must_use]
pub fn with_overflow(mut self, overflow: OverflowMethod) -> Self {
self.overflow = Some(overflow);
self
}
#[must_use]
pub fn with_max_length(mut self, max_length: usize) -> Self {
self.max_length = Some(max_length);
self
}
#[must_use]
pub fn with_max_string(mut self, max_string: usize) -> Self {
self.max_string = Some(max_string);
self
}
#[must_use]
pub fn with_expand_all(mut self, expand_all: bool) -> Self {
self.expand_all = expand_all;
self
}
#[must_use]
pub fn with_type_annotation(mut self, annotation: bool) -> Self {
self.type_annotation = annotation;
self
}
#[cfg(feature = "json")]
#[must_use]
pub fn rebuild_json(mut self, value: &serde_json::Value) -> Self {
let formatted = format_json_value(
value,
0,
self.indent_size,
self.max_length,
self.max_string,
self.expand_all,
);
let hl = JSONHighlighter::new();
self.text = hl.apply(&formatted);
self
}
#[must_use]
pub fn rebuild_debug<T: std::fmt::Debug>(mut self, value: &T) -> Self {
let formatted = format!("{:#?}", value);
let processed = apply_debug_params(&formatted, self.max_length, self.max_string);
let hl = ReprHighlighter::new();
self.text = hl.apply(&processed);
self
}
fn apply_indent_guides(&self) -> Text {
if !self.indent_guides {
return self.text.clone();
}
static GUIDE_STYLE: std::sync::LazyLock<Style> =
std::sync::LazyLock::new(|| Style::parse("dim green"));
self.text
.with_indent_guides(Some(self.indent_size), '\u{2502}', GUIDE_STYLE.clone())
}
pub fn measure(&self) -> Measurement {
self.text.measure()
}
}
impl Renderable for Pretty {
fn gilt_console(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
let mut text = self.apply_indent_guides();
if self.no_wrap {
text.no_wrap = Some(true);
}
if let Some(overflow) = self.overflow {
text.overflow = Some(overflow);
}
if self.type_annotation {
let type_name = infer_type_name(self.text.plain());
let annotation_style = Style::parse("dim italic");
use crate::text::TextPart;
text = Text::assemble(
&[
TextPart::Styled(format!("({}) ", type_name), annotation_style),
TextPart::Inner(text),
],
Style::null(),
);
}
text.gilt_console(console, options)
}
}
#[cfg(feature = "json")]
fn format_json_value(
value: &serde_json::Value,
depth: usize,
indent_size: usize,
max_length: Option<usize>,
max_string: Option<usize>,
expand_all: bool,
) -> String {
match value {
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => {
let truncated = truncate_string(s, max_string);
format!("\"{}\"", escape_json_string(&truncated))
}
serde_json::Value::Array(arr) => {
if arr.is_empty() {
return "[]".to_string();
}
format_json_array(arr, depth, indent_size, max_length, max_string, expand_all)
}
serde_json::Value::Object(obj) => {
if obj.is_empty() {
return "{}".to_string();
}
format_json_object(obj, depth, indent_size, max_length, max_string, expand_all)
}
}
}
#[cfg(feature = "json")]
fn format_json_array(
arr: &[serde_json::Value],
depth: usize,
indent_size: usize,
max_length: Option<usize>,
max_string: Option<usize>,
expand_all: bool,
) -> String {
let total = arr.len();
let display_count = match max_length {
Some(max) => max.min(total),
None => total,
};
let truncated_count = total - display_count;
let items: Vec<String> = arr[..display_count]
.iter()
.map(|v| {
format_json_value(
v,
depth + 1,
indent_size,
max_length,
max_string,
expand_all,
)
})
.collect();
let should_expand = if expand_all {
true
} else {
let compact = items.join(", ");
compact.len() > 80 || items.iter().any(|s| s.contains('\n'))
};
if should_expand {
let indent = " ".repeat(indent_size * (depth + 1));
let closing_indent = " ".repeat(indent_size * depth);
let mut parts: Vec<String> = items
.iter()
.map(|item| format!("{}{}", indent, item))
.collect();
if truncated_count > 0 {
parts.push(format!("{}... +{} more", indent, truncated_count));
}
format!("[\n{}\n{}]", parts.join(",\n"), closing_indent)
} else {
let mut result = items.join(", ");
if truncated_count > 0 {
result.push_str(&format!(", ... +{} more", truncated_count));
}
format!("[{}]", result)
}
}
#[cfg(feature = "json")]
fn format_json_object(
obj: &serde_json::Map<String, serde_json::Value>,
depth: usize,
indent_size: usize,
max_length: Option<usize>,
max_string: Option<usize>,
expand_all: bool,
) -> String {
let entries: Vec<(&String, &serde_json::Value)> = obj.iter().collect();
let total = entries.len();
let display_count = match max_length {
Some(max) => max.min(total),
None => total,
};
let truncated_count = total - display_count;
let items: Vec<String> = entries[..display_count]
.iter()
.map(|(k, v)| {
let key_str = format!("\"{}\"", escape_json_string(k));
let val_str = format_json_value(
v,
depth + 1,
indent_size,
max_length,
max_string,
expand_all,
);
format!("{}: {}", key_str, val_str)
})
.collect();
let should_expand = if expand_all {
true
} else {
let compact = items.join(", ");
compact.len() > 80 || items.iter().any(|s| s.contains('\n'))
};
if should_expand {
let indent = " ".repeat(indent_size * (depth + 1));
let closing_indent = " ".repeat(indent_size * depth);
let mut parts: Vec<String> = items
.iter()
.map(|item| format!("{}{}", indent, item))
.collect();
if truncated_count > 0 {
parts.push(format!("{}... +{} more", indent, truncated_count));
}
format!("{{\n{}\n{}}}", parts.join(",\n"), closing_indent)
} else {
let mut result = items.join(", ");
if truncated_count > 0 {
result.push_str(&format!(", ... +{} more", truncated_count));
}
format!("{{{}}}", result)
}
}
#[cfg(feature = "json")]
fn truncate_string(s: &str, max_string: Option<usize>) -> String {
match max_string {
Some(max) if s.chars().count() > max => {
let truncated: String = s.chars().take(max).collect();
let remaining = s.chars().count() - max;
format!("{}+{}", truncated, remaining)
}
_ => s.to_string(),
}
}
fn infer_type_name(text: &str) -> &'static str {
let trimmed = text.trim();
if trimmed.is_empty() {
return "empty";
}
match trimmed.as_bytes()[0] {
b'{' => "object",
b'[' => "array",
b'"' => "str",
b't' | b'f' if trimmed == "true" || trimmed == "false" => "bool",
b'n' if trimmed == "null" => "null",
b'0'..=b'9' | b'-' => "number",
_ => {
if trimmed.contains(' ') && trimmed.contains('{') {
"struct"
} else {
"str"
}
}
}
}
#[cfg(feature = "json")]
fn escape_json_string(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
c if c.is_control() => {
result.push_str(&format!("\\u{:04x}", c as u32));
}
_ => result.push(c),
}
}
result
}
fn apply_debug_params(
formatted: &str,
max_length: Option<usize>,
max_string: Option<usize>,
) -> String {
let mut result = formatted.to_string();
if let Some(max_s) = max_string {
result = truncate_debug_strings(&result, max_s);
}
if let Some(max_l) = max_length {
result = truncate_debug_collections(&result, max_l);
}
result
}
fn truncate_debug_strings(s: &str, max_string: usize) -> String {
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '"' {
result.push('"');
i += 1;
let mut content = String::new();
while i < chars.len() && chars[i] != '"' {
if chars[i] == '\\' && i + 1 < chars.len() {
content.push(chars[i]);
content.push(chars[i + 1]);
i += 2;
} else {
content.push(chars[i]);
i += 1;
}
}
let char_count = content.chars().count();
if char_count > max_string {
let truncated: String = content.chars().take(max_string).collect();
let remaining = char_count - max_string;
result.push_str(&truncated);
result.push_str(&format!("+{}", remaining));
} else {
result.push_str(&content);
}
if i < chars.len() {
result.push('"'); i += 1;
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn truncate_debug_collections(s: &str, max_length: usize) -> String {
let lines: Vec<&str> = s.lines().collect();
if lines.len() <= 1 {
return truncate_inline_collection(s, max_length);
}
truncate_multiline_collection(&lines, max_length)
}
fn truncate_inline_collection(s: &str, max_length: usize) -> String {
if let Some(start) = s.find('[') {
if let Some(end) = s.rfind(']') {
if start < end {
let inner = &s[start + 1..end];
let truncated = truncate_comma_items(inner, max_length);
return format!("{}[{}]{}", &s[..start], truncated, &s[end + 1..]);
}
}
}
s.to_string()
}
fn truncate_comma_items(inner: &str, max_length: usize) -> String {
let items: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
let total = items.len();
if total <= max_length {
return inner.to_string();
}
let kept: Vec<&str> = items[..max_length].to_vec();
let remaining = total - max_length;
format!("{}, ... +{} more", kept.join(", "), remaining)
}
fn truncate_multiline_collection(lines: &[&str], max_length: usize) -> String {
let mut result = Vec::new();
let mut depth = 0i32;
let mut item_count = 0usize;
let mut truncated = false;
let mut skipped_count = 0usize;
let mut inside_collection = false;
for &line in lines {
let trimmed = line.trim();
let opens = trimmed.chars().filter(|&c| c == '[' || c == '{').count() as i32;
let closes = trimmed.chars().filter(|&c| c == ']' || c == '}').count() as i32;
if depth == 0 && opens > 0 {
inside_collection = true;
item_count = 0;
truncated = false;
skipped_count = 0;
depth += opens - closes;
result.push(line.to_string());
continue;
}
if inside_collection && depth == 1 && (closes > 0 && opens == 0) {
if skipped_count > 0 {
let indent_len = line.len() - line.trim_start().len();
let pad = " ".repeat(indent_len + 4);
result.push(format!("{}... +{} more,", pad, skipped_count));
}
depth += opens - closes;
if depth <= 0 {
inside_collection = false;
}
result.push(line.to_string());
continue;
}
depth += opens - closes;
if inside_collection && !truncated {
if trimmed.ends_with(',') || closes > 0 {
item_count += 1;
}
if item_count > max_length {
truncated = true;
skipped_count += 1;
continue;
}
result.push(line.to_string());
} else if truncated {
skipped_count += 1;
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
impl std::fmt::Display for Pretty {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut console = Console::builder()
.width(f.width().unwrap_or(80))
.force_terminal(true)
.no_color(true)
.build();
console.begin_capture();
console.print(self);
let output = console.end_capture();
write!(f, "{}", output.trim_end_matches('\n'))
}
}
#[cfg(test)]
#[path = "pretty_tests.rs"]
mod tests;