]> git.r.bdr.sh - rbdr/gema_texto/blob - src/gemini_parser.rs
Add new credentials
[rbdr/gema_texto] / src / gemini_parser.rs
1 #[derive(PartialEq, Eq, Debug)]
2
3 /// Represents one of the six line types in gemtext.
4 ///
5 /// # Examples
6 /// ```
7 /// use gema_texto::gemini_parser::GeminiLine;
8 /// use gema_texto::gemini_parser::parse;
9 ///
10 /// let gemini_source = "\
11 /// # Hello
12 /// This is some gemini text!
13 /// => https://test Go to the test page.
14 ///
15 /// This text actually has more linkes
16 /// => https://test2 Go to the second test page.
17 /// => https://test3 Go to the third test page.
18 /// ";
19 ///
20 /// let gemini_lines = parse(&gemini_source);
21 /// // Count the number of links
22 /// let link_count = gemini_lines.iter()
23 /// .filter(|line| matches!(line, GeminiLine::Link(_, _)))
24 /// .count();
25 /// assert_eq!(link_count, 3);
26 /// ```
27 pub enum GeminiLine {
28 /// Text line, with a bool that shows whether it's preformatted.
29 Text(String, bool),
30
31 /// Preformatted toggle line with alt text.
32 ///
33 /// The boolean indicates whether it turned preformatted mode on or off.
34 /// The string is the optional alt-text.
35 PreformattedToggle(bool, String),
36
37 /// Heading with a number between 1 and 3 inclusive indicating level.
38 Heading(u8, String),
39
40 /// Link with URL and text.
41 Link(String, String),
42
43 /// Quote
44 Quote(String),
45
46 /// List item
47 ListItem(String),
48 }
49
50 /// Parses gemtext source code into a vector of `GeminiLine` elements.
51 ///
52 /// # Arguments
53 /// * `source` - A string slice that contains the gemtext
54 ///
55 /// # Returns
56 /// A `Vec<GeminiLine>` containing the rendered HTML.
57 ///
58 /// # Examples
59 ///
60 /// ```
61 /// use std::fs::read_to_string;
62 /// use gema_texto::gemini_parser::parse;
63 /// let gemini_source = "\
64 /// # Hello
65 /// This is some gemini text!
66 /// => https://test Go to the test page.
67 /// ";
68 ///
69 /// let gemini_lines = parse(&gemini_source);
70 ///
71 /// // Prints the different lines in the array.
72 /// for line in gemini_lines {
73 /// println!("{:#?}", line);
74 /// }
75 /// ```
76 #[must_use]
77 pub fn parse(source: &str) -> Vec<GeminiLine> {
78 source
79 .lines()
80 .fold((Vec::new(), false), |(mut lines, is_preformatted), line| {
81 let parsed = if is_preformatted {
82 parse_preformatted_line(line)
83 } else {
84 parse_line(line)
85 };
86
87 let new_is_preformatted = match parsed {
88 GeminiLine::PreformattedToggle(x, _) => x,
89 _ => is_preformatted,
90 };
91
92 lines.push(parsed);
93 (lines, new_is_preformatted)
94 })
95 .0
96 }
97
98 fn parse_preformatted_line(line: &str) -> GeminiLine {
99 match line {
100 s if s.starts_with("```") => GeminiLine::PreformattedToggle(false, String::new()),
101 _ => GeminiLine::Text(line.to_string(), true),
102 }
103 }
104
105 fn parse_line(line: &str) -> GeminiLine {
106 match line {
107 s if s.starts_with("###") => GeminiLine::Heading(3, s[3..].to_string()),
108 s if s.starts_with("##") => GeminiLine::Heading(2, s[2..].to_string()),
109 s if s.starts_with('#') => GeminiLine::Heading(1, s[1..].to_string()),
110 s if s.starts_with("=>") => {
111 let content = s[2..].trim();
112 match content.split_once(char::is_whitespace) {
113 Some((url, text)) => {
114 GeminiLine::Link(url.trim().to_string(), text.trim().to_string())
115 }
116 None => GeminiLine::Link(content.trim().to_string(), String::new()),
117 }
118 }
119 s if s.starts_with("* ") => GeminiLine::ListItem(s[2..].to_string()),
120 s if s.starts_with('>') => GeminiLine::Quote(s[1..].to_string()),
121 s if s.starts_with("```") => GeminiLine::PreformattedToggle(true, s[3..].to_string()),
122 _ => GeminiLine::Text(line.to_string(), false),
123 }
124 }
125
126 #[cfg(test)]
127 mod tests {
128 use super::*;
129
130 #[test]
131 fn test_headings() {
132 assert_eq!(
133 parse_line("### Heading"),
134 GeminiLine::Heading(3, " Heading".to_string())
135 );
136 assert_eq!(
137 parse_line("## Heading"),
138 GeminiLine::Heading(2, " Heading".to_string())
139 );
140 assert_eq!(
141 parse_line("# Heading"),
142 GeminiLine::Heading(1, " Heading".to_string())
143 );
144 assert_eq!(parse_line("###"), GeminiLine::Heading(3, String::new()));
145 assert_eq!(
146 parse_line("#####"),
147 GeminiLine::Heading(3, "##".to_string())
148 );
149 assert_eq!(parse_line("# "), GeminiLine::Heading(1, " ".to_string()));
150
151 assert_eq!(
152 parse_preformatted_line("### Heading"),
153 GeminiLine::Text("### Heading".to_string(), true)
154 );
155 assert_eq!(
156 parse_preformatted_line("## Heading"),
157 GeminiLine::Text("## Heading".to_string(), true)
158 );
159 assert_eq!(
160 parse_preformatted_line("# Heading"),
161 GeminiLine::Text("# Heading".to_string(), true)
162 );
163 }
164
165 #[test]
166 fn test_links() {
167 assert_eq!(
168 parse_line("=> https://example.com Link text"),
169 GeminiLine::Link("https://example.com".to_string(), "Link text".to_string())
170 );
171 assert_eq!(
172 parse_line("=> /local/path"),
173 GeminiLine::Link("/local/path".to_string(), String::new())
174 );
175
176 assert_eq!(
177 parse_line("=>"),
178 GeminiLine::Link(String::new(), String::new())
179 );
180 assert_eq!(
181 parse_line("=> "),
182 GeminiLine::Link(String::new(), String::new())
183 );
184 assert_eq!(
185 parse_line("=> multiple spaces in text"),
186 GeminiLine::Link("multiple".to_string(), "spaces in text".to_string())
187 );
188
189 assert_eq!(
190 parse_preformatted_line("=> https://example.com Link text"),
191 GeminiLine::Text("=> https://example.com Link text".to_string(), true)
192 );
193 }
194
195 #[test]
196 fn test_list_items() {
197 assert_eq!(
198 parse_line("* List item"),
199 GeminiLine::ListItem("List item".to_string())
200 );
201
202 assert_eq!(parse_line("* "), GeminiLine::ListItem(String::new()));
203 assert_eq!(parse_line("*"), GeminiLine::Text("*".to_string(), false));
204 assert_eq!(
205 parse_line("*WithText"),
206 GeminiLine::Text("*WithText".to_string(), false)
207 );
208 assert_eq!(
209 parse_line("* Multiple spaces"),
210 GeminiLine::ListItem(" Multiple spaces".to_string())
211 );
212 }
213
214 #[test]
215 fn test_quotes() {
216 assert_eq!(
217 parse_line(">Quote text"),
218 GeminiLine::Quote("Quote text".to_string())
219 );
220
221 assert_eq!(parse_line(">"), GeminiLine::Quote(String::new()));
222 assert_eq!(parse_line("> "), GeminiLine::Quote(" ".to_string()));
223 assert_eq!(
224 parse_line(">>Nested"),
225 GeminiLine::Quote(">Nested".to_string())
226 );
227 }
228
229 #[test]
230 fn test_preformatted() {
231 assert_eq!(
232 parse_line("```alt-text"),
233 GeminiLine::PreformattedToggle(true, "alt-text".to_string())
234 );
235
236 assert_eq!(
237 parse_line("```"),
238 GeminiLine::PreformattedToggle(true, String::new())
239 );
240 assert_eq!(
241 parse_line("``` "),
242 GeminiLine::PreformattedToggle(true, " ".to_string())
243 );
244 assert_eq!(
245 parse_line("````"),
246 GeminiLine::PreformattedToggle(true, "`".to_string())
247 );
248
249 assert_eq!(
250 parse_preformatted_line("```alt-text"),
251 GeminiLine::PreformattedToggle(false, String::new())
252 );
253 assert_eq!(
254 parse_preformatted_line("```"),
255 GeminiLine::PreformattedToggle(false, String::new())
256 );
257 }
258
259 #[test]
260 fn test_text() {
261 // Normal case
262 assert_eq!(
263 parse_line("Regular text"),
264 GeminiLine::Text("Regular text".to_string(), false)
265 );
266
267 // Edge cases
268 assert_eq!(parse_line(""), GeminiLine::Text(String::new(), false));
269 assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false));
270 assert_eq!(parse_line(" "), GeminiLine::Text(" ".to_string(), false));
271 }
272
273 #[test]
274 fn test_malformed_input() {
275 assert_eq!(
276 parse_line("= >Not a link"),
277 GeminiLine::Text("= >Not a link".to_string(), false)
278 );
279 assert_eq!(
280 parse_line("``Not preformatted"),
281 GeminiLine::Text("``Not preformatted".to_string(), false)
282 );
283 assert_eq!(
284 parse_line("** Not a list"),
285 GeminiLine::Text("** Not a list".to_string(), false)
286 );
287 }
288
289 #[test]
290 fn test_full_document() {
291 let input = "\
292 # Heading 1
293 ## Heading 2
294 ### Heading 3
295 Regular text
296 => https://example.com Link text
297 * List item
298 >Quote
299 ```alt
300 code
301 # Heading 1
302 ## Heading 2
303 ### Heading 3
304 => https://example.com Link text
305 * List item
306 >Quote
307 ```trailing alt";
308 let result = parse(input);
309 assert_eq!(
310 result,
311 vec![
312 GeminiLine::Heading(1, " Heading 1".to_string()),
313 GeminiLine::Heading(2, " Heading 2".to_string()),
314 GeminiLine::Heading(3, " Heading 3".to_string()),
315 GeminiLine::Text("Regular text".to_string(), false),
316 GeminiLine::Link("https://example.com".to_string(), "Link text".to_string()),
317 GeminiLine::ListItem("List item".to_string()),
318 GeminiLine::Quote("Quote".to_string()),
319 GeminiLine::PreformattedToggle(true, "alt".to_string()),
320 GeminiLine::Text("code".to_string(), true),
321 GeminiLine::Text("# Heading 1".to_string(), true),
322 GeminiLine::Text("## Heading 2".to_string(), true),
323 GeminiLine::Text("### Heading 3".to_string(), true),
324 GeminiLine::Text("=> https://example.com Link text".to_string(), true),
325 GeminiLine::Text("* List item".to_string(), true),
326 GeminiLine::Text(">Quote".to_string(), true),
327 GeminiLine::PreformattedToggle(false, String::new()),
328 ]
329 );
330 }
331 }