Skip to main content

marco_core/parser/inlines/
text_parser.rs

1//! Text parser - handle plain text fallback
2//!
3//! Parses plain text segments when no inline elements match. Handles special
4//! cases like trailing spaces before newlines and consecutive backticks.
5
6use super::shared::{opt_span, GrammarSpan};
7use crate::parser::ast::{Node, NodeKind};
8use nom::bytes::complete::take;
9use nom::IResult;
10use nom::Input;
11use nom::Parser;
12
13/// Parse plain text up to the next special character
14///
15/// Consumes text until a special inline character is found (*_`[<!&\\n).
16///
17/// Note: This also stops at several extension delimiters so they can be parsed
18/// in the middle of a line:
19/// - `^` (superscript)
20/// - `~` (subscript or strikethrough)
21/// - `==` (mark)
22/// - `--` (dash strikethrough)
23/// - `˅` (arrow-style subscript)
24///   Handles special cases:
25/// - Trailing spaces before newlines (potential hard line break)
26/// - Consecutive backticks (consume all together)
27///
28/// # Arguments
29/// * `input` - The input text as a GrammarSpan
30///
31/// # Returns
32/// * `Ok((remaining, node))` - Successfully parsed text node
33/// * `Err(_)` - No text to parse (input starts with special character)
34pub fn parse_text(input: GrammarSpan) -> IResult<GrammarSpan, Node> {
35    let text_fragment = input.fragment();
36
37    // Autolink literals, emoji shortcodes, and platform mentions are all
38    // single-line constructs, so bound their lookahead scans to the current
39    // line instead of the entire remaining document. Without this bound,
40    // each of the three `find_next_*_start` helpers below does an unbounded
41    // substring search to the end of `text_fragment` (which shrinks by only
42    // one small chunk per `parse_text` call), making a large multi-line
43    // paragraph with no matches anywhere cost O(n^2) instead of O(n).
44    let line_end = text_fragment.find('\n').unwrap_or(text_fragment.len());
45    let line = &text_fragment[..line_end];
46
47    // GFM autolink literals can appear in the middle of a text node.
48    // If we can see a valid autolink literal starting at some offset, we must
49    // stop before it so the dedicated parser can run.
50    let next_autolink_literal =
51        super::gfm_autolink_literal_parser::find_next_autolink_literal_start(line)
52            .unwrap_or(text_fragment.len());
53
54    // Emoji shortcodes (extended syntax) can appear in the middle of a text node.
55    // Only stop for *recognized* shortcodes; unknown ones remain literal.
56    let next_emoji_shortcode =
57        super::marco_emoji_shortcode_parser::find_next_emoji_shortcode_start(line)
58            .unwrap_or(text_fragment.len());
59
60    // Platform mentions (extended syntax) can appear in the middle of a text node.
61    let next_platform_mention =
62        super::marco_platform_mentions_parser::find_next_platform_mention_start(line)
63            .unwrap_or(text_fragment.len());
64
65    // Find the next special character / delimiter start.
66    //
67    // Important: we intentionally do NOT treat a single '-' as special because
68    // it's too common in normal prose. Instead we only stop at the start of
69    // a *double* dash sequence "--".
70    let next_special = text_fragment
71        .char_indices()
72        .find_map(|(idx, ch)| match ch {
73            '*' | '_' | '`' | '[' | '<' | '!' | '&' | '\n' | '\\' | '$' => Some(idx),
74            '^' | '~' | '˅' => Some(idx),
75            '=' => {
76                if text_fragment[idx..].starts_with("==") {
77                    Some(idx)
78                } else {
79                    None
80                }
81            }
82            '-' => {
83                if text_fragment[idx..].starts_with("--") {
84                    Some(idx)
85                } else {
86                    None
87                }
88            }
89            _ => None,
90        })
91        .unwrap_or(text_fragment.len());
92
93    // If an autolink literal begins at offset 0, do not treat it as plain text.
94    if next_autolink_literal == 0 {
95        return Err(nom::Err::Error(nom::error::Error::new(
96            input,
97            nom::error::ErrorKind::Verify,
98        )));
99    }
100
101    // If an emoji shortcode begins at offset 0, do not treat it as plain text.
102    if next_emoji_shortcode == 0 {
103        return Err(nom::Err::Error(nom::error::Error::new(
104            input,
105            nom::error::ErrorKind::Verify,
106        )));
107    }
108
109    // If a platform mention begins at offset 0, do not treat it as plain text.
110    if next_platform_mention == 0 {
111        return Err(nom::Err::Error(nom::error::Error::new(
112            input,
113            nom::error::ErrorKind::Verify,
114        )));
115    }
116
117    let next_special = next_special
118        .min(next_autolink_literal)
119        .min(next_emoji_shortcode)
120        .min(next_platform_mention);
121
122    if next_special == 0 {
123        // No text - input starts with special character
124        return Err(nom::Err::Error(nom::error::Error::new(
125            input,
126            nom::error::ErrorKind::Verify,
127        )));
128    }
129
130    // Check if the upcoming character is a newline and the text ends with spaces
131    // If so, don't consume trailing spaces (they might be part of a hard line break)
132    let mut text_len = next_special;
133    if next_special < text_fragment.len() && text_fragment[next_special..].starts_with('\n') {
134        // Check for trailing spaces
135        let mut trailing_spaces = 0;
136        for ch in text_fragment[..next_special].chars().rev() {
137            if ch == ' ' {
138                trailing_spaces += 1;
139            } else {
140                break;
141            }
142        }
143
144        // If we have 2+ trailing spaces, don't consume them
145        // (they might be part of a hard line break pattern)
146        if trailing_spaces >= 2 {
147            text_len = next_special - trailing_spaces;
148        }
149    }
150
151    if text_len == 0 {
152        // Only trailing spaces - don't consume them
153        return Err(nom::Err::Error(nom::error::Error::new(
154            input,
155            nom::error::ErrorKind::Verify,
156        )));
157    }
158
159    // Use nom::Input to properly advance by byte count (not character count!)
160    let text_content = input.take(text_len);
161    let rest = input.take_from(text_len);
162
163    let span = opt_span(text_content);
164
165    let node = Node {
166        kind: NodeKind::Text(text_content.fragment().to_string()),
167        span,
168        children: Vec::new(),
169    };
170
171    Ok((rest, node))
172}
173
174/// Parse a single special character as text (fallback for unmatched syntax)
175///
176/// When an inline element parser fails to match, this function consumes the
177/// special character as plain text. For backticks, consumes all consecutive
178/// backticks together.
179///
180/// # Arguments
181/// * `input` - The input text as a GrammarSpan
182///
183/// # Returns
184/// * `Ok((remaining, node))` - Successfully parsed text node with special character
185/// * `Err(_)` - Input is empty
186pub fn parse_special_as_text(input: GrammarSpan) -> IResult<GrammarSpan, Node> {
187    let text_fragment = input.fragment();
188
189    if text_fragment.is_empty() {
190        return Err(nom::Err::Error(nom::error::Error::new(
191            input,
192            nom::error::ErrorKind::Eof,
193        )));
194    }
195
196    // Special case: if it's a backtick, consume all consecutive backticks
197    // This prevents ```foo`` from being parsed as ` + ``foo``
198    let char_len = if text_fragment.starts_with('`') {
199        // Count all consecutive backticks
200        text_fragment.chars().take_while(|&c| c == '`').count()
201    } else {
202        text_fragment
203            .chars()
204            .next()
205            .map(|c| c.len_utf8())
206            .unwrap_or(1)
207    };
208
209    let (rest, text_content) = take(char_len).parse(input)?;
210
211    let span = opt_span(text_content);
212
213    let node = Node {
214        kind: NodeKind::Text(text_content.fragment().to_string()),
215        span,
216        children: Vec::new(),
217    };
218
219    Ok((rest, node))
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn smoke_test_parse_text_basic() {
228        let input = GrammarSpan::new("Hello World*");
229        let result = parse_text(input);
230
231        assert!(result.is_ok(), "Failed to parse plain text");
232        let (rest, node) = result.unwrap();
233
234        assert_eq!(rest.fragment(), &"*");
235
236        if let NodeKind::Text(text) = &node.kind {
237            assert_eq!(text, "Hello World");
238        } else {
239            panic!("Expected Text node");
240        }
241    }
242
243    #[test]
244    fn smoke_test_parse_text_up_to_special() {
245        let input = GrammarSpan::new("text with `code`");
246        let result = parse_text(input);
247
248        assert!(result.is_ok());
249        let (rest, node) = result.unwrap();
250
251        assert_eq!(rest.fragment(), &"`code`");
252
253        if let NodeKind::Text(text) = &node.kind {
254            assert_eq!(text, "text with ");
255        }
256    }
257
258    #[test]
259    fn smoke_test_parse_text_trailing_spaces() {
260        let input = GrammarSpan::new("text  \n");
261        let result = parse_text(input);
262
263        assert!(result.is_ok());
264        let (rest, node) = result.unwrap();
265
266        // Should not consume trailing spaces before newline
267        assert_eq!(rest.fragment(), &"  \n");
268
269        if let NodeKind::Text(text) = &node.kind {
270            assert_eq!(text, "text");
271        }
272    }
273
274    #[test]
275    fn smoke_test_parse_text_starts_with_special() {
276        let input = GrammarSpan::new("*emphasis*");
277        let result = parse_text(input);
278
279        assert!(
280            result.is_err(),
281            "Should not parse text starting with special char"
282        );
283    }
284
285    #[test]
286    fn smoke_test_parse_special_as_text_asterisk() {
287        let input = GrammarSpan::new("* not emphasis");
288        let result = parse_special_as_text(input);
289
290        assert!(result.is_ok(), "Failed to parse special as text");
291        let (rest, node) = result.unwrap();
292
293        assert_eq!(rest.fragment(), &" not emphasis");
294
295        if let NodeKind::Text(text) = &node.kind {
296            assert_eq!(text, "*");
297        }
298    }
299
300    #[test]
301    fn smoke_test_parse_special_as_text_backticks() {
302        let input = GrammarSpan::new("```not code");
303        let result = parse_special_as_text(input);
304
305        assert!(result.is_ok());
306        let (rest, node) = result.unwrap();
307
308        assert_eq!(rest.fragment(), &"not code");
309
310        if let NodeKind::Text(text) = &node.kind {
311            assert_eq!(text, "```");
312        }
313    }
314
315    #[test]
316    fn smoke_test_parse_text_position() {
317        let input = GrammarSpan::new("Hello*");
318        let result = parse_text(input);
319
320        assert!(result.is_ok());
321        let (_, node) = result.unwrap();
322
323        assert!(node.span.is_some(), "Text should have position info");
324
325        let span = node.span.unwrap();
326        assert_eq!(span.start.offset, 0);
327        assert_eq!(span.end.offset, 5); // "Hello" is 5 bytes
328    }
329}