aboutsummaryrefslogtreecommitdiffstats
path: root/src/output.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/output.rs')
-rw-r--r--src/output.rs404
1 files changed, 404 insertions, 0 deletions
diff --git a/src/output.rs b/src/output.rs
new file mode 100644
index 0000000..26c65d3
--- /dev/null
+++ b/src/output.rs
@@ -0,0 +1,404 @@
+//! Terminal and JSON presentation.
+//!
+//! Color turns itself off when the output is not a terminal (a pipe, a file)
+//! or when `NO_COLOR` is set, following <https://no-color.org>.
+
+use std::io::IsTerminal;
+
+use crate::model::{Conjugation, Entry, Locution, Sense};
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum ColorChoice {
+ Auto,
+ Always,
+ Never,
+}
+
+#[derive(Clone, Copy)]
+pub struct Style {
+ color: bool,
+ pub width: usize,
+}
+
+impl Style {
+ pub fn new(choice: ColorChoice, width: Option<usize>) -> Self {
+ let color = match choice {
+ ColorChoice::Always => true,
+ ColorChoice::Never => false,
+ ColorChoice::Auto => {
+ std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
+ }
+ };
+ Style {
+ color,
+ width: width.unwrap_or_else(detect_width).clamp(40, 100),
+ }
+ }
+
+ fn paint(&self, code: &str, s: &str) -> String {
+ if self.color && !s.is_empty() {
+ format!("\x1b[{code}m{s}\x1b[0m")
+ } else {
+ s.to_string()
+ }
+ }
+
+ pub fn bold(&self, s: &str) -> String {
+ self.paint("1", s)
+ }
+ pub fn dim(&self, s: &str) -> String {
+ self.paint("2", s)
+ }
+ pub fn italic(&self, s: &str) -> String {
+ self.paint("3", s)
+ }
+ pub fn cyan(&self, s: &str) -> String {
+ self.paint("36", s)
+ }
+ pub fn green(&self, s: &str) -> String {
+ self.paint("32", s)
+ }
+ pub fn red(&self, s: &str) -> String {
+ self.paint("31", s)
+ }
+ pub fn yellow(&self, s: &str) -> String {
+ self.paint("33", s)
+ }
+ pub fn heading(&self, s: &str) -> String {
+ self.paint("1;36", s)
+ }
+}
+
+fn detect_width() -> usize {
+ if let Ok(cols) = std::env::var("COLUMNS") {
+ if let Ok(n) = cols.parse::<usize>() {
+ return n;
+ }
+ }
+ terminal_size::terminal_size()
+ .map(|(terminal_size::Width(w), _)| w as usize)
+ .unwrap_or(80)
+}
+
+/// Wraps a paragraph to the given width. `first` indents the first line and
+/// `rest` the following ones, so the text stays aligned under the sense
+/// number instead of going back to the margin.
+pub fn wrap(text: &str, width: usize, first: &str, rest: &str) -> String {
+ let usable = width.saturating_sub(rest.chars().count()).max(20);
+ let mut out = String::new();
+ let mut line = String::new();
+ let mut line_len = 0usize;
+ let mut started = false;
+
+ for word in text.split_whitespace() {
+ let wlen = word.chars().count();
+ if line_len > 0 && line_len + 1 + wlen > usable {
+ out.push_str(if started { rest } else { first });
+ out.push_str(&line);
+ out.push('\n');
+ started = true;
+ line.clear();
+ line_len = 0;
+ }
+ if line_len > 0 {
+ line.push(' ');
+ line_len += 1;
+ }
+ line.push_str(word);
+ line_len += wlen;
+ }
+ if line_len > 0 {
+ out.push_str(if started { rest } else { first });
+ out.push_str(&line);
+ }
+ out
+}
+
+/// The full entry, as shown in the terminal.
+pub fn entry(e: &Entry, st: &Style, show_abbr: bool) -> String {
+ let mut out = String::new();
+ out.push_str(&st.heading(&e.word));
+ out.push('\n');
+
+ if let Some(ety) = &e.etymology {
+ out.push_str(&st.dim(&wrap(ety, st.width, "", "")));
+ out.push('\n');
+ }
+ out.push('\n');
+
+ for s in &e.senses {
+ out.push_str(&sense(s, st, show_abbr));
+ out.push('\n');
+ }
+
+ if !e.locutions.is_empty() {
+ out.push('\n');
+ out.push_str(&st.heading("Locuciones y expresiones"));
+ out.push('\n');
+ for l in &e.locutions {
+ out.push_str(&locution(l, st, show_abbr));
+ }
+ }
+
+ if let Some(c) = &e.conjugation {
+ out.push('\n');
+ out.push_str(&conjugation(c, st));
+ }
+ out
+}
+
+/// A sense: number, definition, examples and synonyms.
+pub fn sense(s: &Sense, st: &Style, show_abbr: bool) -> String {
+ let num = s.number.clone().unwrap_or_default();
+ let label = if num.is_empty() {
+ " ".to_string()
+ } else {
+ format!("{num}.")
+ };
+ let pad = " ".repeat(label.chars().count().max(2) + 1);
+ let first = format!("{} ", st.yellow(&label));
+
+ let mut out = wrap(&superscript_homographs(&s.text), st.width, &first, &pad);
+ out.push('\n');
+
+ for ex in &s.examples {
+ out.push_str(&st.italic(&st.dim(&wrap(ex, st.width, &pad, &pad))));
+ out.push('\n');
+ }
+ if !s.synonyms.is_empty() {
+ let joined = superscript_homographs(&s.synonyms.join(", "));
+ let line = format!("{} {joined}", st.green("Sin.:"));
+ out.push_str(&wrap(&line, st.width, &pad, &pad));
+ out.push('\n');
+ }
+ if !s.antonyms.is_empty() {
+ let joined = superscript_homographs(&s.antonyms.join(", "));
+ let line = format!("{} {joined}", st.red("Ant.:"));
+ out.push_str(&wrap(&line, st.width, &pad, &pad));
+ out.push('\n');
+ }
+ if show_abbr && !s.grammar.is_empty() {
+ let expanded: Vec<String> = s
+ .grammar
+ .iter()
+ .map(|a| format!("{} = {}", a.short, a.long))
+ .collect();
+ let line = expanded.join("; ");
+ out.push_str(&st.dim(&wrap(&line, st.width, &pad, &pad)));
+ out.push('\n');
+ }
+ out
+}
+
+fn locution(l: &Locution, st: &Style, show_abbr: bool) -> String {
+ let mut out = format!("\n{}\n", st.bold(&l.phrase));
+ for s in &l.senses {
+ out.push_str(&sense(s, st, show_abbr));
+ }
+ out
+}
+
+/// The conjugation, one mood per block and one tense per paragraph.
+///
+/// A seven-person grid per tense does not fit an 80-column terminal once
+/// compound tenses show up («yo habría hablado»), so the forms flow separated
+/// by «·». Each form already carries its pronoun, so no column header is needed.
+pub fn conjugation(c: &Conjugation, st: &Style) -> String {
+ let mut out = format!("{}\n", st.heading(&c.title));
+ for g in &c.groups {
+ out.push_str(&format!("\n{}\n", st.bold(&g.title)));
+ for t in &g.tenses {
+ out.push_str(&format!(" {}\n", st.yellow(&t.name)));
+ let line = t.forms.join(" · ");
+ out.push_str(&wrap(&line, st.width, " ", " "));
+ out.push('\n');
+ }
+ }
+ out
+}
+
+/// Turns homograph numbers into superscripts: the dictionary tells «can¹»
+/// from «can²», but the sources deliver them as `can1`.
+///
+/// The rule is narrow on purpose: only a single digit stuck to the end of a
+/// word. That way it leaves alone quantities («365 días») and ordinals
+/// («3.º C») inside definitions.
+pub fn superscript_homographs(s: &str) -> String {
+ const SUP: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
+ let chars: Vec<char> = s.chars().collect();
+ let mut out = String::with_capacity(s.len());
+ for (i, &c) in chars.iter().enumerate() {
+ let is_homograph = c.is_ascii_digit()
+ && chars
+ .get(i.wrapping_sub(1))
+ .is_some_and(|p| p.is_alphabetic())
+ && i > 0
+ && !chars.get(i + 1).is_some_and(|n| n.is_alphanumeric());
+ if is_homograph {
+ out.push(SUP[c.to_digit(10).unwrap() as usize]);
+ } else {
+ out.push(c);
+ }
+ }
+ out
+}
+
+/// Truncates to `max` characters without splitting a multibyte character.
+pub fn truncate(s: &str, max: usize) -> String {
+ if s.chars().count() <= max {
+ s.to_string()
+ } else {
+ let cut: String = s.chars().take(max.saturating_sub(1)).collect();
+ format!("{cut}…")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::model::Abbr;
+
+ fn plain() -> Style {
+ Style {
+ color: false,
+ width: 40,
+ }
+ }
+
+ #[test]
+ fn wrap_respects_the_width() {
+ let t = "una definición bastante larga que no cabe en una sola línea de texto";
+ let w = wrap(t, 30, "", "");
+ assert!(w.lines().all(|l| l.chars().count() <= 30), "{w}");
+ assert!(w.lines().count() > 1);
+ }
+
+ #[test]
+ fn wrap_indents_continuation_lines() {
+ let t = "palabra ".repeat(12);
+ let w = wrap(t.trim(), 30, "1. ", " ");
+ let mut lines = w.lines();
+ assert!(lines.next().unwrap().starts_with("1. "));
+ assert!(lines.all(|l| l.starts_with(" ")), "{w}");
+ }
+
+ #[test]
+ fn wrap_handles_accented_text_by_characters_not_bytes() {
+ // Counting bytes, accents would make the line too short.
+ let t = "ñandú cóndor océano sábana límite página tránsito";
+ let w = wrap(t, 24, "", "");
+ assert!(w.lines().all(|l| l.chars().count() <= 24), "{w}");
+ }
+
+ #[test]
+ fn sense_shows_number_example_and_synonyms() {
+ let s = Sense {
+ number: Some("1".into()),
+ grammar: vec![Abbr {
+ short: "f.".into(),
+ long: "nombre femenino".into(),
+ }],
+ text: "Edificio para habitar.".into(),
+ examples: vec!["Una casa de ocho plantas.".into()],
+ synonyms: vec!["vivienda".into()],
+ antonyms: vec![],
+ };
+ let out = sense(&s, &plain(), false);
+ assert!(out.starts_with("1. Edificio"), "{out}");
+ assert!(out.contains("Una casa de ocho plantas."));
+ assert!(out.contains("Sin.: vivienda"));
+ // Without --abreviaturas the grammar mark is not expanded.
+ assert!(!out.contains("nombre femenino"));
+ }
+
+ #[test]
+ fn sense_expands_abbreviations_on_request() {
+ let s = Sense {
+ number: Some("1".into()),
+ grammar: vec![Abbr {
+ short: "f.".into(),
+ long: "nombre femenino".into(),
+ }],
+ text: "Edificio.".into(),
+ ..Default::default()
+ };
+ assert!(sense(&s, &plain(), true).contains("f. = nombre femenino"));
+ }
+
+ #[test]
+ fn no_color_env_disables_ansi() {
+ let st = Style {
+ color: false,
+ width: 80,
+ };
+ assert_eq!(st.bold("hola"), "hola");
+ assert!(!st.heading("hola").contains('\x1b'));
+ }
+
+ #[test]
+ fn color_when_enabled_wraps_in_ansi() {
+ let st = Style {
+ color: true,
+ width: 80,
+ };
+ assert_eq!(st.bold("hola"), "\x1b[1mhola\x1b[0m");
+ }
+
+ #[test]
+ fn conjugation_lists_forms_in_flow() {
+ use crate::model::{ConjGroup, Tense};
+ let c = Conjugation {
+ title: "Conjugación de «hablar»".into(),
+ groups: vec![ConjGroup {
+ title: "Modo indicativo".into(),
+ tenses: vec![Tense {
+ name: "Presente".into(),
+ forms: vec!["yo hablo".into(), "vos hablás".into()],
+ }],
+ }],
+ };
+ let out = conjugation(&c, &plain());
+ assert!(out.contains("Modo indicativo"));
+ assert!(out.contains("Presente"));
+ assert!(out.contains("yo hablo · vos hablás"), "{out}");
+ assert!(out.lines().all(|l| l.chars().count() <= 40), "{out}");
+ }
+
+ #[test]
+ fn raises_homograph_numbers() {
+ assert_eq!(superscript_homographs("can1, chucho1"), "can¹, chucho¹");
+ assert_eq!(superscript_homographs("decir1."), "decir¹.");
+ assert_eq!(superscript_homographs("piso2"), "piso²");
+ }
+
+ #[test]
+ fn leaves_ordinary_numbers_alone() {
+ // Quantities and ordinals that appear in definitions.
+ let t = "equivale a 365 días, 5 horas y 46 segundos";
+ assert_eq!(superscript_homographs(t), t);
+ assert_eq!(
+ superscript_homographs("Mi casa está en el 3.º C"),
+ "Mi casa está en el 3.º C"
+ );
+ // A digit followed by a letter or another digit is not a homograph.
+ assert_eq!(superscript_homographs("H2O"), "H2O");
+ assert_eq!(superscript_homographs("año 1492"), "año 1492");
+ }
+
+ #[test]
+ fn sense_raises_homographs_in_synonyms() {
+ let s = Sense {
+ number: Some("1".into()),
+ text: "m. y f. Mamífero doméstico.".into(),
+ synonyms: vec!["can1".into(), "chucho1".into()],
+ ..Default::default()
+ };
+ assert!(sense(&s, &plain(), false).contains("can¹, chucho¹"));
+ }
+
+ #[test]
+ fn truncate_is_multibyte_safe() {
+ assert_eq!(truncate("ñandú y cóndor", 6), "ñandú…");
+ assert_eq!(truncate("casa", 10), "casa");
+ }
+}