Skip to main content

asciidoc_parser/parser/
source_map.rs

1use std::fmt;
2
3use crate::Span;
4
5/// Generated by the preprocessor: a map from a line number in the preprocessed,
6/// unified source ([`Span`]) back to the original input file and line it came
7/// from. This lets a consumer work backwards from a parsed element's [`Span`]
8/// to the file and line the author actually wrote.
9///
10/// The map is stored as a sparse, sorted list of segments. Each segment anchors
11/// one preprocessed line to an origin (file, line); the lines that follow it
12/// map by simple offset until the next segment. A segment also records the
13/// [`Fidelity`] of the preprocessed line relative to its origin – whether the
14/// preprocessor rewrote the line content (so a column can no longer be mapped
15/// back faithfully) or left it verbatim. All line numbers are 1-based.
16///
17/// Origin file names are interned: a file included many times, or re-anchored
18/// many times, stores its path only once.
19///
20/// [`Span`]: crate::Span
21#[derive(Clone, Default, Eq, PartialEq)]
22pub struct SourceMap {
23    /// Interned origin file names. A [`Segment::file`] of `Some(i)` indexes
24    /// here; `None` means the root document passed to `Parser::parse`.
25    files: Vec<String>,
26
27    /// The anchor segments, sorted by [`Segment::output_line`].
28    segments: Vec<Segment>,
29}
30
31/// A single anchor in a [`SourceMap`]: the preprocessed line
32/// [`output_line`](Self::output_line) came from origin line
33/// [`source_line`](Self::source_line) of [`file`](Self::file), with the given
34/// [`fidelity`](Self::fidelity). Preprocessed lines after it map by offset
35/// (`source_line + n` for `output_line + n`) until the next segment.
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37struct Segment {
38    output_line: usize,
39    file: Option<u32>,
40    source_line: usize,
41    fidelity: Fidelity,
42}
43
44/// A `SourceLine` represents the original file and line number where a line of
45/// AsciiDoc text was found before [include file] and [conditional]
46/// pre-processing occurred.
47///
48/// The first member is the file name as specified on the [include file]
49/// directive. The second member is the 1-based line number.
50///
51/// [include file]: https://docs.asciidoctor.org/asciidoc/latest/directives/include/
52/// [conditional]: https://docs.asciidoctor.org/asciidoc/latest/directives/conditionals/
53#[derive(Clone, Debug, Default, Eq, PartialEq)]
54pub struct SourceLine(pub Option<String>, pub usize);
55
56/// The origin of a position in the preprocessed source: the input file, line,
57/// and (where recoverable) column the author actually wrote, together with the
58/// [`Fidelity`] of the mapping.
59///
60/// Returned by [`SourceMap::origin_at`], [`SourceMap::origin_of`], and
61/// [`Document::origin_of`](crate::Document::origin_of).
62///
63/// The `file` is the name as written on the [include file] directive that
64/// pulled it in, or `None` for the root document passed to `Parser::parse`.
65///
66/// `line` and `col` are 1-based. `col` counts **Unicode scalar values** from
67/// the start of the line (matching [`Span::col`]), not grapheme clusters or
68/// UTF-16 code units; an editor that counts grapheme clusters may report a
69/// different column for a line containing multi-scalar graphemes. `col` is
70/// `Some` only when [`fidelity`](Self::fidelity) is [`Fidelity::Verbatim`]: on
71/// a line the preprocessor rewrote, the column can no longer be mapped back to
72/// the origin, so it is reported as `None`.
73///
74/// [include file]: https://docs.asciidoctor.org/asciidoc/latest/directives/include/
75/// [`Span::col`]: crate::Span::col
76#[derive(Clone, Copy, Debug, Eq, PartialEq)]
77pub struct Origin<'a> {
78    /// The origin file, or `None` for the root document.
79    pub file: Option<&'a str>,
80
81    /// 1-based line within `file`.
82    pub line: usize,
83
84    /// 1-based column (Unicode scalar values) within the origin line, or `None`
85    /// when the preprocessor rewrote the line so the column cannot be mapped
86    /// back. See the type-level documentation.
87    pub col: Option<usize>,
88
89    /// How the preprocessed line relates to its origin line.
90    pub fidelity: Fidelity,
91}
92
93/// Describes how a preprocessed line relates to the origin line it maps to, so
94/// a consumer can tell whether a column (and the line content itself) can be
95/// trusted to match the original file.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum Fidelity {
98    /// The preprocessed line is byte-for-byte identical to the origin line. Its
99    /// line and column map straight back to the original file.
100    Verbatim,
101
102    /// The preprocessed line corresponds to a real origin line, but the
103    /// preprocessor changed its content (the named [`Transform`]), so the line
104    /// still maps but the column does not.
105    Transformed(Transform),
106
107    /// The preprocessed line has no verbatim origin line: the preprocessor
108    /// generated it (the named [`Transform`]). The reported line points at the
109    /// directive that produced it, as a best-effort anchor.
110    Synthetic(Transform),
111}
112
113/// Identifies which preprocessor transform is responsible for a non-verbatim
114/// line. Informational: it tells a consumer *why* a column is unavailable.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum Transform {
117    /// Tabs were expanded to spaces per the `tabsize` attribute.
118    TabExpansion,
119
120    /// Leading indentation was normalized per the `indent` attribute.
121    Reindent,
122
123    /// A synthetic `:leveloffset:` attribute entry wrapping an include.
124    LevelOffsetWrapper,
125
126    /// A rewrite of an `include::` directive into a `link:` macro: applied to
127    /// every target at `SafeMode::Secure` and above, and to a remote (URI)
128    /// target below secure when `allow-uri-read` is unset.
129    SecureLinkRewrite,
130
131    /// A synthetic "Unresolved directive" line replacing an include that could
132    /// not be resolved.
133    UnresolvedDirective,
134
135    /// The line was otherwise rewritten so its columns no longer align with the
136    /// origin: an escaped directive with its leading backslash removed, or the
137    /// spliced-in content of a single-line conditional.
138    Rewritten,
139}
140
141impl SourceMap {
142    pub(crate) fn append(
143        &mut self,
144        output_line: usize,
145        file: Option<&str>,
146        source_line: usize,
147        fidelity: Fidelity,
148    ) {
149        let file = self.intern(file);
150
151        // Segments must be appended in non-decreasing `output_line` order:
152        // `resolve` binary-searches `segments` by `output_line`, which silently
153        // returns garbage on an unsorted slice. Lock that invariant here so an
154        // out-of-order call site fails loudly in debug builds rather than
155        // corrupting every downstream origin lookup.
156        debug_assert!(
157            self.segments
158                .last()
159                .is_none_or(|last| output_line >= last.output_line),
160            "SourceMap::append called out of order: output_line {output_line} follows {}",
161            self.segments.last().map_or(0, |last| last.output_line),
162        );
163
164        self.segments.push(Segment {
165            output_line,
166            file,
167            source_line,
168            fidelity,
169        });
170    }
171
172    /// Intern an origin file name, returning its id. `None` (the root document)
173    /// is represented directly and interns nothing.
174    fn intern(&mut self, file: Option<&str>) -> Option<u32> {
175        let name = file?;
176
177        if let Some(i) = self.files.iter().position(|f| f == name) {
178            return Some(i as u32);
179        }
180
181        let id = self.files.len() as u32;
182        self.files.push(name.to_owned());
183        Some(id)
184    }
185
186    /// Resolve the segment covering preprocessed line `key`, returning the
187    /// origin file id, origin line, and fidelity. A `key` before the first
188    /// segment (or an empty map) maps to the root document, verbatim, at the
189    /// same line number.
190    fn resolve(&self, key: usize) -> (Option<u32>, usize, Fidelity) {
191        // The exact match and the segment-before-`key` cases share one formula
192        // (`source_line + key - output_line`, which is `source_line` on an exact
193        // match). A `key` before the first segment has no governing segment.
194        let segment = match self.segments.binary_search_by_key(&key, |s| s.output_line) {
195            Ok(i) => self.segments.get(i),
196            Err(0) => None,
197            Err(i) => self.segments.get(i - 1),
198        };
199
200        match segment {
201            Some(s) => (s.file, s.source_line + key - s.output_line, s.fidelity),
202            None => (None, key, Fidelity::Verbatim),
203        }
204    }
205
206    /// Reconstruct the anchor segments as `(output_line, file, source_line)`
207    /// tuples, in order. Used by the test fixtures to compare an observed map
208    /// against an expected one (fidelity is not compared there).
209    #[cfg(test)]
210    pub(crate) fn anchors(&self) -> impl Iterator<Item = (usize, Option<&str>, usize)> {
211        self.segments
212            .iter()
213            .map(|s| (s.output_line, self.file_name(s.file), s.source_line))
214    }
215
216    /// Resolve an interned file id to its name.
217    fn file_name(&self, id: Option<u32>) -> Option<&str> {
218        id.and_then(|i| self.files.get(i as usize))
219            .map(String::as_str)
220    }
221
222    /// Given a 1-based line number in the preprocessed source file, translate
223    /// that to a file name and line number as original inputs to the parsing
224    /// process.
225    ///
226    /// This is line-granular only; use [`origin_at`](Self::origin_at) or
227    /// [`origin_of`](Self::origin_of) to also recover a column (on verbatim
228    /// lines) and the [`Fidelity`] of the mapping.
229    pub fn original_file_and_line(&self, key: usize) -> Option<SourceLine> {
230        let (file, source_line, _) = self.resolve(key);
231        Some(SourceLine(
232            self.file_name(file).map(str::to_owned),
233            source_line,
234        ))
235    }
236
237    /// Translate a 1-based (line, column) position in the preprocessed source
238    /// back to its [`Origin`] in the original input files.
239    ///
240    /// `col` counts Unicode scalar values, as [`Span::col`] does. It is carried
241    /// through to [`Origin::col`] only when the line is [`Fidelity::Verbatim`];
242    /// otherwise [`Origin::col`] is `None`. See [`Origin`] for the column-unit
243    /// contract.
244    ///
245    /// [`Span::col`]: crate::Span::col
246    pub fn origin_at(&self, line: usize, col: usize) -> Origin<'_> {
247        let (file, source_line, fidelity) = self.resolve(line);
248
249        Origin {
250            file: self.file_name(file),
251            line: source_line,
252            col: matches!(fidelity, Fidelity::Verbatim).then_some(col),
253            fidelity,
254        }
255    }
256
257    /// Translate the start of `span` back to its [`Origin`] in the original
258    /// input files. Convenience for `origin_at(span.line(), span.col())`.
259    pub fn origin_of(&self, span: Span<'_>) -> Origin<'_> {
260        self.origin_at(span.line(), span.col())
261    }
262}
263
264impl fmt::Debug for SourceMap {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        f.write_str("SourceMap(&")?;
267        f.debug_list()
268            .entries(self.segments.iter().map(|s| {
269                (
270                    s.output_line,
271                    SourceLine(self.file_name(s.file).map(str::to_owned), s.source_line),
272                    s.fidelity,
273                )
274            }))
275            .finish()?;
276        f.write_str(")")
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    #![allow(clippy::unwrap_used)]
283
284    use crate::parser::{
285        SourceLine, SourceMap,
286        source_map::{Fidelity, Transform},
287    };
288
289    fn append(sm: &mut SourceMap, output_line: usize, file: Option<&str>, source_line: usize) {
290        sm.append(output_line, file, source_line, Fidelity::Verbatim);
291    }
292
293    #[test]
294    fn empty() {
295        let sm = SourceMap::default();
296        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
297    }
298
299    #[test]
300    fn one_entry() {
301        let mut sm = SourceMap::default();
302        append(&mut sm, 1, None, 1);
303
304        assert_eq!(sm.original_file_and_line(0), Some(SourceLine(None, 0)));
305        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
306        assert_eq!(sm.original_file_and_line(4), Some(SourceLine(None, 4)));
307
308        assert_eq!(
309            sm.original_file_and_line(4000),
310            Some(SourceLine(None, 4000))
311        );
312    }
313
314    #[test]
315    fn multiple_entries() {
316        let mut sm = SourceMap::default();
317        append(&mut sm, 1, None, 1);
318        append(&mut sm, 10, Some("foo.adoc"), 1);
319        append(&mut sm, 20, Some("bar.adoc"), 18);
320        append(&mut sm, 30, None, 11);
321
322        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
323        assert_eq!(sm.original_file_and_line(4), Some(SourceLine(None, 4)));
324
325        assert_eq!(
326            sm.original_file_and_line(10),
327            Some(SourceLine(Some("foo.adoc".to_owned()), 1))
328        );
329        assert_eq!(
330            sm.original_file_and_line(19),
331            Some(SourceLine(Some("foo.adoc".to_owned()), 10))
332        );
333
334        assert_eq!(
335            sm.original_file_and_line(20),
336            Some(SourceLine(Some("bar.adoc".to_owned()), 18))
337        );
338        assert_eq!(
339            sm.original_file_and_line(21),
340            Some(SourceLine(Some("bar.adoc".to_owned()), 19))
341        );
342        assert_eq!(
343            sm.original_file_and_line(29),
344            Some(SourceLine(Some("bar.adoc".to_owned()), 27))
345        );
346
347        assert_eq!(sm.original_file_and_line(30), Some(SourceLine(None, 11)));
348        assert_eq!(sm.original_file_and_line(40), Some(SourceLine(None, 21)));
349    }
350
351    #[test]
352    fn interns_repeated_file_names() {
353        let mut sm = SourceMap::default();
354        append(&mut sm, 1, Some("foo.adoc"), 1);
355        append(&mut sm, 5, Some("foo.adoc"), 10);
356        append(&mut sm, 9, Some("foo.adoc"), 20);
357
358        // The same file name is stored once regardless of how many segments
359        // reference it.
360        assert_eq!(sm.files, vec!["foo.adoc".to_owned()]);
361
362        assert_eq!(
363            sm.original_file_and_line(6),
364            Some(SourceLine(Some("foo.adoc".to_owned()), 11))
365        );
366    }
367
368    #[test]
369    fn origin_at_reports_column_only_when_verbatim() {
370        let mut sm = SourceMap::default();
371        sm.append(1, None, 1, Fidelity::Verbatim);
372        sm.append(
373            2,
374            Some("inc.adoc"),
375            4,
376            Fidelity::Transformed(Transform::TabExpansion),
377        );
378        sm.append(3, Some("inc.adoc"), 5, Fidelity::Verbatim);
379
380        let verbatim = sm.origin_at(1, 7);
381        assert_eq!(verbatim.file, None);
382        assert_eq!(verbatim.line, 1);
383        assert_eq!(verbatim.col, Some(7));
384        assert_eq!(verbatim.fidelity, Fidelity::Verbatim);
385
386        let transformed = sm.origin_at(2, 7);
387        assert_eq!(transformed.file, Some("inc.adoc"));
388        assert_eq!(transformed.line, 4);
389        assert_eq!(transformed.col, None);
390        assert_eq!(
391            transformed.fidelity,
392            Fidelity::Transformed(Transform::TabExpansion)
393        );
394
395        let after = sm.origin_at(3, 2);
396        assert_eq!(after.file, Some("inc.adoc"));
397        assert_eq!(after.line, 5);
398        assert_eq!(after.col, Some(2));
399    }
400
401    #[test]
402    #[cfg(debug_assertions)]
403    #[should_panic(expected = "SourceMap::append called out of order")]
404    fn append_out_of_order_panics_in_debug() {
405        let mut sm = SourceMap::default();
406        append(&mut sm, 10, None, 1);
407
408        // An earlier `output_line` than the last segment breaks the ordering
409        // that `resolve`'s binary search relies on, so it must fail loudly.
410        append(&mut sm, 5, None, 1);
411    }
412
413    #[test]
414    fn append_equal_output_line_is_allowed() {
415        let mut sm = SourceMap::default();
416        append(&mut sm, 1, None, 1);
417
418        // Equal `output_line` keeps the slice sorted, so it is permitted.
419        sm.append(1, None, 1, Fidelity::Verbatim);
420    }
421
422    #[test]
423    fn impl_debug() {
424        let mut sm = SourceMap::default();
425        append(&mut sm, 1, None, 1);
426
427        assert_eq!(
428            format!("{sm:#?}"),
429            "SourceMap(&[\n    (\n        1,\n        SourceLine(\n            None,\n            1,\n        ),\n        Verbatim,\n    ),\n])"
430        );
431    }
432}