use std::io::{self, BufRead, Cursor, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use ratatui::layout::Rect;
use tabnas_alchemy::Output;
use tabnas_transduce::Metrics;
use crate::alchemy::{Compiled, ProgramArg};
use crate::export::{self, ExportError, Input, Job, Plan, Renderer, What};
use crate::load::Format;
use crate::translate;
pub const MAX_OUTPUT_BYTES: usize = 16 << 20;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Role {
Input,
Output,
Program,
}
impl Role {
pub fn name(self) -> &'static str {
match self {
Role::Input => "input",
Role::Output => "output",
Role::Program => "program",
}
}
pub fn from_name(name: &str) -> Option<Role> {
match name.trim().to_ascii_lowercase().as_str() {
"in" | "input" => Some(Role::Input),
"out" | "output" => Some(Role::Output),
"program" | "prog" => Some(Role::Program),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PaneMode {
Structure,
Source,
}
impl PaneMode {
pub fn toggled(self) -> PaneMode {
match self {
PaneMode::Structure => PaneMode::Source,
PaneMode::Source => PaneMode::Structure,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Arrangement {
SideBySide,
Stacked,
}
impl Arrangement {
pub fn toggled(self) -> Arrangement {
match self {
Arrangement::SideBySide => Arrangement::Stacked,
Arrangement::Stacked => Arrangement::SideBySide,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Pane {
pub role: Role,
pub mode: PaneMode,
}
impl Pane {
pub fn new(role: Role) -> Pane {
let mode = match role {
Role::Program => PaneMode::Source,
Role::Input | Role::Output => PaneMode::Structure,
};
Pane { role, mode }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PaneArea {
pub title: Option<Rect>,
pub body: Rect,
pub rule: Option<Rect>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Workspace {
pub panes: Vec<Pane>,
pub arrangement: Arrangement,
pub focus: usize,
}
impl Default for Workspace {
fn default() -> Workspace {
Workspace {
panes: vec![Pane::new(Role::Input)],
arrangement: Arrangement::SideBySide,
focus: 0,
}
}
}
impl Workspace {
pub fn focused(&self) -> Pane {
self.panes[self.focus.min(self.panes.len() - 1)]
}
pub fn focused_mut(&mut self) -> &mut Pane {
let i = self.focus.min(self.panes.len() - 1);
&mut self.panes[i]
}
pub fn position(&self, role: Role) -> Option<usize> {
self.panes.iter().position(|p| p.role == role)
}
pub fn open(&mut self, role: Role) -> usize {
if let Some(i) = self.position(role) {
return i;
}
let focused = self.focused().role;
let at = self.panes.partition_point(|p| p.role < role);
self.panes.insert(at, Pane::new(role));
self.focus = self.position(focused).unwrap_or(0);
at
}
pub fn close(&mut self, index: usize) -> bool {
if index >= self.panes.len() || self.panes[index].role == Role::Input {
return false;
}
let focused = self.focused().role;
self.panes.remove(index);
self.focus = self
.position(focused)
.unwrap_or_else(|| index.saturating_sub(1).min(self.panes.len() - 1));
true
}
pub fn cycle(&mut self) {
self.focus = (self.focus + 1) % self.panes.len();
}
pub fn areas(&self, area: Rect) -> Vec<PaneArea> {
let n = self.panes.len() as u16;
if n <= 1 {
return vec![PaneArea {
title: None,
body: area,
rule: None,
}];
}
let mut out = Vec::with_capacity(self.panes.len());
match self.arrangement {
Arrangement::SideBySide => {
let rules = n - 1;
let share = area.width.saturating_sub(rules) / n;
let mut x = area.x;
for i in 0..n {
let rule = (i > 0 && x < area.right()).then(|| {
let r = Rect::new(x, area.y, 1, area.height);
x += 1;
r
});
let width = if i + 1 == n {
area.right().saturating_sub(x)
} else {
share
};
out.push(titled(Rect::new(x, area.y, width, area.height), rule));
x = (x + width).min(area.right());
}
}
Arrangement::Stacked => {
let share = area.height / n;
let mut y = area.y;
for i in 0..n {
let height = if i + 1 == n {
area.bottom().saturating_sub(y)
} else {
share
};
out.push(titled(Rect::new(area.x, y, area.width, height), None));
y = (y + height).min(area.bottom());
}
}
}
out
}
}
fn titled(outer: Rect, rule: Option<Rect>) -> PaneArea {
let title = (outer.height > 0).then(|| Rect::new(outer.x, outer.y, outer.width, 1));
let body = Rect::new(
outer.x,
outer.y + u16::from(title.is_some()),
outer.width,
outer.height.saturating_sub(1),
);
PaneArea { title, body, rule }
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Through {
Render(Renderer),
Program {
arg: ProgramArg,
render: Option<Renderer>,
},
}
impl Default for Through {
fn default() -> Through {
Through::Render(Renderer::Json)
}
}
impl Through {
pub fn label(&self) -> String {
match self {
Through::Render(r) => r.name().to_string(),
Through::Program { arg, .. } => match arg.path() {
Some(p) => p
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| arg.name()),
None => arg.name(),
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rendered {
pub text: String,
pub format: Format,
pub cut: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn render(
name: &str,
source: &str,
format: Format,
through: &Through,
program: Option<&Compiled>,
indent: usize,
options: &translate::Options,
timeout: Option<Duration>,
) -> Result<Rendered, String> {
render_within(
name,
source,
format,
through,
program,
indent,
options,
timeout,
MAX_OUTPUT_BYTES,
)
}
#[allow(clippy::too_many_arguments)]
fn render_within(
name: &str,
source: &str,
format: Format,
through: &Through,
program: Option<&Compiled>,
indent: usize,
options: &translate::Options,
timeout: Option<Duration>,
max: usize,
) -> Result<Rendered, String> {
let Some(plan) = export::plan(format) else {
return Err(format!(
"{name} is plain text, which has no values to write: open it with -k to name its format"
));
};
let mut composed: Option<(&translate::Part, translate::Composition)> = None;
let (what, out_format) = match through {
Through::Render(Renderer::Part(id)) => {
let part = translate::part(id).ok_or_else(|| format!("no render for {id}"))?;
let translation = translate::translation(translate::source_part(format), part, options)
.map_err(|fail| {
translate::schema_only(&fail, part, name).unwrap_or_else(|| fail.to_string())
})?;
match translation {
translate::Translation::Native { renderer, .. } => {
(What::Render(renderer), format_of(Renderer::Part(id)))
}
translate::Translation::Composed(c) => {
composed = Some((part, c));
(What::Part, format_of(Renderer::Part(id)))
}
}
}
Through::Render(r) => (What::Render(*r), format_of(*r)),
Through::Program { render, arg } => {
let compiled = program.ok_or("the program did not compile")?;
crate::alchemy::check_render(&compiled.program, *render)?;
if let Some(Renderer::Part(id)) = render {
let part = translate::part(id).ok_or_else(|| format!("no render for {id}"))?;
let c = translate::compose_program(
tabnas_alchemy::Source::new(&arg.name(), &compiled.source),
compiled.program.output(),
part,
options,
)
.map_err(|fail| fail.to_string())?;
composed = Some((part, c));
}
let out = match (compiled.program.output(), render) {
(Output::Text, _) => Format::Text,
(_, Some(r)) => format_of(*r),
(Output::TableRows, None) => Format::Csv,
(Output::JsonEvents, None) => Format::Json,
};
(
What::Program {
rows: compiled.program.row_selector().cloned(),
},
out,
)
}
};
let job = Job {
name: name.to_string(),
origin: name.to_string(),
format,
what,
path: Vec::new(),
compact: false,
indent,
timeout,
started: None,
max_output: None,
};
let kept = Arc::new(Mutex::new(Vec::new()));
let cut = Arc::new(AtomicBool::new(false));
let out = Box::new(Capped {
kept: kept.clone(),
cut: cut.clone(),
max,
});
let input = match plan {
Plan::Lines => {
let reader: Box<dyn BufRead + Send> = Box::new(Cursor::new(source.as_bytes()));
Input::Lines(reader)
}
_ => Input::Text(source),
};
let result = match (&composed, through) {
(Some((part, composition)), _) => translate::run(
&job,
composition,
translate::records(part),
input,
out,
Metrics::new(),
),
(None, Through::Render(_)) => export::export(&job, input, out),
(None, Through::Program { render, .. }) => {
let compiled = program.ok_or("the program did not compile")?;
crate::alchemy::run(&job, &compiled.program, *render, input, out)
}
};
let cut = cut.load(Ordering::Relaxed);
match result {
Ok(()) => {}
Err(ExportError::ReaderGone) if cut => {}
Err(e) => return Err(failure(e)),
}
let bytes = std::mem::take(&mut *kept.lock().unwrap_or_else(|e| e.into_inner()));
Ok(Rendered {
text: String::from_utf8_lossy(&bytes).into_owned(),
format: out_format,
cut,
})
}
fn format_of(r: Renderer) -> Format {
match r {
Renderer::Csv => Format::Csv,
Renderer::Json => Format::Json,
Renderer::Part(id) => Format::from_name(id).unwrap_or(Format::Text),
}
}
fn failure(e: ExportError) -> String {
match e {
ExportError::Usage(m) => m,
ExportError::NotFound { message, .. } => message,
ExportError::Load { error, .. } => error.to_string(),
ExportError::Program(fail) | ExportError::Transduce(fail) => fail.to_string(),
ExportError::ReaderGone => "the output could not be kept".to_string(),
}
}
struct Capped {
kept: Arc<Mutex<Vec<u8>>>,
cut: Arc<AtomicBool>,
max: usize,
}
impl Write for Capped {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut kept = self.kept.lock().unwrap_or_else(|e| e.into_inner());
let room = self.max.saturating_sub(kept.len());
if buf.len() > room {
kept.extend_from_slice(&buf[..room]);
self.cut.store(true, Ordering::Relaxed);
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"the output pane keeps no more",
));
}
kept.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn roles(w: &Workspace) -> Vec<Role> {
w.panes.iter().map(|p| p.role).collect()
}
#[test]
fn panes_open_in_role_order_and_the_input_stays() {
let mut w = Workspace::default();
assert_eq!(roles(&w), [Role::Input]);
assert_eq!(w.open(Role::Program), 1);
assert_eq!(w.open(Role::Output), 1);
assert_eq!(roles(&w), [Role::Input, Role::Output, Role::Program]);
assert_eq!(w.focused().role, Role::Input, "opening moves no focus");
assert_eq!(
w.panes[2].mode,
PaneMode::Source,
"a program opens as its text"
);
assert_eq!(w.open(Role::Output), 1, "an open pane stays as it is");
w.cycle();
w.cycle();
assert_eq!(w.focused().role, Role::Program);
assert!(!w.close(0), "the input pane stays");
assert!(w.close(2));
assert_eq!(w.focused().role, Role::Output, "the focus moves back");
w.cycle();
assert_eq!(w.focused().role, Role::Input, "cycling goes round");
assert_eq!(Role::from_name(" OUT "), Some(Role::Output));
assert_eq!(Role::from_name("tree"), None);
}
#[test]
fn one_pane_takes_the_area_without_a_title() {
let area = Rect::new(0, 1, 80, 20);
let w = Workspace::default();
assert_eq!(
w.areas(area),
[PaneArea {
title: None,
body: area,
rule: None
}]
);
}
#[test]
fn side_by_side_panes_share_the_width_with_a_rule_between() {
let mut w = Workspace::default();
w.open(Role::Output);
w.open(Role::Program);
let area = Rect::new(0, 1, 80, 20);
let a = w.areas(area);
assert_eq!(a.len(), 3);
assert_eq!(a[0].title, Some(Rect::new(0, 1, 26, 1)));
assert_eq!(a[0].body, Rect::new(0, 2, 26, 19));
assert_eq!(a[0].rule, None);
assert_eq!(a[1].rule, Some(Rect::new(26, 1, 1, 20)));
assert_eq!(a[1].body, Rect::new(27, 2, 26, 19));
assert_eq!(a[2].rule, Some(Rect::new(53, 1, 1, 20)));
assert_eq!(a[2].body.x, 54);
assert_eq!(a[2].body.right(), 80, "the last pane takes the remainder");
}
#[test]
fn stacked_panes_share_the_height() {
let mut w = Workspace {
arrangement: Arrangement::Stacked,
..Workspace::default()
};
w.open(Role::Output);
let a = w.areas(Rect::new(0, 1, 40, 21));
assert_eq!(a[0].title, Some(Rect::new(0, 1, 40, 1)));
assert_eq!(a[0].body, Rect::new(0, 2, 40, 9));
assert_eq!(a[1].title, Some(Rect::new(0, 11, 40, 1)));
assert_eq!(a[1].body, Rect::new(0, 12, 40, 10));
assert!(a.iter().all(|p| p.rule.is_none()));
let a = w.areas(Rect::new(0, 0, 3, 1));
assert!(a.iter().all(|p| p.body.bottom() <= 1));
}
#[test]
fn the_output_is_what_render_would_print() {
let csv = "name,age\nada,36\n";
let json = render(
"t.csv",
csv,
Format::Csv,
&Through::default(),
None,
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(json.format, Format::Json);
assert!(!json.cut);
let value: serde_json::Value = serde_json::from_str(&json.text).unwrap();
assert_eq!(value, serde_json::json!([{"name": "ada", "age": "36"}]));
let back = render(
"t.json",
&json.text,
Format::Json,
&Through::Render(Renderer::Csv),
None,
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(
(back.text.as_str(), back.format),
("\"name\",\"age\"\r\n\"ada\",\"36\"\r\n", Format::Csv)
);
let yaml = render(
"t.csv",
csv,
Format::Csv,
&Through::Render(Renderer::Part("yaml")),
None,
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(yaml.format, Format::Yaml);
assert!(yaml.text.contains("\"name\": \"ada\""), "{}", yaml.text);
let err = render(
"t.txt",
"a\nb\n",
Format::Text,
&Through::default(),
None,
2,
&translate::Options::default(),
None,
)
.unwrap_err();
assert!(err.contains("plain text"), "{err}");
}
#[test]
fn a_programs_output_reads_back_in_its_kind() {
let program = |text: &str| Compiled {
program: crate::alchemy::compile(text, "p.alc").unwrap(),
source: text.to_string(),
};
let through = Through::Program {
arg: ProgramArg::Expr(String::new()),
render: None,
};
let text = program(r#"def export [input] "hi""#);
let out = render(
"t.json",
"1",
Format::Json,
&through,
Some(&text),
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!((out.text.as_str(), out.format), ("hi", Format::Text));
let events = program("def export [input] input");
let out = render(
"t.json",
"[1, 2]",
Format::Json,
&through,
Some(&events),
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(out.format, Format::Json);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&out.text).unwrap(),
serde_json::json!([1, 2])
);
let failed = render(
"t.json",
"[1",
Format::Json,
&through,
Some(&events),
2,
&translate::Options::default(),
None,
);
assert!(failed.is_err(), "{failed:?}");
let table = program(
"def rows (record (entry :columns :infer) (entry :rows (path each-index)))\n\
def export [input] (table-from-json rows input)",
);
let with = |render| Through::Program {
arg: ProgramArg::Expr(String::new()),
render: Some(render),
};
let records = r#"[{"a": 1}, {"a": 2}]"#;
let out = render(
"t.json",
records,
Format::Json,
&with(Renderer::Part("json")),
Some(&table),
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(out.format, Format::Json, "{}", out.text);
assert!(serde_json::from_str::<serde_json::Value>(&out.text).is_ok());
let out = render(
"t.json",
records,
Format::Json,
&with(Renderer::Part("csv")),
Some(&events),
2,
&translate::Options::default(),
None,
)
.unwrap();
assert_eq!(out.format, Format::Csv, "{}", out.text);
assert_eq!(out.text, "\"a\"\r\n\"1\"\r\n\"2\"\r\n");
}
#[test]
fn the_output_is_cut_at_its_cap() {
let long = format!("[{}]", vec!["\"xxxxxxxx\""; 100].join(","));
let through = Through::default();
let out = render_within(
"big.json",
&long,
Format::Json,
&through,
None,
2,
&translate::Options::default(),
None,
64,
)
.unwrap();
assert!(out.cut);
assert_eq!(out.text.len(), 64);
let whole = render_within(
"big.json",
&long,
Format::Json,
&through,
None,
2,
&translate::Options::default(),
None,
1 << 20,
);
assert!(!whole.unwrap().cut);
}
#[test]
fn a_through_is_labelled_by_its_format_or_its_file() {
assert_eq!(Through::default().label(), "json");
let program = Through::Program {
arg: ProgramArg::File("dir/export.alc".into()),
render: None,
};
assert_eq!(program.label(), "export.alc");
}
}