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}