1use polars::prelude::*;
16use std::io::Write as _;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum BackendChoice {
21 #[default]
22 Auto,
23 Native,
24 Osc52,
25}
26
27impl BackendChoice {
28 pub fn parse(s: &str) -> Option<Self> {
29 match s.trim().to_ascii_lowercase().as_str() {
30 "auto" => Some(Self::Auto),
31 "native" => Some(Self::Native),
32 "osc52" => Some(Self::Osc52),
33 _ => None,
34 }
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Payload {
43 pub text: String,
44 pub html: Option<String>,
45}
46
47impl Payload {
48 pub fn text(text: String) -> Self {
49 Self { text, html: None }
50 }
51}
52
53pub trait Destination {
56 fn write(&mut self, payload: Payload) -> Result<(), String>;
58 fn describe(&self) -> &'static str;
60 fn accepts(&self) -> Accepts {
61 Accepts {
62 html: true,
63 base64_limit: None,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct Accepts {
71 pub html: bool,
73 pub base64_limit: Option<usize>,
75}
76
77pub struct Native {
81 clipboard: arboard::Clipboard,
82}
83
84impl Native {
85 pub fn new() -> Result<Self, String> {
86 arboard::Clipboard::new()
87 .map(|clipboard| Self { clipboard })
88 .map_err(|e| format!("clipboard unavailable: {e}"))
89 }
90}
91
92impl Destination for Native {
93 fn write(&mut self, payload: Payload) -> Result<(), String> {
94 let result = match payload.html {
95 Some(html) => self.clipboard.set_html(html, Some(payload.text)),
96 None => self.clipboard.set_text(payload.text),
97 };
98 result.map_err(|e| format!("copy failed: {e}"))
99 }
100
101 fn describe(&self) -> &'static str {
102 "clipboard"
103 }
104}
105
106pub struct Osc52 {
108 pub limit: usize,
110}
111
112impl Destination for Osc52 {
113 fn write(&mut self, payload: Payload) -> Result<(), String> {
114 let sequence = osc52_sequence(&payload.text, self.limit)?;
115 drop(payload);
117 let mut out = std::io::stdout();
118 out.write_all(sequence.as_bytes())
119 .and_then(|()| out.flush())
120 .map_err(|e| format!("copy failed: {e}"))
121 }
122
123 fn describe(&self) -> &'static str {
124 "terminal"
125 }
126
127 fn accepts(&self) -> Accepts {
128 Accepts {
129 html: false,
130 base64_limit: Some(self.limit),
131 }
132 }
133}
134
135pub fn base64_len(bytes: usize) -> usize {
137 bytes.div_ceil(3).saturating_mul(4)
138}
139
140pub fn osc52_sequence(text: &str, limit: usize) -> Result<String, String> {
143 use base64::Engine as _;
144 let encoded = base64_len(text.len());
145 if encoded > limit {
146 return Err(over_osc52_limit(Some(encoded), limit));
147 }
148 let mut sequence = String::with_capacity(encoded + 8);
149 sequence.push_str("\x1b]52;c;");
150 base64::engine::general_purpose::STANDARD.encode_string(text.as_bytes(), &mut sequence);
151 sequence.push('\x07');
152 Ok(sequence)
153}
154
155pub(crate) fn over_osc52_limit(encoded: Option<usize>, limit: usize) -> String {
158 let size = match encoded {
159 Some(bytes) => format_kb(bytes),
160 None => format!("over {}", format_kb(limit)),
161 };
162 format!(
163 "the copy is {size} of base64 and the terminal path is capped at {} \
164 (raise [clipboard] osc52_limit, or export to a file)",
165 format_kb(limit),
166 )
167}
168
169fn format_kb(bytes: usize) -> String {
170 format!("{} KB", bytes.div_ceil(1024))
171}
172
173pub fn destination(
175 choice: BackendChoice,
176 osc52_limit: usize,
177) -> Result<Box<dyn Destination>, String> {
178 match choice {
179 BackendChoice::Native => Native::new().map(|n| Box::new(n) as Box<dyn Destination>),
180 BackendChoice::Osc52 => Ok(Box::new(Osc52 { limit: osc52_limit })),
181 BackendChoice::Auto => Ok(match Native::new() {
182 Ok(native) => Box::new(native),
183 Err(_) => Box::new(Osc52 { limit: osc52_limit }),
186 }),
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
194pub enum CopyFormat {
195 #[default]
196 Tsv,
197 Csv,
198 Markdown,
199}
200
201impl CopyFormat {
202 pub const ALL: [Self; 3] = [Self::Tsv, Self::Csv, Self::Markdown];
203
204 pub fn as_str(self) -> &'static str {
205 match self {
206 Self::Tsv => "TSV",
207 Self::Csv => "CSV",
208 Self::Markdown => "Markdown",
209 }
210 }
211}
212
213pub fn delimited(df: &DataFrame, separator: u8, header: bool) -> Result<String, String> {
216 let mut out = Vec::new();
217 let mut df = crate::export::nested_json::frame_as_json(df).map_err(|e| e.to_string())?;
218 CsvWriter::new(&mut out)
219 .with_separator(separator)
220 .include_header(header)
221 .finish(&mut df)
222 .map_err(|e| e.to_string())?;
223 let mut text = String::from_utf8(out).map_err(|e| e.to_string())?;
224 while text.ends_with('\n') || text.ends_with('\r') {
227 text.pop();
228 }
229 Ok(text)
230}
231
232pub fn markdown(df: &DataFrame) -> Result<String, String> {
235 let mut layout = MarkdownLayout::new(df);
236 layout.measure(df)?;
237 let mut out = String::with_capacity(layout.len(df.height()));
238 layout.write(df, &mut out)?;
239 Ok(out)
240}
241
242struct MarkdownLayout {
246 names: Vec<String>,
247 numeric: Vec<bool>,
248 widths: Vec<usize>,
249}
250
251impl MarkdownLayout {
252 fn new(df: &DataFrame) -> Self {
253 let names: Vec<String> = df
254 .get_column_names()
255 .iter()
256 .map(|name| markdown_escape(name))
257 .collect();
258 let widths = names.iter().map(|n| n.chars().count().max(3)).collect();
259 let numeric = df
260 .columns()
261 .iter()
262 .map(|c| c.dtype().is_primitive_numeric())
263 .collect();
264 Self {
265 names,
266 numeric,
267 widths,
268 }
269 }
270
271 fn measure(&mut self, df: &DataFrame) -> Result<(), String> {
273 for (column, width) in df.columns().iter().zip(&mut self.widths) {
274 let series = column.as_materialized_series();
275 for row in 0..df.height() {
276 *width = (*width).max(markdown_cell(series, row)?.chars().count());
277 }
278 }
279 Ok(())
280 }
281
282 fn len(&self, rows: usize) -> usize {
286 let line = self.widths.iter().sum::<usize>() + 3 * self.widths.len() + 1;
287 (rows + 2) * line + rows + 1
288 }
289
290 fn write_line<'a>(&self, out: &mut String, cells: impl Iterator<Item = &'a str>) {
291 out.push_str("| ");
292 for (i, ((cell, &width), &right)) in cells.zip(&self.widths).zip(&self.numeric).enumerate()
293 {
294 if i > 0 {
295 out.push_str(" | ");
296 }
297 let fill = width - cell.chars().count();
300 if right {
301 out.extend(std::iter::repeat_n(' ', fill));
302 out.push_str(cell);
303 } else {
304 out.push_str(cell);
305 out.extend(std::iter::repeat_n(' ', fill));
306 }
307 }
308 out.push_str(" |");
309 }
310
311 fn write(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
313 self.write_line(out, self.names.iter().map(String::as_str));
314 out.push_str("\n|");
315 for (i, (&width, &right)) in self.widths.iter().zip(&self.numeric).enumerate() {
316 if i > 0 {
317 out.push('|');
318 }
319 out.push(' ');
320 if right {
321 out.extend(std::iter::repeat_n('-', width.saturating_sub(1)));
322 out.push(':');
323 } else {
324 out.extend(std::iter::repeat_n('-', width));
325 }
326 out.push(' ');
327 }
328 out.push('|');
329 self.write_rows(df, out)
330 }
331
332 fn write_rows(&self, df: &DataFrame, out: &mut String) -> Result<(), String> {
334 let series: Vec<&Series> = df
335 .columns()
336 .iter()
337 .map(Column::as_materialized_series)
338 .collect();
339 let mut cells = Vec::with_capacity(series.len());
340 for row in 0..df.height() {
341 cells.clear();
342 for s in &series {
343 cells.push(markdown_cell(s, row)?);
344 }
345 out.push('\n');
346 self.write_line(out, cells.iter().map(String::as_str));
347 }
348 Ok(())
349 }
350}
351
352fn markdown_escape(s: &str) -> String {
353 s.replace('|', "\\|").replace(['\n', '\r'], " ")
354}
355
356fn markdown_cell(series: &Series, row: usize) -> Result<String, String> {
358 Ok(match series.get(row).map_err(|e| e.to_string())? {
359 AnyValue::Null => String::new(),
360 v => markdown_escape(&crate::exact::value_text(&v)),
363 })
364}
365
366pub fn html_table(df: &DataFrame, header: bool) -> Result<String, String> {
369 let escape = |s: &str| {
370 s.replace('&', "&")
371 .replace('<', "<")
372 .replace('>', ">")
373 };
374 let column_names = df.get_column_names_owned();
375 let mut out = String::from("<table>");
376 if header {
377 out.push_str("<thead><tr>");
378 for name in &column_names {
379 out.push_str(&format!("<th>{}</th>", escape(name)));
380 }
381 out.push_str("</tr></thead>");
382 }
383 out.push_str("<tbody>");
384 for row in 0..df.height() {
385 out.push_str("<tr>");
386 for name in &column_names {
387 let value = df
388 .column(name)
389 .map_err(|e| e.to_string())?
390 .as_materialized_series()
391 .get(row)
392 .map_err(|e| e.to_string())?;
393 let text = match value {
394 AnyValue::Null => String::new(),
395 v => crate::exact::value_text(&v),
396 };
397 out.push_str(&format!("<td>{}</td>", escape(&text)));
398 }
399 out.push_str("</tr>");
400 }
401 out.push_str("</tbody></table>");
402 Ok(out)
403}
404
405pub fn tabular_payload(
408 df: &DataFrame,
409 format: CopyFormat,
410 header: bool,
411 html: bool,
412) -> Result<Payload, String> {
413 let df = &crate::export::nested_json::frame_as_cells(df).map_err(|e| e.to_string())?;
414 let text = match format {
415 CopyFormat::Tsv => delimited(df, b'\t', header)?,
416 CopyFormat::Csv => delimited(df, b',', header)?,
417 CopyFormat::Markdown => markdown(df)?,
418 };
419 let html = match format {
420 CopyFormat::Tsv | CopyFormat::Csv if html => Some(html_table(df, header)?),
421 _ => None,
422 };
423 Ok(Payload { text, html })
424}
425
426const BOUNDED_BATCH_ROWS: usize = 1024;
429
430pub fn bounded_table_text(
435 lf: LazyFrame,
436 format: CopyFormat,
437 header: bool,
438 limit: usize,
439) -> Result<(String, usize), String> {
440 use std::sync::{Arc, Mutex};
441 let polars_error = |e: PolarsError| crate::error_display::user_message_from_polars(&e);
442 let schema = lf.clone().collect_schema().map_err(polars_error)?;
443 let state = Arc::new(Mutex::new(BoundedText::new(format, header, limit)));
444 let sink_state = Arc::clone(&state);
445 let sink = lf
446 .sink_batches(
447 PlanCallback::new(move |batch: DataFrame| {
448 let mut text = sink_state
449 .lock()
450 .map_err(|_| PolarsError::ComputeError("copy lock failed".into()))?;
451 Ok(text.take(batch))
453 }),
454 true,
455 std::num::NonZeroUsize::new(BOUNDED_BATCH_ROWS),
456 )
457 .map_err(polars_error)?;
458 crate::analysis::statistics::collect_lazy(sink, true).map_err(polars_error)?;
461 let mut text = std::mem::replace(
462 &mut *state.lock().map_err(|_| "copy lock failed".to_string())?,
463 BoundedText::new(format, header, limit),
464 );
465 if !text.started {
466 text.take(DataFrame::empty_with_schema(&schema));
468 }
469 text.finish()
470}
471
472struct BoundedText {
474 format: CopyFormat,
475 header: bool,
476 limit: usize,
477 started: bool,
478 text: String,
479 rows: usize,
480 markdown: Option<(MarkdownLayout, Vec<DataFrame>)>,
482 over: bool,
483 error: Option<String>,
484}
485
486impl BoundedText {
487 fn new(format: CopyFormat, header: bool, limit: usize) -> Self {
488 Self {
489 format,
490 header,
491 limit,
492 started: false,
493 text: String::new(),
494 rows: 0,
495 markdown: None,
496 over: false,
497 error: None,
498 }
499 }
500
501 fn take(&mut self, batch: DataFrame) -> bool {
503 if self.over || self.error.is_some() {
504 return true;
505 }
506 if let Err(e) = self.try_take(batch) {
507 self.error = Some(e);
508 }
509 self.over || self.error.is_some()
510 }
511
512 fn try_take(&mut self, batch: DataFrame) -> Result<(), String> {
513 let first = !self.started;
514 self.started = true;
515 self.rows += batch.height();
516 let batch =
517 crate::export::nested_json::frame_as_cells(&batch).map_err(|e| e.to_string())?;
518 let separator = match self.format {
519 CopyFormat::Tsv => b'\t',
520 CopyFormat::Csv => b',',
521 CopyFormat::Markdown => {
522 let (layout, rows) = self
523 .markdown
524 .get_or_insert_with(|| (MarkdownLayout::new(&batch), Vec::new()));
525 layout.measure(&batch)?;
526 self.over = base64_len(layout.len(self.rows)) > self.limit;
528 rows.push(batch);
529 return Ok(());
530 }
531 };
532 let mut out = Vec::new();
533 let mut batch =
534 crate::export::nested_json::frame_as_json(&batch).map_err(|e| e.to_string())?;
535 CsvWriter::new(&mut out)
536 .with_separator(separator)
537 .include_header(first && self.header)
538 .finish(&mut batch)
539 .map_err(|e| e.to_string())?;
540 self.text
541 .push_str(&String::from_utf8(out).map_err(|e| e.to_string())?);
542 self.over = base64_len(self.text.len().saturating_sub(1)) > self.limit;
544 Ok(())
545 }
546
547 fn finish(mut self) -> Result<(String, usize), String> {
548 if let Some(e) = self.error {
549 return Err(e);
550 }
551 if self.over {
552 return Err(over_osc52_limit(None, self.limit));
553 }
554 if let Some((layout, frames)) = self.markdown.take() {
555 self.text.reserve(layout.len(self.rows));
556 let mut frames = frames.iter();
557 if let Some(first) = frames.next() {
558 layout.write(first, &mut self.text)?;
559 }
560 for frame in frames {
561 layout.write_rows(frame, &mut self.text)?;
562 }
563 }
564 while self.text.ends_with('\n') || self.text.ends_with('\r') {
565 self.text.pop();
566 }
567 let encoded = base64_len(self.text.len());
568 if encoded > self.limit {
569 return Err(over_osc52_limit(Some(encoded), self.limit));
570 }
571 Ok((self.text, self.rows))
572 }
573}
574
575#[cfg(test)]
576mod tests;