Skip to main content

acdc_parser/grammar/
passthrough_processing.rs

1use bumpalo::Bump;
2use std::mem::take;
3
4use crate::{
5    InlineMacro, InlineNode, LineBreak, Location, ParseInlineResult, Pass, PassthroughKind, Plain,
6    ProcessedContent, Raw, Substitution,
7    model::substitution::{
8        SubstitutionPlan, character_reference_end, escape_attribute_characters,
9        is_restorable_character_reference, resolve_passthrough_substitutions,
10    },
11    parsed::OwnedInput,
12};
13
14use super::{
15    ParserState,
16    inlines::inline_parser,
17    location_mapping::clamp_inline_node_locations,
18    state::{InlineContext, InlineRules},
19    utf8_utils::{RoundDirection, step_char},
20};
21
22const QUOTES_THEN_MACROS: &[Substitution] = &[Substitution::Quotes, Substitution::Macros];
23
24/// Expand an attribute at its grammar position, before later macros register.
25/// Locations stay relative to the placeholder until the normal source map runs.
26pub(crate) fn process_attribute_placeholder<'a>(
27    index: usize,
28    start: usize,
29    end: usize,
30    state: &ParserState<'a>,
31) -> Vec<InlineNode<'a>> {
32    let Some(mut pass) = state.attribute_passthroughs.get(index).cloned() else {
33        return Vec::new();
34    };
35    pass.location = state.create_location(start, end);
36    process_passthrough(
37        pass.text.unwrap_or_default(),
38        &pass,
39        state,
40        state.inline_ctx.substitutions,
41    )
42}
43
44/// Apply an inline passthrough's substitutions in source order.
45fn process_passthrough<'a>(
46    content: &'a str,
47    passthrough: &Pass<'a>,
48    state: &ParserState<'a>,
49    attribute_substitutions: SubstitutionPlan,
50) -> Vec<InlineNode<'a>> {
51    let raw = Raw {
52        content,
53        location: passthrough_content_location(passthrough, content, state),
54        subs: Vec::new(),
55    };
56    if !passthrough.attribute_fragments.is_empty() {
57        let mut nodes = Vec::new();
58        let remaining = attribute_substitutions.after(&Substitution::Attributes);
59        let mut cursor = 0;
60        for fragment in &passthrough.attribute_fragments {
61            if cursor < fragment.range.start {
62                nodes.extend(process_raw_substitutions(
63                    Raw {
64                        content: &content[cursor..fragment.range.start],
65                        ..raw.clone()
66                    },
67                    &remaining,
68                    state,
69                ));
70            }
71            nodes.extend(process_raw_substitutions(
72                Raw {
73                    content: &content[fragment.range.clone()],
74                    ..raw.clone()
75                },
76                &fragment.substitutions,
77                state,
78            ));
79            cursor = fragment.range.end;
80        }
81        if cursor < content.len() {
82            nodes.extend(process_raw_substitutions(
83                Raw {
84                    content: &content[cursor..],
85                    ..raw.clone()
86                },
87                &remaining,
88                state,
89            ));
90        }
91        // These characters replace one source reference, irrespective of the
92        // retained text's length or the number of parsing profiles it contains.
93        for node in &mut nodes {
94            super::location_walk::walk_inline_locations_mut(node, &mut |location| {
95                *location = raw.location.clone();
96            });
97        }
98        return nodes;
99    }
100    let substitutions = resolve_passthrough_substitutions(&passthrough.substitutions);
101    if passthrough.kind != PassthroughKind::Macro || !content.contains(r"\]") {
102        return process_raw_substitutions(raw, &substitutions, state);
103    }
104
105    // Remove bracket escapes before substitutions. Keep source fragments so
106    // subsequent parsing can map nodes across each removed backslash.
107    let mut fragments = Vec::new();
108    let mut cursor = 0;
109    for (start, _) in content.match_indices(r"\]") {
110        push_raw_segment(&mut fragments, &raw, cursor, start, Vec::new(), state);
111        fragments.push(InlineNode::RawText(Raw {
112            content: &content[start + 1..start + 2],
113            location: raw_segment_location(&raw, start, start + 2, state),
114            subs: Vec::new(),
115        }));
116        cursor = start + 2;
117    }
118    push_raw_segment(
119        &mut fragments,
120        &raw,
121        cursor,
122        content.len(),
123        Vec::new(),
124        state,
125    );
126    process_inline_nodes(fragments, &substitutions, state)
127}
128
129fn passthrough_content_location(
130    passthrough: &Pass<'_>,
131    content: &str,
132    state: &ParserState<'_>,
133) -> Location {
134    // Preprocessor ranges are half-open; published node locations are inclusive.
135    let delimiter_len = match passthrough.kind {
136        PassthroughKind::Macro | PassthroughKind::Single => 1,
137        PassthroughKind::Double => 2,
138        PassthroughKind::Triple => 3,
139        PassthroughKind::AttributeRef => {
140            return state.create_block_location(
141                passthrough.location.absolute_start,
142                passthrough.location.absolute_end,
143                0,
144            );
145        }
146    };
147    let total_len = passthrough.location.absolute_end - passthrough.location.absolute_start;
148    let prefix_len = total_len.saturating_sub(content.len() + delimiter_len);
149    let absolute_start = passthrough.location.absolute_start + prefix_len;
150    state.create_block_location(absolute_start, absolute_start + content.len(), 0)
151}
152
153fn process_raw_substitutions<'a>(
154    mut raw: Raw<'a>,
155    substitutions: &[Substitution],
156    state: &ParserState<'a>,
157) -> Vec<InlineNode<'a>> {
158    let Some((substitution, remaining)) = substitutions.split_first() else {
159        return vec![InlineNode::RawText(raw)];
160    };
161
162    if let [Substitution::Quotes, Substitution::Macros, remaining @ ..] = substitutions {
163        let parsed = parse_raw_stage(
164            raw,
165            SubstitutionPlan::from_substitutions(QUOTES_THEN_MACROS),
166            state,
167        );
168        return process_inline_nodes(parsed, remaining, state);
169    }
170
171    match substitution {
172        Substitution::SpecialChars => {
173            if raw.subs.last() == Some(&Substitution::SpecialChars) {
174                // Keep one deferred escape stage. Earlier escapes are literal
175                // text, including when an attribute introduced the fragment.
176                raw.content = state.intern_str(&escape_attribute_characters(raw.content));
177            } else {
178                raw.subs.push(substitution.clone());
179            }
180            process_raw_substitutions(raw, remaining, state)
181        }
182        Substitution::Replacements => {
183            if let Some(fragments) = restore_raw_character_references(&raw, state) {
184                return process_inline_nodes(fragments, remaining, state);
185            }
186            if !raw.subs.contains(&Substitution::Replacements) {
187                raw.subs.push(substitution.clone());
188            }
189            process_raw_substitutions(raw, remaining, state)
190        }
191        Substitution::Attributes => {
192            process_inline_nodes(expand_raw_attributes(&raw, state), remaining, state)
193        }
194        Substitution::Quotes | Substitution::Macros | Substitution::PostReplacements => {
195            process_inline_nodes(
196                parse_raw_substitution(raw, substitution, state),
197                remaining,
198                state,
199            )
200        }
201        // Inline passthroughs do not support callouts. Group variants were
202        // expanded before processing started.
203        Substitution::Callouts | Substitution::Normal | Substitution::Verbatim => {
204            process_raw_substitutions(raw, remaining, state)
205        }
206    }
207}
208
209fn restore_raw_character_references<'a>(
210    raw: &Raw<'a>,
211    state: &ParserState<'a>,
212) -> Option<Vec<InlineNode<'a>>> {
213    if !raw.content.contains('&') {
214        return None;
215    }
216    let mut fragments = Vec::new();
217    let mut cursor = 0;
218    let mut subs = raw.subs.clone();
219    if !subs.contains(&Substitution::Replacements) {
220        subs.push(Substitution::Replacements);
221    }
222    for (amp, _) in raw.content.match_indices('&') {
223        if amp < cursor {
224            continue;
225        }
226        let Some(length) = character_reference_end(&raw.content[amp..]) else {
227            continue;
228        };
229        let end = amp + length;
230        // Keep encoded arrow tokens together for deferred replacements. A prior
231        // escape stage instead protects the authored reference from that rule.
232        let encoded_arrow = match &raw.content[amp..end] {
233            "&gt;" => amp > 0 && matches!(raw.content.as_bytes().get(amp - 1), Some(b'-' | b'=')),
234            "&lt;" => {
235                matches!(raw.content.as_bytes().get(end), Some(b'-' | b'='))
236                    || raw.content[end..].starts_with("\\-&gt;")
237                    || raw.content[end..].starts_with("\\=&gt;")
238            }
239            _ => false,
240        };
241        if encoded_arrow && !raw.subs.contains(&Substitution::SpecialChars) {
242            continue;
243        }
244        let mut start = if amp > cursor && raw.content.as_bytes().get(amp - 1) == Some(&b'\\') {
245            amp - 1
246        } else {
247            amp
248        };
249        let mut prepared = raw.content[start..end].to_owned();
250        for substitution in &raw.subs {
251            if *substitution == Substitution::SpecialChars {
252                prepared = escape_attribute_characters(&prepared);
253            }
254        }
255        let escaped = prepared.starts_with('\\');
256        let reference = prepared.strip_prefix('\\').unwrap_or(&prepared);
257        if let Some((body, _)) = reference
258            .strip_prefix("&amp;")
259            .and_then(|rest| rest.split_once(';'))
260            && is_restorable_character_reference(body)
261        {
262            prepared = if escaped {
263                reference.to_owned()
264            } else {
265                format!("&{}", &reference[5..])
266            };
267        }
268
269        if prepared.starts_with('\\') {
270            start = amp;
271            prepared.remove(0);
272        }
273
274        // Retain an escaping stage for literal references, but remove it from
275        // restored ones so every converter sees the same active character.
276        let mut reference_subs = Vec::new();
277        for (encoded, decoded) in [("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">")] {
278            if let Some(rest) = prepared.strip_prefix(encoded) {
279                prepared = format!("{decoded}{rest}");
280                reference_subs.push(Substitution::SpecialChars);
281                break;
282            }
283        }
284        push_raw_segment(&mut fragments, raw, cursor, start, subs.clone(), state);
285        fragments.push(InlineNode::RawText(Raw {
286            content: state.intern_str(&prepared),
287            location: raw_segment_location(raw, start, end, state),
288            subs: reference_subs,
289        }));
290        cursor = end;
291    }
292    if fragments.is_empty() {
293        return None;
294    }
295    push_raw_segment(&mut fragments, raw, cursor, raw.content.len(), subs, state);
296    Some(fragments)
297}
298
299fn process_inline_nodes<'a>(
300    nodes: Vec<InlineNode<'a>>,
301    substitutions: &[Substitution],
302    state: &ParserState<'a>,
303) -> Vec<InlineNode<'a>> {
304    let Some((substitution, remaining)) = substitutions.split_first() else {
305        return nodes;
306    };
307
308    if matches!(substitution, Substitution::PostReplacements) {
309        let nodes = process_post_replacements(nodes, state);
310        return process_inline_nodes(nodes, remaining, state);
311    }
312
313    if matches!(substitution, Substitution::Quotes | Substitution::Macros) {
314        let (stages, remaining) =
315            if let [Substitution::Quotes, Substitution::Macros, remaining @ ..] = substitutions {
316                (QUOTES_THEN_MACROS, remaining)
317            } else {
318                (std::slice::from_ref(substitution), remaining)
319            };
320        let plan = SubstitutionPlan::from_substitutions(stages);
321        let mut result = Vec::with_capacity(nodes.len());
322        let mut fragments = Vec::new();
323        for mut node in nodes {
324            if let InlineNode::RawText(raw) = node {
325                fragments.push(raw);
326            } else {
327                result.extend(parse_raw_fragments(take(&mut fragments), plan, state));
328                process_inline_children(&mut node, stages, state);
329                result.push(node);
330            }
331        }
332        result.extend(parse_raw_fragments(fragments, plan, state));
333        return process_inline_nodes(result, remaining, state);
334    }
335
336    // Finish this stage across all fragments before later macros join them.
337    let mut result = Vec::with_capacity(nodes.len());
338    for mut node in nodes {
339        if let InlineNode::RawText(raw) = node {
340            result.extend(process_raw_substitutions(
341                raw,
342                std::slice::from_ref(substitution),
343                state,
344            ));
345            continue;
346        }
347        process_inline_children(&mut node, std::slice::from_ref(substitution), state);
348        result.push(node);
349    }
350    process_inline_nodes(result, remaining, state)
351}
352
353fn process_post_replacements<'a>(
354    nodes: Vec<InlineNode<'a>>,
355    state: &ParserState<'a>,
356) -> Vec<InlineNode<'a>> {
357    let mut staged = Vec::with_capacity(nodes.len());
358    for mut node in nodes {
359        if let InlineNode::RawText(raw) = node {
360            staged.extend(parse_raw_substitution(
361                raw,
362                &Substitution::PostReplacements,
363                state,
364            ));
365        } else {
366            process_inline_children(&mut node, &[Substitution::PostReplacements], state);
367            staged.push(node);
368        }
369    }
370    replace_cross_boundary_hardbreaks(staged, state)
371}
372
373fn replace_cross_boundary_hardbreaks<'a>(
374    nodes: Vec<InlineNode<'a>>,
375    state: &ParserState<'a>,
376) -> Vec<InlineNode<'a>> {
377    let mut result = Vec::with_capacity(nodes.len());
378    let mut pending = None;
379
380    for node in nodes {
381        let InlineNode::RawText(right) = node else {
382            if let Some(left) = pending.take() {
383                result.push(InlineNode::RawText(left));
384            }
385            result.push(node);
386            continue;
387        };
388
389        let Some(left) = pending.take() else {
390            pending = Some(right);
391            continue;
392        };
393
394        if is_cross_boundary_hardbreak(&result, &left, &right) {
395            let location = cross_boundary_hardbreak_location(&result, &left, state);
396            remove_cross_boundary_hardbreak_marker(&mut result, left, state);
397            result.push(InlineNode::LineBreak(LineBreak { location }));
398            pending = raw_without_first_byte(right, state);
399        } else {
400            result.push(InlineNode::RawText(left));
401            pending = Some(right);
402        }
403    }
404
405    if let Some(raw) = pending {
406        result.push(InlineNode::RawText(raw));
407    }
408    result
409}
410
411fn is_cross_boundary_hardbreak(result: &[InlineNode<'_>], left: &Raw<'_>, right: &Raw<'_>) -> bool {
412    if !left.content.ends_with('+') || !right.content.starts_with('\n') {
413        return false;
414    }
415    left.content
416        .strip_suffix('+')
417        .and_then(|prefix| prefix.chars().next_back())
418        .or_else(|| match result.last() {
419            Some(InlineNode::RawText(raw)) => raw.content.chars().next_back(),
420            _ => None,
421        })
422        == Some(' ')
423}
424
425fn remove_cross_boundary_hardbreak_marker<'a>(
426    result: &mut Vec<InlineNode<'a>>,
427    left: Raw<'a>,
428    state: &ParserState<'_>,
429) {
430    if left.content.len() > 1 {
431        if let Some(prefix) = raw_without_suffix(left, 2, state) {
432            result.push(InlineNode::RawText(prefix));
433        }
434        return;
435    }
436
437    let Some(InlineNode::RawText(previous)) = result.pop() else {
438        return;
439    };
440    if let Some(prefix) = raw_without_suffix(previous, 1, state) {
441        result.push(InlineNode::RawText(prefix));
442    }
443}
444
445fn raw_without_suffix<'a>(
446    mut raw: Raw<'a>,
447    suffix_len: usize,
448    state: &ParserState<'_>,
449) -> Option<Raw<'a>> {
450    let end = raw.content.len().checked_sub(suffix_len)?;
451    if end == 0 {
452        return None;
453    }
454    raw.location = raw_segment_location(&raw, 0, end, state);
455    raw.content = &raw.content[..end];
456    Some(raw)
457}
458
459fn raw_without_first_byte<'a>(mut raw: Raw<'a>, state: &ParserState<'_>) -> Option<Raw<'a>> {
460    if raw.content.len() <= 1 {
461        return None;
462    }
463    raw.location = raw_segment_location(&raw, 1, raw.content.len(), state);
464    raw.content = &raw.content[1..];
465    Some(raw)
466}
467
468fn cross_boundary_hardbreak_location(
469    preceding: &[InlineNode<'_>],
470    left: &Raw<'_>,
471    state: &ParserState<'_>,
472) -> Location {
473    let start = if left.content.len() > 1 {
474        raw_segment_location(left, left.content.len() - 2, left.content.len(), state).absolute_start
475    } else if let Some(InlineNode::RawText(previous)) = preceding.last() {
476        raw_segment_location(
477            previous,
478            previous.content.len().saturating_sub(1),
479            previous.content.len(),
480            state,
481        )
482        .absolute_start
483    } else {
484        left.location.absolute_start
485    };
486    state.create_location(start, left.location.absolute_end)
487}
488
489fn process_inline_children<'a>(
490    node: &mut InlineNode<'a>,
491    substitutions: &[Substitution],
492    state: &ParserState<'a>,
493) {
494    macro_rules! process_content {
495        ($value:expr) => {
496            $value.content = process_inline_nodes(take(&mut $value.content), substitutions, state)
497        };
498    }
499
500    match node {
501        InlineNode::BoldText(value) => process_content!(value),
502        InlineNode::ItalicText(value) => process_content!(value),
503        InlineNode::MonospaceText(value) => process_content!(value),
504        InlineNode::HighlightText(value) => process_content!(value),
505        InlineNode::SubscriptText(value) => process_content!(value),
506        InlineNode::SuperscriptText(value) => process_content!(value),
507        InlineNode::CurvedQuotationText(value) => process_content!(value),
508        InlineNode::CurvedApostropheText(value) => process_content!(value),
509        InlineNode::Macro(macro_node) => match macro_node {
510            InlineMacro::IndexTerm(value) => {
511                if value.catalog.is_none() {
512                    value.catalog = Some(Box::new((**value).clone()));
513                }
514                value.for_each_label_mut(|nodes| {
515                    *nodes = process_inline_nodes(take(nodes), substitutions, state);
516                });
517            }
518            InlineMacro::Url(value) => {
519                value.text = process_inline_nodes(take(&mut value.text), substitutions, state);
520            }
521            InlineMacro::Link(value) => {
522                value.text = process_inline_nodes(take(&mut value.text), substitutions, state);
523            }
524            InlineMacro::Mailto(value) => {
525                value.text = process_inline_nodes(take(&mut value.text), substitutions, state);
526            }
527            InlineMacro::CrossReference(value) => {
528                value.text = process_inline_nodes(take(&mut value.text), substitutions, state);
529            }
530            // Footnotes retain the body captured at the macro stage.
531            InlineMacro::Footnote(_)
532            | InlineMacro::Icon(_)
533            | InlineMacro::Image(_)
534            | InlineMacro::Keyboard(_)
535            | InlineMacro::Button(_)
536            | InlineMacro::Menu(_)
537            | InlineMacro::Autolink(_)
538            | InlineMacro::Pass(_)
539            | InlineMacro::Stem(_) => {}
540        },
541        InlineNode::PlainText(_)
542        | InlineNode::RawText(_)
543        | InlineNode::VerbatimText(_)
544        | InlineNode::StandaloneCurvedApostrophe(_)
545        | InlineNode::LineBreak(_)
546        | InlineNode::InlineAnchor(_)
547        | InlineNode::CalloutRef(_) => {}
548    }
549}
550
551fn expand_raw_attributes<'a>(raw: &Raw<'a>, state: &ParserState<'a>) -> Vec<InlineNode<'a>> {
552    let mut result = Vec::new();
553    let mut cursor = 0;
554    let mut copied_until = 0;
555    while let Some(relative_start) = raw.content[cursor..].find('{') {
556        let start = cursor + relative_start;
557        let Some(relative_end) = raw.content[start + 1..].find('}') else {
558            break;
559        };
560        let end = start + 1 + relative_end + 1;
561        let name = &raw.content[start + 1..end - 1];
562        if let Some((source_start, literal)) =
563            escaped_raw_attribute_reference(raw, start, end, state)
564        {
565            push_raw_segment(
566                &mut result,
567                raw,
568                copied_until,
569                source_start,
570                raw.subs.clone(),
571                state,
572            );
573            result.push(literal);
574            cursor = end;
575            copied_until = end;
576            continue;
577        }
578        if name.is_empty()
579            || !name
580                .bytes()
581                .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
582        {
583            cursor = end;
584            continue;
585        }
586
587        let Some(resolved) = state.document_attributes.get(name) else {
588            cursor = end;
589            continue;
590        };
591        push_raw_segment(
592            &mut result,
593            raw,
594            copied_until,
595            start,
596            raw.subs.clone(),
597            state,
598        );
599        let reference_location = raw_segment_location(raw, start, end, state);
600        let mut value = String::new();
601        let _ = resolved.write_text(&mut value);
602        cursor = end;
603        copied_until = end;
604        if !resolved.inline_fragments().is_empty() {
605            let passthrough = Pass {
606                attribute_fragments: resolved.inline_fragments().into(),
607                text: Some(state.intern_str(&value)),
608                substitutions: Vec::new(),
609                location: reference_location,
610                kind: PassthroughKind::AttributeRef,
611            };
612            result.extend(process_passthrough(
613                passthrough.text.unwrap_or_default(),
614                &passthrough,
615                state,
616                SubstitutionPlan::only(&Substitution::Attributes),
617            ));
618            continue;
619        }
620        let mut value_cursor = 0;
621        // Prepared ranges have already undergone definition-time substitutions.
622        // Only ordinary attribute text still needs its header escaping here.
623        for range in resolved.passthrough_ranges() {
624            if value_cursor < range.start {
625                result.push(InlineNode::RawText(Raw {
626                    content: state.intern_str(&value[value_cursor..range.start]),
627                    location: reference_location.clone(),
628                    subs: vec![Substitution::SpecialChars],
629                }));
630            }
631            result.push(InlineNode::RawText(Raw {
632                content: state.intern_str(&value[range.clone()]),
633                location: reference_location.clone(),
634                subs: Vec::new(),
635            }));
636            value_cursor = range.end;
637        }
638        if value_cursor < value.len() {
639            result.push(InlineNode::RawText(Raw {
640                content: state.intern_str(&value[value_cursor..]),
641                location: reference_location,
642                subs: vec![Substitution::SpecialChars],
643            }));
644        }
645    }
646    push_raw_segment(
647        &mut result,
648        raw,
649        copied_until,
650        raw.content.len(),
651        raw.subs.clone(),
652        state,
653    );
654    result
655}
656
657fn escaped_raw_attribute_reference<'a>(
658    raw: &Raw<'a>,
659    start: usize,
660    end: usize,
661    state: &ParserState<'a>,
662) -> Option<(usize, InlineNode<'a>)> {
663    let name = &raw.content[start + 1..end - 1];
664    let escaped_start = raw.content[..start].ends_with('\\');
665    if !escaped_start && !name.ends_with('\\') {
666        return None;
667    }
668    let name = name.strip_suffix('\\').unwrap_or(name);
669    if name.is_empty()
670        || !name
671            .bytes()
672            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
673    {
674        return None;
675    }
676    let source_start = start - usize::from(escaped_start);
677    let literal = format!("{{{name}}}");
678    Some((
679        source_start,
680        InlineNode::RawText(Raw {
681            content: state.intern_str(&literal),
682            location: raw_segment_location(raw, source_start, end, state),
683            subs: raw.subs.clone(),
684        }),
685    ))
686}
687
688fn push_raw_segment<'a>(
689    result: &mut Vec<InlineNode<'a>>,
690    raw: &Raw<'a>,
691    start: usize,
692    end: usize,
693    subs: Vec<Substitution>,
694    state: &ParserState<'_>,
695) {
696    if start < end {
697        result.push(InlineNode::RawText(Raw {
698            content: &raw.content[start..end],
699            location: raw_segment_location(raw, start, end, state),
700            subs,
701        }));
702    }
703}
704
705fn raw_segment_location(
706    raw: &Raw<'_>,
707    start: usize,
708    end: usize,
709    state: &ParserState<'_>,
710) -> Location {
711    let Some(source_start) = raw_source_start(raw, state) else {
712        // A generated fragment maps to the complete source reference that produced it.
713        return raw.location.clone();
714    };
715    let start = if start == 0 {
716        raw.location.absolute_start
717    } else {
718        source_start + start
719    };
720    state.create_block_location(start, source_start + end, 0)
721}
722
723fn raw_source_start(raw: &Raw<'_>, state: &ParserState<'_>) -> Option<usize> {
724    let end = step_char(
725        state.input,
726        raw.location.absolute_end,
727        RoundDirection::Forward,
728    );
729    let original = state.input.get(raw.location.absolute_start..end)?;
730    let escapes = original.strip_suffix(raw.content)?;
731    escapes
732        .bytes()
733        .all(|byte| byte == b'\\')
734        .then_some(raw.location.absolute_start + escapes.len())
735}
736
737fn parse_raw_substitution<'a>(
738    raw: Raw<'a>,
739    substitution: &Substitution,
740    state: &ParserState<'a>,
741) -> Vec<InlineNode<'a>> {
742    parse_raw_stage(raw, SubstitutionPlan::only(substitution), state)
743}
744
745fn parse_raw_stage<'a>(
746    raw: Raw<'a>,
747    plan: SubstitutionPlan,
748    state: &ParserState<'a>,
749) -> Vec<InlineNode<'a>> {
750    if raw.content.is_empty() {
751        return Vec::new();
752    }
753
754    let Some(mut parsed) = parse_raw_inlines(raw.content, plan, state) else {
755        return vec![InlineNode::RawText(raw)];
756    };
757    for node in &mut parsed {
758        convert_plain_to_raw_with_source(node, &raw.subs, Some(raw.content));
759        map_stage_locations(node, &raw, state);
760    }
761    parsed
762}
763
764fn parse_raw_inlines<'a>(
765    content: &'a str,
766    plan: SubstitutionPlan,
767    state: &ParserState<'a>,
768) -> Option<Vec<InlineNode<'a>>> {
769    let mut rules = state.inline_ctx.rules;
770    rules.set(InlineRules::AUTOLINKS, plan.enabled(&Substitution::Macros));
771    rules.remove(InlineRules::HARD_BREAKS);
772    rules.set(
773        InlineRules::EOI_HARD_BREAK,
774        plan.enabled(&Substitution::PostReplacements),
775    );
776    let inline_ctx = InlineContext {
777        offset: 0,
778        substitutions: plan,
779        rules,
780    };
781    let mut child = ParserState::for_inline_parsing(content, state, inline_ctx);
782
783    let parsed = if plan.enabled(&Substitution::Quotes) && !plan.enabled(&Substitution::Macros) {
784        inline_parser::quotes_only_inlines(content, &mut child)
785    } else {
786        inline_parser::inlines(content, &mut child)
787    };
788    parsed.ok()
789}
790
791fn map_stage_locations(node: &mut InlineNode<'_>, raw: &Raw<'_>, state: &ParserState<'_>) {
792    super::location_walk::walk_inline_locations_mut(node, &mut |location| {
793        let end = step_char(raw.content, location.absolute_end, RoundDirection::Forward);
794        *location = raw_segment_location(raw, location.absolute_start, end, state);
795    });
796}
797
798// Attribute expansion splits raw text to retain its escaping rules. A later
799// structural stage must still recognize syntax across those fragment boundaries.
800fn parse_raw_fragments<'a>(
801    fragments: Vec<Raw<'a>>,
802    plan: SubstitutionPlan,
803    state: &ParserState<'a>,
804) -> Vec<InlineNode<'a>> {
805    if fragments.len() <= 1 {
806        return fragments
807            .into_iter()
808            .flat_map(|raw| parse_raw_stage(raw, plan, state))
809            .collect();
810    }
811    let text: String = fragments.iter().map(|raw| raw.content).collect();
812    let text = state.intern_str(&text);
813    let Some(mut nodes) = parse_raw_inlines(text, plan, state) else {
814        return fragments.into_iter().map(InlineNode::RawText).collect();
815    };
816    for node in &mut nodes {
817        convert_plain_to_raw_with_source(node, &[], Some(text));
818    }
819    let mut offset = 0;
820    let spans: Vec<_> = fragments
821        .iter()
822        .map(|raw| {
823            let start = offset;
824            offset += raw.content.len();
825            (start..offset, raw)
826        })
827        .collect();
828    let mut nodes = restore_fragment_substitutions(nodes, &spans, text);
829    for node in &mut nodes {
830        super::location_walk::walk_inline_locations_mut(node, &mut |location| {
831            location.absolute_start =
832                fragment_offset(location.absolute_start, &spans, false, state);
833            location.absolute_end = fragment_offset(location.absolute_end, &spans, true, state);
834            location.start = state
835                .line_map
836                .offset_to_position(location.absolute_start, state.input);
837            location.end = state
838                .line_map
839                .offset_to_position(location.absolute_end, state.input);
840        });
841    }
842    nodes
843}
844
845fn fragment_offset(
846    offset: usize,
847    fragments: &[(std::ops::Range<usize>, &Raw<'_>)],
848    end: bool,
849    state: &ParserState<'_>,
850) -> usize {
851    let Some((span, raw)) = fragments
852        .iter()
853        .find(|(span, _)| offset < span.end)
854        .or_else(|| fragments.last())
855    else {
856        return offset;
857    };
858    let relative = offset.saturating_sub(span.start).min(span.len());
859    if let Some(start) = raw_source_start(raw, state) {
860        if relative == 0 && !end {
861            raw.location.absolute_start
862        } else {
863            start + relative
864        }
865    } else if end {
866        raw.location.absolute_end
867    } else {
868        raw.location.absolute_start
869    }
870}
871
872fn restore_fragment_substitutions<'a>(
873    nodes: Vec<InlineNode<'a>>,
874    fragments: &[(std::ops::Range<usize>, &Raw<'a>)],
875    text: &str,
876) -> Vec<InlineNode<'a>> {
877    let mut result = Vec::with_capacity(nodes.len());
878    for mut node in nodes {
879        if let InlineNode::RawText(raw) = &node
880            && raw.subs.is_empty()
881        {
882            let original_start = raw.location.absolute_start;
883            // Escaped macro text no longer contains its leading backslash.
884            let start = text
885                .get(
886                    original_start
887                        ..step_char(text, raw.location.absolute_end, RoundDirection::Forward),
888                )
889                .and_then(|source| source.find(raw.content))
890                .map_or(original_start, |offset| original_start + offset);
891            let end = start + raw.content.len();
892            for (span, fragment) in fragments {
893                let left = start.max(span.start);
894                let right = end.min(span.end);
895                if left < right {
896                    result.push(InlineNode::RawText(Raw {
897                        content: &raw.content[left - start..right - start],
898                        location: Location {
899                            absolute_start: if left == start { original_start } else { left },
900                            absolute_end: step_char(text, right, RoundDirection::Backward),
901                            ..raw.location.clone()
902                        },
903                        subs: fragment.subs.clone(),
904                    }));
905                }
906            }
907            continue;
908        }
909        for_each_inline_children(&mut node, &mut |children| {
910            *children = restore_fragment_substitutions(take(children), fragments, text);
911        });
912        result.push(node);
913    }
914    result
915}
916
917pub(crate) fn convert_plain_to_raw(node: &mut InlineNode<'_>, subs: &[Substitution]) {
918    convert_plain_to_raw_with_source(node, subs, None);
919}
920
921fn convert_plain_to_raw_with_source(
922    node: &mut InlineNode<'_>,
923    subs: &[Substitution],
924    source: Option<&str>,
925) {
926    if let InlineNode::PlainText(plain) = node {
927        if let Some(source) = source {
928            let start = plain.location.absolute_start;
929            let end = start + plain.content.len();
930            if source.get(start..end) == Some(plain.content) {
931                plain.location.absolute_end = step_char(source, end, RoundDirection::Backward);
932            }
933        }
934        *node = InlineNode::RawText(Raw {
935            content: plain.content,
936            location: plain.location.clone(),
937            subs: subs.to_vec(),
938        });
939        return;
940    }
941    for_each_inline_children(node, &mut |children| {
942        for child in children {
943            convert_plain_to_raw_with_source(child, subs, source);
944        }
945    });
946}
947
948fn for_each_inline_children<'a>(
949    node: &mut InlineNode<'a>,
950    visit: &mut impl FnMut(&mut Vec<InlineNode<'a>>),
951) {
952    match node {
953        InlineNode::BoldText(value) => visit(&mut value.content),
954        InlineNode::ItalicText(value) => visit(&mut value.content),
955        InlineNode::MonospaceText(value) => visit(&mut value.content),
956        InlineNode::HighlightText(value) => visit(&mut value.content),
957        InlineNode::SubscriptText(value) => visit(&mut value.content),
958        InlineNode::SuperscriptText(value) => visit(&mut value.content),
959        InlineNode::CurvedQuotationText(value) => visit(&mut value.content),
960        InlineNode::CurvedApostropheText(value) => visit(&mut value.content),
961        InlineNode::Macro(macro_node) => match macro_node {
962            InlineMacro::Footnote(value) => visit(&mut value.content),
963            InlineMacro::Url(value) => visit(&mut value.text),
964            InlineMacro::Link(value) => visit(&mut value.text),
965            InlineMacro::Mailto(value) => visit(&mut value.text),
966            InlineMacro::CrossReference(value) => visit(&mut value.text),
967            InlineMacro::IndexTerm(value) => {
968                value.for_each_label_mut(&mut *visit);
969                if let Some(catalog) = &mut value.catalog {
970                    catalog.for_each_label_mut(&mut *visit);
971                }
972            }
973            InlineMacro::Icon(_)
974            | InlineMacro::Image(_)
975            | InlineMacro::Keyboard(_)
976            | InlineMacro::Button(_)
977            | InlineMacro::Menu(_)
978            | InlineMacro::Autolink(_)
979            | InlineMacro::Pass(_)
980            | InlineMacro::Stem(_) => {}
981        },
982        InlineNode::PlainText(_)
983        | InlineNode::RawText(_)
984        | InlineNode::VerbatimText(_)
985        | InlineNode::StandaloneCurvedApostrophe(_)
986        | InlineNode::LineBreak(_)
987        | InlineNode::InlineAnchor(_)
988        | InlineNode::CalloutRef(_) => {}
989    }
990}
991
992/// Parse text for inline formatting markup (bold, italic, monospace, etc.).
993///
994/// Public entry point — returns a `ParseInlineResult` that owns the arena
995/// the resulting `InlineNode`s borrow from. Callers reach the nodes via
996/// `.inlines()`. Each call allocates a fresh arena; memory is reclaimed
997/// when the returned value is dropped (no leaks). The returned
998/// `ParseInlineResult::warnings()` slice is always empty for this entry
999/// point — the quotes-only grammar never raises warnings.
1000///
1001/// # Supported Patterns
1002///
1003/// - `*bold*` and `**bold**` (constrained/unconstrained)
1004/// - `_italic_` and `__italic__`
1005/// - `` `monospace` `` and ``` ``monospace`` ```
1006/// - `^superscript^` and `~subscript~`
1007/// - `#highlight#` and `##highlight##`
1008/// - `` "`curved quotes`" `` and `` '`curved apostrophe`' ``
1009///
1010/// # Example
1011///
1012/// ```
1013/// use acdc_parser::parse_text_for_quotes;
1014///
1015/// let parsed = parse_text_for_quotes("This has *bold* text.");
1016/// assert_eq!(parsed.inlines().len(), 3); // "This has ", Bold("bold"), " text."
1017/// ```
1018pub fn parse_text_for_quotes(content: &str) -> ParseInlineResult {
1019    let owner = OwnedInput::new(content.into());
1020    ParseInlineResult::from_infallible(owner, |owner| {
1021        parse_text_for_quotes_in(&owner.arena, &owner.source)
1022    })
1023}
1024
1025/// Arena-parameterised variant for internal callers that already have an
1026/// arena threaded through `ParserState`. Avoids the per-call `Bump`
1027/// allocation that the public entry point does.
1028pub(crate) fn parse_text_for_quotes_in<'a>(
1029    arena: &'a Bump,
1030    content: &'a str,
1031) -> Vec<InlineNode<'a>> {
1032    if content.is_empty() {
1033        return Vec::new();
1034    }
1035
1036    // Fast path: if content has no formatting markers, return as plain text
1037    // without creating a ParserState or invoking the PEG parser.
1038    // Covers ~87% of calls in typical documents.
1039    if !content
1040        .bytes()
1041        .any(|b| matches!(b, b'*' | b'_' | b'`' | b'#' | b'^' | b'~' | b'"' | b'\''))
1042    {
1043        return vec![InlineNode::PlainText(Plain {
1044            content,
1045            location: Location::default(),
1046            escaped: false,
1047        })];
1048    }
1049
1050    let mut state = ParserState::new_quotes_only(content, arena);
1051    if let Ok(nodes) = inline_parser::quotes_only_inlines(content, &mut state) {
1052        nodes
1053    } else {
1054        tracing::warn!(
1055            input_len = content.len(),
1056            "quotes-only PEG parse failed, falling back to plain text"
1057        );
1058        vec![InlineNode::PlainText(Plain {
1059            content,
1060            location: Location::default(),
1061            escaped: false,
1062        })]
1063    }
1064}
1065
1066/// Locate a text fragment using byte offsets and the document's line map.
1067fn plain_text_at<'a>(
1068    text: &'a str,
1069    base_location: &Location,
1070    offset: usize,
1071    state: &ParserState<'_>,
1072) -> InlineNode<'a> {
1073    let start = base_location.absolute_start + offset;
1074    InlineNode::PlainText(Plain {
1075        content: text,
1076        location: state.create_block_location(start, start + text.len(), 0),
1077        escaped: false,
1078    })
1079}
1080
1081/// Process passthrough placeholders in content, returning expanded `InlineNode`s.
1082///
1083/// This function handles the multi-pass parsing needed for passthroughs with quote substitutions.
1084/// It splits the content around placeholders and processes each passthrough according to its
1085/// substitution settings.
1086pub(crate) fn process_passthrough_placeholders<'a>(
1087    content: &'a str,
1088    processed: &ProcessedContent<'a>,
1089    state: &ParserState<'a>,
1090    base_location: &Location,
1091) -> Vec<InlineNode<'a>> {
1092    // Each passthrough produces at most (placeholder-count × small factor) +
1093    // one trailing-plain. Upper-bound at 2 × placeholders + 1 so a paragraph
1094    // full of passthroughs doesn't trigger log-N reallocs.
1095    let mut result = Vec::with_capacity(processed.passthroughs.len() * 2 + 1);
1096    let mut remaining = content;
1097    let mut processed_offset = 0; // Position in the processed content (with placeholders)
1098
1099    // Process each passthrough placeholder in order
1100    for (index, passthrough) in processed.passthroughs.iter().enumerate() {
1101        let placeholder = format!("���{index}���");
1102
1103        if let Some(placeholder_pos) = remaining.find(&placeholder) {
1104            let before_content = if placeholder_pos > 0 {
1105                Some(&remaining[..placeholder_pos])
1106            } else {
1107                None
1108            };
1109
1110            // Add content before the placeholder if any, using original string positions
1111            if let Some(before) = before_content
1112                && !before.is_empty()
1113            {
1114                result.push(plain_text_at(
1115                    before,
1116                    base_location,
1117                    processed_offset,
1118                    state,
1119                ));
1120                processed_offset += before.len();
1121            }
1122
1123            // Process the passthrough content using original string positions from passthrough.location
1124            if let Some(passthrough_content) = &passthrough.text {
1125                let processed_nodes = process_passthrough(
1126                    passthrough_content,
1127                    passthrough,
1128                    state,
1129                    processed.attribute_substitutions,
1130                );
1131                for node in processed_nodes {
1132                    result.push(node);
1133                }
1134            }
1135
1136            // Move past the placeholder in the processed content
1137            let skip_len = placeholder_pos + placeholder.len();
1138            remaining = &remaining[skip_len..];
1139            // Update processed_offset to account for the original passthrough macro length
1140            processed_offset +=
1141                passthrough.location.absolute_end - passthrough.location.absolute_start;
1142        }
1143    }
1144
1145    // Add any remaining content as plain text
1146    if !remaining.is_empty() {
1147        // Check if the last node is PlainText and merge if so
1148        if let Some(InlineNode::PlainText(last_plain)) = result.last_mut() {
1149            // Merge remaining content with the last plain text node
1150            last_plain.content =
1151                state.intern_fmt(format_args!("{}{remaining}", last_plain.content));
1152            // Extend the location to include the remaining content
1153            last_plain.location.absolute_end = base_location.absolute_end;
1154            last_plain.location.end = base_location.end.clone();
1155        } else {
1156            // Add as separate node if last node is not plain text. Extend
1157            // the end to cover `base_location.end` (this is the final
1158            // trailing segment).
1159            let mut node = plain_text_at(remaining, base_location, processed_offset, state);
1160            if let InlineNode::PlainText(ref mut p) = node {
1161                p.location.absolute_end = base_location.absolute_end;
1162                p.location.end = base_location.end.clone();
1163            }
1164            result.push(node);
1165        }
1166    }
1167
1168    // Clamp all locations to valid bounds within the input string
1169    for node in &mut result {
1170        clamp_inline_node_locations(node, state.input);
1171    }
1172
1173    // Merge adjacent plain text nodes
1174    merge_adjacent_plain_text_nodes(state, result)
1175}
1176
1177/// Merge adjacent plain text nodes into single nodes to simplify the output.
1178/// Arena-interns the concatenated content so the merged node keeps lifetime `'a`.
1179pub(crate) fn merge_adjacent_plain_text_nodes<'a>(
1180    state: &ParserState<'a>,
1181    nodes: Vec<InlineNode<'a>>,
1182) -> Vec<InlineNode<'a>> {
1183    // Worst case: no merges possible, so the output matches the input length.
1184    let mut result: Vec<InlineNode<'a>> = Vec::with_capacity(nodes.len());
1185
1186    for node in nodes {
1187        match (result.last_mut(), node) {
1188            (Some(InlineNode::PlainText(last_plain)), InlineNode::PlainText(current_plain)) => {
1189                // Merge current plain text with the last one
1190                last_plain.content = state.intern_fmt(format_args!(
1191                    "{}{}",
1192                    last_plain.content, current_plain.content
1193                ));
1194                // Extend the location to cover both nodes
1195                last_plain.location.absolute_end = current_plain.location.absolute_end;
1196                last_plain.location.end = current_plain.location.end;
1197            }
1198            (_, node) => {
1199                // Not adjacent plain text nodes, add as separate node
1200                result.push(node);
1201            }
1202        }
1203    }
1204
1205    result
1206}
1207
1208pub(crate) fn replace_passthrough_placeholders(
1209    content: &str,
1210    processed: &ProcessedContent,
1211) -> String {
1212    let mut result: String = content.into();
1213
1214    // Replace each passthrough placeholder with its content
1215    for (index, passthrough) in processed.passthroughs.iter().enumerate() {
1216        let placeholder = format!("���{index}���");
1217        if let Some(text) = &passthrough.text {
1218            result = result.replace(&placeholder, text);
1219        }
1220    }
1221
1222    result
1223}
1224
1225#[cfg(test)]
1226#[allow(clippy::indexing_slicing)] // Tests verify length before indexing
1227mod tests {
1228    use super::*;
1229
1230    // === Divergence Prevention Tests ===
1231    //
1232    // These tests verify that parse_text_for_quotes produces the same structural
1233    // output as the main PEG parser for common inline formatting patterns.
1234    // If these tests fail after grammar changes, update parse_text_for_quotes.
1235
1236    #[test]
1237    fn test_constrained_bold_pattern() {
1238        let parsed = parse_text_for_quotes("This is *bold* text.");
1239        let nodes = parsed.inlines();
1240        assert_eq!(nodes.len(), 3);
1241        assert!(matches!(nodes[0], InlineNode::PlainText(_)));
1242        assert!(
1243            matches!(&nodes[1], InlineNode::BoldText(b) if matches!(b.content.first(), Some(InlineNode::PlainText(p)) if p.content == "bold"))
1244        );
1245        assert!(matches!(nodes[2], InlineNode::PlainText(_)));
1246    }
1247
1248    #[test]
1249    fn test_unconstrained_bold_pattern() {
1250        let parsed = parse_text_for_quotes("This**bold**word");
1251        let nodes = parsed.inlines();
1252        assert_eq!(nodes.len(), 3);
1253        assert!(
1254            matches!(&nodes[1], InlineNode::BoldText(b) if matches!(b.content.first(), Some(InlineNode::PlainText(p)) if p.content == "bold"))
1255        );
1256    }
1257
1258    #[test]
1259    fn test_constrained_italic_pattern() {
1260        let parsed = parse_text_for_quotes("This is _italic_ text.");
1261        let nodes = parsed.inlines();
1262        assert_eq!(nodes.len(), 3);
1263        assert!(
1264            matches!(&nodes[1], InlineNode::ItalicText(i) if matches!(i.content.first(), Some(InlineNode::PlainText(p)) if p.content == "italic"))
1265        );
1266    }
1267
1268    #[test]
1269    fn test_unconstrained_italic_pattern() {
1270        let parsed = parse_text_for_quotes("This__italic__word");
1271        let nodes = parsed.inlines();
1272        assert_eq!(nodes.len(), 3);
1273        assert!(
1274            matches!(&nodes[1], InlineNode::ItalicText(i) if matches!(i.content.first(), Some(InlineNode::PlainText(p)) if p.content == "italic"))
1275        );
1276    }
1277
1278    #[test]
1279    fn test_constrained_monospace_pattern() {
1280        let parsed = parse_text_for_quotes("Use `code` here.");
1281        let nodes = parsed.inlines();
1282        assert_eq!(nodes.len(), 3);
1283        assert!(
1284            matches!(&nodes[1], InlineNode::MonospaceText(m) if matches!(m.content.first(), Some(InlineNode::PlainText(p)) if p.content == "code"))
1285        );
1286    }
1287
1288    #[test]
1289    fn test_superscript_pattern() {
1290        let parsed = parse_text_for_quotes("E=mc^2^");
1291        let nodes = parsed.inlines();
1292        assert_eq!(nodes.len(), 2);
1293        assert!(
1294            matches!(&nodes[1], InlineNode::SuperscriptText(s) if matches!(s.content.first(), Some(InlineNode::PlainText(p)) if p.content == "2"))
1295        );
1296    }
1297
1298    #[test]
1299    fn test_subscript_pattern() {
1300        let parsed = parse_text_for_quotes("H~2~O");
1301        let nodes = parsed.inlines();
1302        assert_eq!(nodes.len(), 3);
1303        assert!(
1304            matches!(&nodes[1], InlineNode::SubscriptText(s) if matches!(s.content.first(), Some(InlineNode::PlainText(p)) if p.content == "2"))
1305        );
1306    }
1307
1308    #[test]
1309    fn test_highlight_pattern() {
1310        let parsed = parse_text_for_quotes("This is #highlighted# text.");
1311        let nodes = parsed.inlines();
1312        assert_eq!(nodes.len(), 3);
1313        assert!(
1314            matches!(&nodes[1], InlineNode::HighlightText(h) if matches!(h.content.first(), Some(InlineNode::PlainText(p)) if p.content == "highlighted"))
1315        );
1316    }
1317
1318    #[test]
1319    fn test_escaped_superscript_not_parsed() {
1320        // Backslash-escaped markers should not be parsed as formatting
1321        let parsed = parse_text_for_quotes(r"E=mc\^2^");
1322        let nodes = parsed.inlines();
1323        // Should remain as plain text (escape prevents parsing)
1324        assert!(
1325            nodes.iter().all(|n| matches!(n, InlineNode::PlainText(_))),
1326            "Escaped superscript should not be parsed"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_escaped_subscript_not_parsed() {
1332        let parsed = parse_text_for_quotes(r"H\~2~O");
1333        let nodes = parsed.inlines();
1334        assert!(
1335            nodes.iter().all(|n| matches!(n, InlineNode::PlainText(_))),
1336            "Escaped subscript should not be parsed"
1337        );
1338    }
1339
1340    #[test]
1341    fn test_multiple_formats_in_sequence() {
1342        let parsed = parse_text_for_quotes("*bold* and _italic_ and `code`");
1343        let nodes = parsed.inlines();
1344        assert!(nodes.iter().any(|n| matches!(n, InlineNode::BoldText(_))));
1345        assert!(nodes.iter().any(|n| matches!(n, InlineNode::ItalicText(_))));
1346        assert!(
1347            nodes
1348                .iter()
1349                .any(|n| matches!(n, InlineNode::MonospaceText(_)))
1350        );
1351    }
1352
1353    #[test]
1354    fn test_plain_text_only() {
1355        let parsed = parse_text_for_quotes("Just plain text here.");
1356        let nodes = parsed.inlines();
1357        assert_eq!(nodes.len(), 1);
1358        assert!(matches!(nodes[0], InlineNode::PlainText(_)));
1359    }
1360
1361    #[test]
1362    fn test_empty_input() {
1363        let parsed = parse_text_for_quotes("");
1364        let nodes = parsed.inlines();
1365        assert_eq!(nodes, []);
1366    }
1367}