1use base64::Engine as _;
10use polars::prelude::*;
11use std::borrow::Cow;
12use std::fmt::Write as _;
13
14pub fn f64_text(v: f64) -> String {
18 if v.is_nan() {
19 return "NaN".to_string();
20 }
21 if v.is_infinite() {
22 return if v > 0.0 { "inf" } else { "-inf" }.to_string();
23 }
24 let a = v.abs();
25 if a == 0.0 || (1e-5..1e16).contains(&a) {
28 whole_reads_as_float(format!("{v}"))
29 } else {
30 format!("{v:e}")
31 }
32}
33
34fn whole_reads_as_float(mut text: String) -> String {
37 if !text.contains('.') {
38 text.push_str(".0");
39 }
40 text
41}
42
43pub fn f32_text(v: f32) -> String {
46 if v.is_nan() {
47 return "NaN".to_string();
48 }
49 if v.is_infinite() {
50 return if v > 0.0 { "inf" } else { "-inf" }.to_string();
51 }
52 let a = v.abs();
53 if a == 0.0 || (1e-5..1e16).contains(&a) {
54 whole_reads_as_float(format!("{v}"))
55 } else {
56 format!("{v:e}")
57 }
58}
59
60pub fn out_of_range(value: &AnyValue) -> Option<String> {
64 use chrono::{DateTime, NaiveDate, TimeDelta};
65 let fits = |dt: Option<DateTime<chrono::Utc>>| {
66 dt.is_some_and(|dt| {
67 dt.checked_add_signed(TimeDelta::days(1)).is_some()
68 && dt.checked_sub_signed(TimeDelta::days(1)).is_some()
69 })
70 };
71 match value {
72 AnyValue::Date(days) => {
73 let fits = days
75 .checked_add(719_163)
76 .and_then(NaiveDate::from_num_days_from_ce_opt)
77 .is_some();
78 (!fits).then(|| format!("{days} days since 1970-01-01"))
79 }
80 AnyValue::Datetime(v, unit, _) | AnyValue::DatetimeOwned(v, unit, _) => {
81 let (dt, unit) = match unit {
82 TimeUnit::Milliseconds => (DateTime::from_timestamp_millis(*v), "ms"),
83 TimeUnit::Microseconds => (DateTime::from_timestamp_micros(*v), "us"),
84 TimeUnit::Nanoseconds => (Some(DateTime::from_timestamp_nanos(*v)), "ns"),
85 };
86 (!fits(dt)).then(|| format!("{v} {unit} since 1970-01-01 UTC"))
87 }
88 AnyValue::Time(ns) => {
89 (!(0..NANOS_PER_DAY).contains(ns)).then(|| format!("{ns} ns since midnight"))
90 }
91 _ => None,
92 }
93}
94
95const NANOS_PER_DAY: i64 = 86_400_000_000_000;
96
97pub fn str_value<'a>(value: &AnyValue<'a>) -> Cow<'a, str> {
101 match past_calendar_text(value) {
102 Some(text) => Cow::Owned(text),
103 None => value.str_value(),
104 }
105}
106
107pub fn list_preview(items: &Series) -> String {
110 const SHOWN: usize = 10;
111 let mut text = String::from("[");
112 for (i, item) in items.iter().take(SHOWN).enumerate() {
113 if i > 0 {
114 text.push_str(", ");
115 }
116 text.push_str(&str_value(&item));
117 }
118 if items.len() > SHOWN {
119 let _ = write!(text, "...] ({} items)", items.len());
120 } else {
121 text.push(']');
122 }
123 text
124}
125
126pub fn past_calendar_text(value: &AnyValue) -> Option<String> {
130 if let Some(text) = out_of_range(value) {
131 return Some(text);
132 }
133 nested_out_of_range(value).then(|| nested_compact(value, CELL_PREVIEW_BYTES).text)
134}
135
136fn nested_out_of_range(value: &AnyValue) -> bool {
139 let holds = |fields: &[Field]| fields.iter().any(|f| holds_calendar(f.dtype()));
140 match value {
141 AnyValue::List(s) | AnyValue::Array(s, _) => series_out_of_range(s),
142 AnyValue::Struct(_, _, fields) if !holds(fields) => false,
143 AnyValue::StructOwned(payload) if !holds(&payload.1) => false,
144 AnyValue::Struct(..) | AnyValue::StructOwned(_) => value
145 ._iter_struct_av()
146 .any(|field| out_of_range(&field).is_some() || nested_out_of_range(&field)),
147 _ => false,
148 }
149}
150
151fn series_out_of_range(s: &Series) -> bool {
154 if !holds_calendar(s.dtype()) {
155 return false;
156 }
157 match s.dtype() {
158 DataType::Date | DataType::Datetime(..) | DataType::Time => {
159 let Ok(stored) = s.to_physical_repr().cast(&DataType::Int64) else {
160 return false;
161 };
162 let Ok(stored) = stored.i64() else {
163 return false;
164 };
165 [stored.min(), stored.max()]
166 .into_iter()
167 .flatten()
168 .any(|v| stored_out_of_range(s.dtype(), v).is_some())
169 }
170 _ => s.iter().any(|item| nested_out_of_range(&item)),
171 }
172}
173
174pub fn stored_out_of_range(dtype: &DataType, stored: i64) -> Option<String> {
176 let value = match dtype {
177 DataType::Date => AnyValue::Date(i32::try_from(stored).ok()?),
178 DataType::Datetime(unit, _) => AnyValue::Datetime(stored, *unit, None),
179 DataType::Time => AnyValue::Time(stored),
180 _ => return None,
181 };
182 out_of_range(&value)
183}
184
185pub fn calendar_without_out_of_range(series: &Series) -> PolarsResult<Option<Series>> {
189 let dtype = series.dtype();
190 if !matches!(
191 dtype,
192 DataType::Date | DataType::Datetime(..) | DataType::Time
193 ) {
194 return Ok(None);
195 }
196 let stored = series.to_physical_repr().cast(&DataType::Int64)?;
197 let stored = stored.i64()?;
198 let past = |v: i64| stored_out_of_range(dtype, v).is_some();
199 if ![stored.min(), stored.max()].into_iter().flatten().any(past) {
200 return Ok(None);
201 }
202 let kept = stored.apply(|v| v.filter(|v| !past(*v)));
203 Ok(Some(
204 kept.into_series()
205 .cast(dtype)?
206 .with_name(series.name().clone()),
207 ))
208}
209
210pub fn holds_calendar(dtype: &DataType) -> bool {
212 match dtype {
213 DataType::Date | DataType::Datetime(..) | DataType::Time => true,
214 DataType::List(inner) | DataType::Array(inner, _) => holds_calendar(inner),
215 DataType::Struct(fields) => fields.iter().any(|f| holds_calendar(f.dtype())),
216 _ => false,
217 }
218}
219
220fn datetime_text(v: i64, unit: TimeUnit, zone: Option<&TimeZone>) -> String {
224 let digits = match unit {
225 TimeUnit::Milliseconds => "%.3f",
226 TimeUnit::Microseconds => "%.6f",
227 TimeUnit::Nanoseconds => "%.9f",
228 };
229 let format = match zone {
230 Some(_) => format!("%Y-%m-%d %H:%M:%S{digits} %:z"),
231 None => format!("%Y-%m-%d %H:%M:%S{digits}"),
232 };
233 let ca = Int64Chunked::from_slice(PlSmallStr::EMPTY, &[v]).into_datetime(unit, zone.cloned());
234 ca.to_string(&format)
235 .ok()
236 .and_then(|s| s.get(0).map(str::to_string))
237 .unwrap_or_else(|| AnyValue::Datetime(v, unit, zone).str_value().into_owned())
238}
239
240pub fn base64_text(bytes: &[u8]) -> String {
242 base64::engine::general_purpose::STANDARD.encode(bytes)
243}
244
245pub fn value_text(value: &AnyValue) -> String {
248 if let Some(text) = out_of_range(value) {
249 return text;
250 }
251 match value {
252 AnyValue::Null => String::new(),
253 AnyValue::Float64(v) => f64_text(*v),
254 AnyValue::Float32(v) => f32_text(*v),
255 AnyValue::Float16(v) => f32_text(f32::from(*v)),
256 AnyValue::Datetime(v, unit, zone) => datetime_text(*v, *unit, *zone),
257 AnyValue::DatetimeOwned(v, unit, zone) => datetime_text(*v, *unit, zone.as_deref()),
258 AnyValue::Duration(v, unit) => {
260 let mut out = String::new();
261 crate::export::nested_json::duration_iso(*v, *unit, &mut out);
262 out
263 }
264 AnyValue::Binary(bytes) => base64_text(bytes),
265 AnyValue::BinaryOwned(bytes) => base64_text(bytes),
266 AnyValue::List(_)
267 | AnyValue::Array(..)
268 | AnyValue::Struct(..)
269 | AnyValue::StructOwned(_) => {
270 let mut out = String::new();
271 write_nested(value, &mut out, None, 0, usize::MAX);
272 out
273 }
274 v => v.str_value().into_owned(),
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct Bounded {
283 pub text: String,
284 pub cut: bool,
285}
286
287pub fn nested_pretty(value: &AnyValue, budget: usize) -> Bounded {
291 let mut text = String::new();
292 let cut = !write_nested(value, &mut text, Some(0), 0, budget);
293 Bounded { text, cut }
294}
295
296pub fn nested_compact(value: &AnyValue, budget: usize) -> Bounded {
299 let mut text = String::new();
300 let cut = !write_nested(value, &mut text, None, 0, budget);
301 Bounded { text, cut }
302}
303
304pub fn is_nested_value(value: &AnyValue) -> bool {
306 matches!(
307 value,
308 AnyValue::List(_) | AnyValue::Array(..) | AnyValue::Struct(..) | AnyValue::StructOwned(_)
309 )
310}
311
312pub fn nested_len(value: &AnyValue) -> Option<usize> {
314 match value {
315 AnyValue::List(s) | AnyValue::Array(s, _) => Some(s.len()),
316 AnyValue::Struct(_, _, fields) => Some(fields.len()),
317 AnyValue::StructOwned(payload) => Some(payload.1.len()),
318 _ => None,
319 }
320}
321
322fn write_nested(
325 value: &AnyValue,
326 out: &mut String,
327 indent: Option<usize>,
328 depth: usize,
329 budget: usize,
330) -> bool {
331 if out.len() >= budget {
332 return false;
333 }
334 match value {
337 AnyValue::List(s) | AnyValue::Array(s, _) => write_items(
338 s.iter().map(|v| (None, v)),
339 s.len(),
340 ('[', ']'),
341 out,
342 indent,
343 depth,
344 budget,
345 ),
346 AnyValue::Struct(_, _, fields) => write_items(
347 fields
348 .iter()
349 .map(|f| Some(f.name().as_str()))
350 .zip(value._iter_struct_av()),
351 fields.len(),
352 ('{', '}'),
353 out,
354 indent,
355 depth,
356 budget,
357 ),
358 AnyValue::StructOwned(payload) => write_items(
359 payload
360 .1
361 .iter()
362 .map(|f| Some(f.name().as_str()))
363 .zip(payload.0.iter().cloned()),
364 payload.1.len(),
365 ('{', '}'),
366 out,
367 indent,
368 depth,
369 budget,
370 ),
371 AnyValue::String(s) => write_quoted(s, out, budget),
374 AnyValue::StringOwned(s) => write_quoted(s, out, budget),
375 AnyValue::Binary(b) => write_bytes(b, out, budget),
376 AnyValue::BinaryOwned(b) => write_bytes(b, out, budget),
377 v => {
378 out.push_str(&json_scalar(v));
379 true
380 }
381 }
382}
383
384fn write_items<'n, 'v>(
387 items: impl Iterator<Item = (Option<&'n str>, AnyValue<'v>)>,
388 len: usize,
389 (open, close): (char, char),
390 out: &mut String,
391 indent: Option<usize>,
392 depth: usize,
393 budget: usize,
394) -> bool {
395 let pad = |out: &mut String, depth: usize| {
396 if indent.is_some() {
397 out.push('\n');
398 out.extend(std::iter::repeat_n(" ", depth));
399 }
400 };
401 out.push(open);
402 if len == 0 {
403 out.push(close);
404 return true;
405 }
406 for (i, (name, item)) in items.enumerate() {
407 if i > 0 {
408 out.push(',');
409 if indent.is_none() {
410 out.push(' ');
411 }
412 }
413 pad(out, depth + 1);
414 if let Some(name) = name {
415 out.push_str(&json_string(name));
416 out.push_str(": ");
417 }
418 if !write_nested(&item, out, indent, depth + 1, budget) {
419 return false;
420 }
421 if out.len() >= budget && i + 1 < len {
422 return false;
423 }
424 }
425 pad(out, depth);
426 out.push(close);
427 true
428}
429
430fn write_quoted(s: &str, out: &mut String, budget: usize) -> bool {
433 out.push('"');
434 let head = prefix(s, budget.saturating_sub(out.len()));
435 escape_into(head, out);
436 if head.len() < s.len() {
437 return false;
438 }
439 out.push('"');
440 true
441}
442
443fn write_bytes(bytes: &[u8], out: &mut String, budget: usize) -> bool {
445 let room = budget.saturating_sub(out.len() + 1) / 4 * 3;
448 let head = &bytes[..bytes.len().min(room)];
449 out.push('"');
450 out.push_str(&base64_text(head));
451 if head.len() < bytes.len() {
452 return false;
453 }
454 out.push('"');
455 true
456}
457
458fn json_scalar(value: &AnyValue) -> String {
461 match value {
462 AnyValue::Null => "null".to_string(),
463 AnyValue::Boolean(b) => b.to_string(),
464 AnyValue::String(s) => json_string(s),
465 AnyValue::StringOwned(s) => json_string(s),
466 v if v.is_primitive_numeric() || matches!(v, AnyValue::Decimal(..)) => value_text(v),
467 v => json_string(&value_text(v)),
468 }
469}
470
471fn json_string(s: &str) -> String {
474 let mut out = String::with_capacity(s.len() + 2);
475 out.push('"');
476 escape_into(s, &mut out);
477 out.push('"');
478 out
479}
480
481fn invisible(c: char) -> bool {
486 c.is_control()
487 || matches!(
488 c,
489 '\u{a0}'
490 | '\u{ad}'
491 | '\u{34f}'
492 | '\u{61c}'
493 | '\u{115f}'..='\u{1160}'
494 | '\u{1680}'
495 | '\u{17b4}'..='\u{17b5}'
496 | '\u{180b}'..='\u{180f}'
497 | '\u{2000}'..='\u{200f}'
498 | '\u{2028}'..='\u{202f}'
499 | '\u{205f}'..='\u{206f}'
500 | '\u{3000}'
501 | '\u{3164}'
502 | '\u{fe00}'..='\u{fe0f}'
503 | '\u{feff}'
504 | '\u{ffa0}'
505 | '\u{fff0}'..='\u{fffb}'
506 | '\u{1bca0}'..='\u{1bca3}'
507 | '\u{1d173}'..='\u{1d17a}'
508 | '\u{e0000}'..='\u{e0fff}'
509 )
510}
511
512pub fn marked(c: char) -> bool {
516 c.is_control()
517 || matches!(
518 c,
519 '\u{61c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}'
520 )
521}
522
523fn escape_into(s: &str, out: &mut String) {
524 for c in s.chars() {
525 escape_char(c, out);
526 }
527}
528
529pub fn escape_char(c: char, out: &mut String) {
532 match c {
533 '\\' => out.push_str("\\\\"),
534 '"' => out.push_str("\\\""),
535 '\n' => out.push_str("\\n"),
536 '\r' => out.push_str("\\r"),
537 '\t' => out.push_str("\\t"),
538 '\0' => out.push_str("\\0"),
539 c if invisible(c) => {
540 let _ = write!(out, "\\u{{{:x}}}", c as u32);
541 }
542 c => out.push(c),
543 }
544}
545
546pub fn escaped(s: &str) -> String {
551 json_string(s)
552}
553
554pub fn escaped_bytes(bytes: &[u8]) -> String {
556 let mut out = String::with_capacity(bytes.len() + 3);
557 out.push_str("b\"");
558 for &b in bytes {
559 match b {
560 b'\\' => out.push_str("\\\\"),
561 b'"' => out.push_str("\\\""),
562 b'\n' => out.push_str("\\n"),
563 b'\r' => out.push_str("\\r"),
564 b'\t' => out.push_str("\\t"),
565 0x20..=0x7e => out.push(b as char),
566 b => {
567 let _ = write!(out, "\\x{b:02x}");
568 }
569 }
570 }
571 out.push('"');
572 out
573}
574
575fn has_marked(s: &str) -> bool {
579 s.bytes().any(|b| b < 0x20 || b == 0x7f)
580 || (s.bytes().any(|b| matches!(b, 0xc2 | 0xd8 | 0xe2)) && s.chars().any(marked))
581}
582
583pub const CELL_PREVIEW_BYTES: usize = 4096;
585
586pub fn cell_preview(s: &str, g: &crate::glyphs::Glyphs) -> String {
590 let head = prefix(s, CELL_PREVIEW_BYTES);
591 let mut text = preview(head, g).into_owned();
592 if head.len() < s.len() {
593 text.push_str(g.ellipsis);
594 }
595 text
596}
597
598pub fn preview<'a>(s: &'a str, g: &crate::glyphs::Glyphs) -> Cow<'a, str> {
602 if !has_marked(s) {
603 return Cow::Borrowed(s);
604 }
605 let mut out = String::with_capacity(s.len());
606 let mut chars = s.chars().peekable();
607 while let Some(c) = chars.next() {
608 match c {
609 '\r' if chars.peek() == Some(&'\n') => {
610 chars.next();
611 out.push_str(g.newline_mark);
612 }
613 '\n' => out.push_str(g.newline_mark),
614 '\t' => out.push_str(g.tab_mark),
615 c if marked(c) => out.push_str(g.control_mark),
616 c => out.push(c),
617 }
618 }
619 Cow::Owned(out)
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
625pub struct TextFacts {
626 pub chars: usize,
627 pub lines: usize,
628 pub leading_spaces: usize,
629 pub trailing_spaces: usize,
630}
631
632pub fn text_facts(s: &str) -> TextFacts {
633 let lines = if s.is_empty() {
634 0
635 } else {
636 s.split('\n').count()
637 };
638 TextFacts {
639 chars: s.chars().count(),
640 lines,
641 leading_spaces: s.chars().take_while(|c| c.is_whitespace()).count(),
642 trailing_spaces: if s.chars().all(char::is_whitespace) {
643 0
644 } else {
645 s.chars().rev().take_while(|c| c.is_whitespace()).count()
646 },
647 }
648}
649
650pub fn prefix(s: &str, budget: usize) -> &str {
653 if s.len() <= budget {
654 return s;
655 }
656 let mut end = budget;
657 while !s.is_char_boundary(end) {
658 end -= 1;
659 }
660 &s[..end]
661}
662
663pub fn copy_text(column: &Column) -> PolarsResult<String> {
666 let value = column.get(0)?;
667 if is_nested_value(&value) {
668 let json = crate::export::nested_json::column_as_json(column)?;
669 return Ok(match json.get(0)? {
670 AnyValue::Null => String::new(),
671 v => v.str_value().into_owned(),
672 });
673 }
674 Ok(value_text(&value))
675}
676
677pub fn copy_len_floor(value: &AnyValue, stop: usize) -> usize {
683 match value {
684 AnyValue::String(s) => s.len(),
685 AnyValue::StringOwned(s) => s.len(),
686 AnyValue::Binary(b) => crate::clipboard::base64_len(b.len()),
687 AnyValue::BinaryOwned(b) => crate::clipboard::base64_len(b.len()),
688 v if is_nested_value(v) => json_len_floor(v, 0, stop),
689 v => value_text(v).len(),
691 }
692}
693
694fn json_len_floor(value: &AnyValue, so_far: usize, stop: usize) -> usize {
696 if so_far > stop {
697 return so_far;
698 }
699 let punctuation = |len: usize| 2 + len.saturating_sub(1);
701 match value {
702 AnyValue::List(s) | AnyValue::Array(s, _) => {
703 let mut n = so_far + punctuation(s.len());
704 for item in s.iter() {
705 if n > stop {
706 break;
707 }
708 n = json_len_floor(&item, n, stop);
709 }
710 n
711 }
712 AnyValue::Struct(_, _, fields) => {
713 let mut n = so_far + punctuation(fields.len());
714 for (field, item) in fields.iter().zip(value._iter_struct_av()) {
715 if n > stop {
716 break;
717 }
718 n = json_len_floor(&item, n + field.name().len() + 3, stop);
720 }
721 n
722 }
723 AnyValue::StructOwned(payload) => {
724 let mut n = so_far + punctuation(payload.1.len());
725 for (field, item) in payload.1.iter().zip(&payload.0) {
726 if n > stop {
727 break;
728 }
729 n = json_len_floor(item, n + field.name().len() + 3, stop);
730 }
731 n
732 }
733 AnyValue::String(s) => so_far + s.len() + 2,
734 AnyValue::StringOwned(s) => so_far + s.len() + 2,
735 AnyValue::Binary(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
736 AnyValue::BinaryOwned(b) => so_far + crate::clipboard::base64_len(b.len()) + 2,
737 _ => so_far + 1,
739 }
740}
741
742#[cfg(test)]
743mod tests;