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