Skip to main content

datui_lib/
exact.rs

1//! A stored value as text, exactly, apart from how the table previews it.
2//!
3//! The table's preview rounds floats, groups digits and cuts long text to fit a
4//! cell; a copy or the inspector gives back what is stored. A float is the
5//! shortest decimal that reads back to the same bits, never Polars' compact
6//! display; a datetime carries every digit of its unit and its zone's offset.
7//! Exact means the stored value: a CSV's `1.50` was stored as `1.5`.
8
9use base64::Engine as _;
10use polars::prelude::*;
11use std::borrow::Cow;
12use std::fmt::Write as _;
13
14/// The shortest decimal that parses back to `v`: plain notation over the
15/// magnitudes people read as plain numbers, exponent notation outside them.
16/// NaN, the infinities and negative zero are spelled out rather than lost.
17pub 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    // Rust's Display and LowerExp are both shortest round-trip; Display alone
26    // would write 1e300 as 301 digits.
27    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
34/// `1.0`, not `1`: a whole float keeps its point, as Polars writes it, so it
35/// does not read as an integer. Parses back the same.
36fn whole_reads_as_float(mut text: String) -> String {
37    if !text.contains('.') {
38        text.push_str(".0");
39    }
40    text
41}
42
43/// [`f64_text`] for an `f32`, shortest at the `f32`'s own precision: widened
44/// to `f64` first, `0.1f32` would read `0.10000000149011612`.
45pub 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
60/// A date, datetime or time outside the calendar's range, as its stored number:
61/// Polars panics formatting one (a sentinel like `i64::MIN` microseconds is real
62/// data). A day of margin either side leaves room for a zone's offset.
63pub 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            // Days from 0001-01-01 to the epoch, as Polars counts them.
74            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
97/// [`AnyValue::str_value`], which panics on a date past the calendar, with such a
98/// value written as its stored number, and a list or struct holding one written
99/// as [`nested_compact`] does.
100pub 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
107/// How the table previews a list cell: its first ten items, and how many there
108/// are when that is not all of them (`[a, b...] (12 items)`).
109pub 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
126/// The text [`str_value`] gives a value Polars panics formatting: a date past
127/// the calendar, or a list or struct holding one. `None` for any other value,
128/// which Polars formats as usual.
129pub 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
136/// Whether a list, array or struct holds a value [`out_of_range`] names. Only one
137/// whose type holds a date, datetime or time is looked into.
138fn 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
151/// Whether any value of `s` is one [`out_of_range`] names: a flat column by its
152/// least and greatest stored number, a nested one item by item.
153fn 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
174/// [`out_of_range`] for a value of `dtype` stored as the number `stored`.
175pub 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
185/// A date, datetime or time column with each value [`out_of_range`] names as
186/// null, for the Polars operations that overflow or panic on one. `None` when
187/// it holds none, the common case, which costs a min and a max.
188pub 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
210/// Whether `dtype` is, or holds, a date, datetime or time.
211pub 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
220/// A datetime with every digit its unit stores, and the zone's offset when it
221/// has a zone. Polars' own display drops trailing zeros and names the zone by
222/// abbreviation, which several zones share.
223fn 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
240/// Bytes as base64, the spelling every copy and export of a binary uses.
241pub fn base64_text(bytes: &[u8]) -> String {
242    base64::engine::general_purpose::STANDARD.encode(bytes)
243}
244
245/// A value as exact text. A null is empty, as in an export; a list or struct
246/// is compact JSON-like text over exact scalars; bytes are base64.
247pub 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        // ISO 8601 seconds, as a CSV export and every other copy write it.
259        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        // Integers, booleans, strings, dates, times, decimals and
275        // categories: Polars' text for these is already the whole value.
276        v => v.str_value().into_owned(),
277    }
278}
279
280/// Text cut to a byte budget, and whether anything was cut.
281#[derive(Debug, Clone, PartialEq, Eq)]
282pub struct Bounded {
283    pub text: String,
284    pub cut: bool,
285}
286
287/// A list, array or struct laid out one item per line, indented, stopping once
288/// `budget` bytes are written. Scalars inside are exact; text is quoted and
289/// escaped so an item's edges are visible.
290pub 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
296/// A list, array or struct on one line, stopping once `budget` bytes are
297/// written: for a preview, which shows only the start.
298pub 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
304/// Whether `value` is a list, array or struct.
305pub fn is_nested_value(value: &AnyValue) -> bool {
306    matches!(
307        value,
308        AnyValue::List(_) | AnyValue::Array(..) | AnyValue::Struct(..) | AnyValue::StructOwned(_)
309    )
310}
311
312/// How many items a list or array holds, or fields a struct has.
313pub 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
322/// Write `value` into `out`. `indent` is the current depth when laid out one
323/// item per line, `None` for compact. Returns false once the budget ran out.
324fn 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    // Items are taken one at a time: a million-item list stops at the budget
335    // rather than being turned into a million values first.
336    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        // Text and bytes are cut at the budget too: one huge string in a list is
372        // as costly as a huge cell.
373        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
384/// The items of a list, array or struct between `open` and `close`, each named
385/// when a struct's. False once the budget ran out.
386fn 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
430/// Text quoted and escaped, as much of it as fits the budget. False when cut,
431/// and then with no closing quote, so it does not read as the whole value.
432fn 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
443/// Bytes as quoted base64, as much as fits the budget. False when cut.
444fn write_bytes(bytes: &[u8], out: &mut String, budget: usize) -> bool {
445    // Base64 writes four characters per three bytes; whole groups keep the
446    // part that is shown decodable.
447    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
458/// A scalar as it reads inside a list or struct: text quoted and escaped,
459/// numbers exact, a null `null`.
460fn 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
471/// `s` in double quotes with JSON's escapes, and the invisible characters
472/// [`escaped`] spells out.
473fn 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
481/// Whether `c` draws nothing, or nothing a reader can tell from a space, so the
482/// escaped view spells it out: controls, Unicode's default-ignorable characters
483/// (zero-width and direction marks, the soft hyphen, variation selectors, tags,
484/// the byte-order mark) and the spaces other than U+0020.
485fn 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
512/// Whether a one-line cell draws `c` as a mark rather than as itself: a control
513/// character, which a cell cannot draw, or a direction control, which a terminal
514/// that lays out bidirectional text would apply to the rest of the row.
515pub 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
529/// One character of [`escaped`]'s literal, without the quotes around it: the
530/// inspector escapes a long value a piece at a time.
531pub 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
546/// `s` as a quoted literal: backslash, quote, line breaks, tabs and every
547/// invisible character written as an escape, so a literal `\n` in the data
548/// (`"\\n"`) never reads as a line break, and leading or trailing spaces and
549/// an empty string show by the quotes around them.
550pub fn escaped(s: &str) -> String {
551    json_string(s)
552}
553
554/// Bytes as a quoted literal: printable ASCII as itself, the rest as `\xNN`.
555pub 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
575/// Whether one-line text holds a character [`marked`] in a cell. Bytes first:
576/// C1 controls start with 0xc2, U+061C with 0xd8, the other direction controls
577/// with 0xe2.
578fn 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
583/// Bytes of a value a table cell previews: more than any terminal row draws.
584pub const CELL_PREVIEW_BYTES: usize = 4096;
585
586/// [`preview`] of the start of `s`, ending in the ellipsis when cut: a cell
587/// draws only its start, and measuring a huge value whole every frame costs
588/// what the value costs.
589pub 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
598/// One-line preview of `s`: a line break, a tab or another [`marked`] character
599/// becomes a mark from the glyph set, so `line1\nline2` does not read as
600/// `line1line2`. A `\r\n` pair is one break.
601pub 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/// The facts about text that its look on screen hides: how long it is, how
623/// many lines it runs to, and whitespace at either end.
624#[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
650/// The longest prefix of `s` no longer than `budget` bytes, cut at a
651/// character boundary.
652pub 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
663/// One cell of a one-row column as copy text: exact scalars, nested values as
664/// the JSON an export writes, bytes as base64, a null as empty.
665pub 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
677/// At least how many bytes [`copy_text`] writes for `value`, counted only until
678/// the count passes `stop`: a copy can be refused at a cap before it is
679/// formatted, and a million-item list is not walked to learn that it is over.
680/// Text is its length and bytes their base64; a list or struct is counted from
681/// its punctuation and leaves, each at its shortest JSON.
682pub 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        // Any other scalar is a few bytes: its text is its measure.
690        v => value_text(v).len(),
691    }
692}
693
694/// `so_far` plus a floor on the JSON written for `value`, stopping once past `stop`.
695fn json_len_floor(value: &AnyValue, so_far: usize, stop: usize) -> usize {
696    if so_far > stop {
697        return so_far;
698    }
699    // Brackets, and a comma between items.
700    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                // `"name":` before the value.
719                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        // A number, a flag or a null is at least a character.
738        _ => so_far + 1,
739    }
740}
741
742#[cfg(test)]
743mod tests;