Skip to main content

kochab/types/
document.rs

1//! Provides types for creating Gemini Documents.
2//!
3//! The module is centered around the `Document` type,
4//! which provides all the necessary methods for programatically
5//! creation of Gemini documents.
6//!
7//! # Examples
8//!
9//! ```
10//! use kochab::document::HeadingLevel::*;
11//!
12//! let mut document = kochab::Document::new();
13//!
14//! document.add_heading(H1, "Heading 1");
15//! document.add_heading(H2, "Heading 2");
16//! document.add_heading(H3, "Heading 3");
17//! document.add_blank_line();
18//! document.add_text("text");
19//! document.add_link("gemini://gemini.circumlunar.space", "Project Gemini");
20//! document.add_unordered_list_item("list item");
21//! document.add_quote("quote");
22//! document.add_preformatted("preformatted");
23//!
24//! assert_eq!(document.to_string(), "\
25//!     ## Heading 1\n\
26//!     ### Heading 2\n\
27//!     #### Heading 3\n\
28//!     \n\
29//!     text\n\
30//!     => gemini://gemini.circumlunar.space/ Project Gemini\n\
31//!     * list item\n\
32//!     > quote\n\
33//!     ```\n\
34//!     preformatted\n\
35//!     ```\n\
36//! ");
37//! ```
38#![warn(missing_docs)]
39use std::convert::TryInto;
40use std::fmt;
41
42use crate::URIReference;
43use crate::util::Cowy;
44
45#[derive(Default)]
46/// Represents a Gemini document.
47///
48/// Provides convenient methods for programatically
49/// creation of Gemini documents.
50pub struct Document {
51    items: Vec<Item>,
52}
53
54impl Document {
55    /// Creates an empty Gemini `Document`.
56    ///
57    /// # Examples
58    ///
59    /// ```
60    /// let document = kochab::Document::new();
61    ///
62    /// assert_eq!(document.to_string(), "");
63    /// ```
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Adds an `item` to the document.
69    ///
70    /// An `item` usually corresponds to a single line,
71    /// except in the case of preformatted text.
72    ///
73    /// # Examples
74    ///
75    /// ```compile_fail
76    /// use kochab::document::{Document, Item, Text};
77    ///
78    /// let mut document = Document::new();
79    /// let text = Text::new_lossy("foo");
80    /// let item = Item::Text(text);
81    ///
82    /// document.add_item(item);
83    ///
84    /// assert_eq!(document.to_string(), "foo\n");
85    /// ```
86    fn add_item(&mut self, item: Item) -> &mut Self {
87        self.items.push(item);
88        self
89    }
90
91    /// Adds multiple `items` to the document.
92    ///
93    /// This is a convenience wrapper around `add_item`.
94    ///
95    /// # Examples
96    ///
97    /// ```compile_fail
98    /// use kochab::document::{Document, Item, Text};
99    ///
100    /// let mut document = Document::new();
101    /// let items = vec!["foo", "bar", "baz"]
102    ///     .into_iter()
103    ///     .map(Text::new_lossy)
104    ///     .map(Item::Text);
105    ///
106    /// document.add_items(items);
107    ///
108    /// assert_eq!(document.to_string(), "foo\nbar\nbaz\n");
109    /// ```
110    fn add_items<I>(&mut self, items: I) -> &mut Self
111    where
112        I: IntoIterator<Item = Item>,
113    {
114        self.items.extend(items);
115        self
116    }
117
118    /// Adds a blank line to the document.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// let mut document = kochab::Document::new();
124    ///
125    /// document.add_blank_line();
126    ///
127    /// assert_eq!(document.to_string(), "\n");
128    /// ```
129    pub fn add_blank_line(&mut self) -> &mut Self {
130        self.add_item(Item::Text(Text::blank()))
131    }
132
133    /// Adds plain text to the document.
134    ///
135    /// This function allows adding multiple lines at once.
136    ///
137    /// It inserts a whitespace at the beginning of a line
138    /// if it starts with a character sequence that
139    /// would make it a non-plain text line (e.g. link, heading etc).
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// let mut document = kochab::Document::new();
145    ///
146    /// document.add_text("hello\n* world!");
147    ///
148    /// assert_eq!(document.to_string(), "hello\n * world!\n");
149    /// ```
150    pub fn add_text(&mut self, text: impl AsRef<str>) -> &mut Self {
151        let text = text
152            .as_ref()
153            .lines()
154            .map(Text::new_lossy)
155            .map(Item::Text);
156
157        self.add_items(text);
158
159        self
160    }
161
162    /// Adds a link to the document.
163    ///
164    /// `uri`s that fail to parse are substituted with `.`.
165    ///
166    /// Consecutive newlines in `label` will be replaced
167    /// with a single whitespace.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// let mut document = kochab::Document::new();
173    ///
174    /// document.add_link("https://wikipedia.org", "Wiki\n\nWiki");
175    ///
176    /// assert_eq!(document.to_string(), "=> https://wikipedia.org/ Wiki Wiki\n");
177    /// ```
178    pub fn add_link<'a, U>(&mut self, uri: U, label: impl Cowy<str>) -> &mut Self
179    where
180        U: TryInto<URIReference<'a>>,
181    {
182        let uri = uri
183            .try_into()
184            .map(URIReference::into_owned)
185            .or_else(|_| ".".try_into()).expect("Northstar BUG");
186        let label = LinkLabel::from_lossy(label);
187        let link = Link { uri: Box::new(uri), label: Some(label) };
188        let link = Item::Link(link);
189
190        self.add_item(link);
191
192        self
193    }
194
195    /// Adds a link to the document, but without a label.
196    ///
197    /// See `add_link` for details.
198    ///
199    /// # Examples
200    ///
201    /// ```
202    /// let mut document = kochab::Document::new();
203    ///
204    /// document.add_link_without_label("https://wikipedia.org");
205    ///
206    /// assert_eq!(document.to_string(), "=> https://wikipedia.org/\n");
207    /// ```
208    pub fn add_link_without_label<'a, U>(&mut self, uri: U) -> &mut Self
209    where
210        U: TryInto<URIReference<'a>>,
211    {
212        let uri = uri
213            .try_into()
214            .map(URIReference::into_owned)
215            .or_else(|_| ".".try_into()).expect("Northstar BUG");
216        let link = Link {
217            uri: Box::new(uri),
218            label: None,
219        };
220        let link = Item::Link(link);
221
222        self.add_item(link);
223
224        self
225    }
226
227    /// Adds a block of preformatted text.
228    ///
229    /// Lines that start with ` ``` ` will be prependend with a whitespace.
230    ///
231    /// # Examples
232    ///
233    /// ```
234    /// let mut document = kochab::Document::new();
235    ///
236    /// document.add_preformatted("a\n b\n  c");
237    ///
238    /// assert_eq!(document.to_string(), "```\na\n b\n  c\n```\n");
239    /// ```
240    pub fn add_preformatted(&mut self, preformatted_text: impl AsRef<str>) -> &mut Self {
241        self.add_preformatted_with_alt("", preformatted_text.as_ref())
242    }
243
244    /// Adds a block of preformatted text with an alt text.
245    ///
246    /// Consecutive newlines in `alt` will be replaced
247    /// with a single whitespace.
248    ///
249    /// `preformatted_text` lines that start with ` ``` `
250    /// will be prependend with a whitespace.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// let mut document = kochab::Document::new();
256    ///
257    /// document.add_preformatted_with_alt("rust", "fn main() {\n}\n");
258    ///
259    /// assert_eq!(document.to_string(), "```rust\nfn main() {\n}\n```\n");
260    /// ```
261    pub fn add_preformatted_with_alt(&mut self, alt: impl AsRef<str>, preformatted_text: impl AsRef<str>) -> &mut Self {
262        let alt = AltText::new_lossy(alt.as_ref());
263        let lines = preformatted_text
264            .as_ref()
265            .lines()
266            .map(PreformattedText::new_lossy)
267            .collect();
268        let preformatted = Preformatted {
269            alt,
270            lines,
271        };
272        let preformatted = Item::Preformatted(preformatted);
273
274        self.add_item(preformatted);
275
276        self
277    }
278
279    /// Adds a heading.
280    ///
281    /// Consecutive newlines in `text` will be replaced
282    /// with a single whitespace.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use kochab::document::HeadingLevel::H1;
288    ///
289    /// let mut document = kochab::Document::new();
290    ///
291    /// document.add_heading(H1, "Welcome!");
292    ///
293    /// assert_eq!(document.to_string(), "# Welcome!\n");
294    /// ```
295    pub fn add_heading(&mut self, level: HeadingLevel, text: impl Cowy<str>) -> &mut Self {
296        let text = HeadingText::new_lossy(text);
297        let heading = Heading {
298            level,
299            text,
300        };
301        let heading = Item::Heading(heading);
302
303        self.add_item(heading);
304
305        self
306    }
307
308    /// Adds an unordered list item.
309    ///
310    /// Consecutive newlines in `text` will be replaced
311    /// with a single whitespace.
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// let mut document = kochab::Document::new();
317    ///
318    /// document.add_unordered_list_item("milk");
319    /// document.add_unordered_list_item("eggs");
320    ///
321    /// assert_eq!(document.to_string(), "* milk\n* eggs\n");
322    /// ```
323    pub fn add_unordered_list_item(&mut self, text: impl AsRef<str>) -> &mut Self {
324        let item = UnorderedListItem::new_lossy(text.as_ref());
325        let item = Item::UnorderedListItem(item);
326
327        self.add_item(item);
328
329        self
330    }
331
332    /// Adds a quote.
333    ///
334    /// This function allows adding multiple quote lines at once.
335    ///
336    /// # Examples
337    ///
338    /// ```
339    /// let mut document = kochab::Document::new();
340    ///
341    /// document.add_quote("I think,\ntherefore I am");
342    ///
343    /// assert_eq!(document.to_string(), "> I think,\n> therefore I am\n");
344    /// ```
345    pub fn add_quote(&mut self, text: impl AsRef<str>) -> &mut Self {
346        let quote = text
347            .as_ref()
348            .lines()
349            .map(Quote::new_lossy)
350            .map(Item::Quote);
351
352        self.add_items(quote);
353
354        self
355    }
356}
357
358impl fmt::Display for Document {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        for item in &self.items {
361            match item {
362                Item::Text(text) => writeln!(f, "{}", text.0)?,
363                Item::Link(link) => {
364                    let separator = if link.label.is_some() {" "} else {""};
365                    let label = link.label.as_ref().map(|label| label.0.as_str())
366                        .unwrap_or("");
367
368                    writeln!(f, "=> {}{}{}", link.uri, separator, label)?;
369                }
370                Item::Preformatted(preformatted) => {
371                    writeln!(f, "```{}", preformatted.alt.0)?;
372
373                    for line in &preformatted.lines {
374                        writeln!(f, "{}", line.0)?;
375                    }
376
377                    writeln!(f, "```")?
378                }
379                Item::Heading(heading) => {
380                    let level = match heading.level {
381                        HeadingLevel::H1 => "#",
382                        HeadingLevel::H2 => "##",
383                        HeadingLevel::H3 => "###",
384                    };
385
386                    writeln!(f, "{} {}", level, heading.text.0)?;
387                }
388                Item::UnorderedListItem(item) => writeln!(f, "* {}", item.0)?,
389                Item::Quote(quote) => writeln!(f, "> {}", quote.0)?,
390            }
391        }
392
393        Ok(())
394    }
395}
396
397#[allow(clippy::enum_variant_names)]
398enum Item {
399    Text(Text),
400    Link(Link),
401    Preformatted(Preformatted),
402    Heading(Heading),
403    UnorderedListItem(UnorderedListItem),
404    Quote(Quote),
405}
406
407#[derive(Default)]
408struct Text(String);
409
410impl Text {
411    fn blank() -> Self {
412        Self::default()
413    }
414
415    fn new_lossy(line: impl Cowy<str>) -> Self {
416        Self(lossy_escaped_line(line, SPECIAL_STARTS))
417    }
418}
419
420struct Link {
421    uri: Box<URIReference<'static>>,
422    label: Option<LinkLabel>,
423}
424
425struct LinkLabel(String);
426
427impl LinkLabel {
428    fn from_lossy(line: impl Cowy<str>) -> Self {
429        let line = strip_newlines(line);
430
431        Self(line)
432    }
433}
434
435struct Preformatted {
436    alt: AltText,
437    lines: Vec<PreformattedText>,
438}
439
440struct PreformattedText(String);
441
442impl PreformattedText {
443    fn new_lossy(line: impl Cowy<str>) -> Self {
444        Self(lossy_escaped_line(line, &[PREFORMATTED_TOGGLE_START]))
445    }
446}
447
448struct AltText(String);
449
450impl AltText {
451    fn new_lossy(alt: &str) -> Self {
452        let alt = strip_newlines(alt);
453
454        Self(alt)
455    }
456}
457
458struct Heading {
459    level: HeadingLevel,
460    text: HeadingText,
461}
462
463/// The level of a heading.
464pub enum HeadingLevel {
465    /// Heading level 1 (`#`)
466    H1,
467    /// Heading level 2 (`##`)
468    H2,
469    /// Heading level 3 (`###`)
470    H3,
471}
472
473struct HeadingText(String);
474
475impl HeadingText {
476    fn new_lossy(line: impl Cowy<str>) -> Self {
477        let line = strip_newlines(line);
478
479        Self(line)
480    }
481}
482
483struct UnorderedListItem(String);
484
485impl UnorderedListItem {
486    fn new_lossy(text: &str) -> Self {
487        let text = strip_newlines(text);
488
489        Self(text)
490    }
491}
492
493struct Quote(String);
494
495impl Quote {
496    fn new_lossy(text: &str) -> Self {
497        Self(lossy_escaped_line(text, &[QUOTE_START]))
498    }
499}
500
501
502const LINK_START: &str = "=>";
503const PREFORMATTED_TOGGLE_START: &str = "```";
504const HEADING_START: &str = "#";
505const UNORDERED_LIST_ITEM_START: &str = "*";
506const QUOTE_START: &str = ">";
507
508const SPECIAL_STARTS: &[&str] = &[
509    LINK_START,
510    PREFORMATTED_TOGGLE_START,
511    HEADING_START,
512    UNORDERED_LIST_ITEM_START,
513    QUOTE_START,
514];
515
516fn starts_with_any(s: &str, starts: &[&str]) -> bool {
517    for start in starts {
518        if s.starts_with(start) {
519            return true;
520        }
521    }
522
523    false
524}
525
526fn lossy_escaped_line(line: impl Cowy<str>, escape_starts: &[&str]) -> String {
527    let line_ref = line.as_ref();
528    let contains_newline = line_ref.contains('\n');
529    let has_special_start = starts_with_any(line_ref, escape_starts);
530
531    if !contains_newline && !has_special_start {
532        return line.into();
533    }
534
535    let mut line = String::new();
536
537    if has_special_start {
538        line.push(' ');
539    }
540
541    if let Some(line_ref) = line_ref.split('\n').next() {
542        line.push_str(line_ref);
543    }
544
545    line
546}
547
548fn strip_newlines(text: impl Cowy<str>) -> String {
549    if !text.as_ref().contains(&['\r', '\n'][..]) {
550        return text.into();
551    }
552
553    text.as_ref()
554        .lines()
555        .filter(|part| !part.is_empty())
556        .collect::<Vec<_>>()
557        .join(" ")
558}