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 secure-mode rewrite of an `include::` directive into a `link:` macro.
127    SecureLinkRewrite,
128
129    /// A synthetic "Unresolved directive" line replacing an include that could
130    /// not be resolved.
131    UnresolvedDirective,
132
133    /// The line was otherwise rewritten so its columns no longer align with the
134    /// origin: an escaped directive with its leading backslash removed, or the
135    /// spliced-in content of a single-line conditional.
136    Rewritten,
137}
138
139impl SourceMap {
140    pub(crate) fn append(
141        &mut self,
142        output_line: usize,
143        file: Option<&str>,
144        source_line: usize,
145        fidelity: Fidelity,
146    ) {
147        let file = self.intern(file);
148
149        // Segments must be appended in non-decreasing `output_line` order:
150        // `resolve` binary-searches `segments` by `output_line`, which silently
151        // returns garbage on an unsorted slice. Lock that invariant here so an
152        // out-of-order call site fails loudly in debug builds rather than
153        // corrupting every downstream origin lookup.
154        debug_assert!(
155            self.segments
156                .last()
157                .is_none_or(|last| output_line >= last.output_line),
158            "SourceMap::append called out of order: output_line {output_line} follows {}",
159            self.segments.last().map_or(0, |last| last.output_line),
160        );
161
162        self.segments.push(Segment {
163            output_line,
164            file,
165            source_line,
166            fidelity,
167        });
168    }
169
170    /// Intern an origin file name, returning its id. `None` (the root document)
171    /// is represented directly and interns nothing.
172    fn intern(&mut self, file: Option<&str>) -> Option<u32> {
173        let name = file?;
174
175        if let Some(i) = self.files.iter().position(|f| f == name) {
176            return Some(i as u32);
177        }
178
179        let id = self.files.len() as u32;
180        self.files.push(name.to_owned());
181        Some(id)
182    }
183
184    /// Resolve the segment covering preprocessed line `key`, returning the
185    /// origin file id, origin line, and fidelity. A `key` before the first
186    /// segment (or an empty map) maps to the root document, verbatim, at the
187    /// same line number.
188    fn resolve(&self, key: usize) -> (Option<u32>, usize, Fidelity) {
189        // The exact match and the segment-before-`key` cases share one formula
190        // (`source_line + key - output_line`, which is `source_line` on an exact
191        // match). A `key` before the first segment has no governing segment.
192        let segment = match self.segments.binary_search_by_key(&key, |s| s.output_line) {
193            Ok(i) => self.segments.get(i),
194            Err(0) => None,
195            Err(i) => self.segments.get(i - 1),
196        };
197
198        match segment {
199            Some(s) => (s.file, s.source_line + key - s.output_line, s.fidelity),
200            None => (None, key, Fidelity::Verbatim),
201        }
202    }
203
204    /// Reconstruct the anchor segments as `(output_line, file, source_line)`
205    /// tuples, in order. Used by the test fixtures to compare an observed map
206    /// against an expected one (fidelity is not compared there).
207    #[cfg(test)]
208    pub(crate) fn anchors(&self) -> impl Iterator<Item = (usize, Option<&str>, usize)> {
209        self.segments
210            .iter()
211            .map(|s| (s.output_line, self.file_name(s.file), s.source_line))
212    }
213
214    /// Resolve an interned file id to its name.
215    fn file_name(&self, id: Option<u32>) -> Option<&str> {
216        id.and_then(|i| self.files.get(i as usize))
217            .map(String::as_str)
218    }
219
220    /// Given a 1-based line number in the preprocessed source file, translate
221    /// that to a file name and line number as original inputs to the parsing
222    /// process.
223    ///
224    /// This is line-granular only; use [`origin_at`](Self::origin_at) or
225    /// [`origin_of`](Self::origin_of) to also recover a column (on verbatim
226    /// lines) and the [`Fidelity`] of the mapping.
227    pub fn original_file_and_line(&self, key: usize) -> Option<SourceLine> {
228        let (file, source_line, _) = self.resolve(key);
229        Some(SourceLine(
230            self.file_name(file).map(str::to_owned),
231            source_line,
232        ))
233    }
234
235    /// Translate a 1-based (line, column) position in the preprocessed source
236    /// back to its [`Origin`] in the original input files.
237    ///
238    /// `col` counts Unicode scalar values, as [`Span::col`] does. It is carried
239    /// through to [`Origin::col`] only when the line is [`Fidelity::Verbatim`];
240    /// otherwise [`Origin::col`] is `None`. See [`Origin`] for the column-unit
241    /// contract.
242    ///
243    /// [`Span::col`]: crate::Span::col
244    pub fn origin_at(&self, line: usize, col: usize) -> Origin<'_> {
245        let (file, source_line, fidelity) = self.resolve(line);
246
247        Origin {
248            file: self.file_name(file),
249            line: source_line,
250            col: matches!(fidelity, Fidelity::Verbatim).then_some(col),
251            fidelity,
252        }
253    }
254
255    /// Translate the start of `span` back to its [`Origin`] in the original
256    /// input files. Convenience for `origin_at(span.line(), span.col())`.
257    pub fn origin_of(&self, span: Span<'_>) -> Origin<'_> {
258        self.origin_at(span.line(), span.col())
259    }
260}
261
262impl fmt::Debug for SourceMap {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        f.write_str("SourceMap(&")?;
265        f.debug_list()
266            .entries(self.segments.iter().map(|s| {
267                (
268                    s.output_line,
269                    SourceLine(self.file_name(s.file).map(str::to_owned), s.source_line),
270                    s.fidelity,
271                )
272            }))
273            .finish()?;
274        f.write_str(")")
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    #![allow(clippy::unwrap_used)]
281
282    use crate::parser::{
283        SourceLine, SourceMap,
284        source_map::{Fidelity, Transform},
285    };
286
287    fn append(sm: &mut SourceMap, output_line: usize, file: Option<&str>, source_line: usize) {
288        sm.append(output_line, file, source_line, Fidelity::Verbatim);
289    }
290
291    #[test]
292    fn empty() {
293        let sm = SourceMap::default();
294        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
295    }
296
297    #[test]
298    fn one_entry() {
299        let mut sm = SourceMap::default();
300        append(&mut sm, 1, None, 1);
301
302        assert_eq!(sm.original_file_and_line(0), Some(SourceLine(None, 0)));
303        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
304        assert_eq!(sm.original_file_and_line(4), Some(SourceLine(None, 4)));
305
306        assert_eq!(
307            sm.original_file_and_line(4000),
308            Some(SourceLine(None, 4000))
309        );
310    }
311
312    #[test]
313    fn multiple_entries() {
314        let mut sm = SourceMap::default();
315        append(&mut sm, 1, None, 1);
316        append(&mut sm, 10, Some("foo.adoc"), 1);
317        append(&mut sm, 20, Some("bar.adoc"), 18);
318        append(&mut sm, 30, None, 11);
319
320        assert_eq!(sm.original_file_and_line(1), Some(SourceLine(None, 1)));
321        assert_eq!(sm.original_file_and_line(4), Some(SourceLine(None, 4)));
322
323        assert_eq!(
324            sm.original_file_and_line(10),
325            Some(SourceLine(Some("foo.adoc".to_owned()), 1))
326        );
327        assert_eq!(
328            sm.original_file_and_line(19),
329            Some(SourceLine(Some("foo.adoc".to_owned()), 10))
330        );
331
332        assert_eq!(
333            sm.original_file_and_line(20),
334            Some(SourceLine(Some("bar.adoc".to_owned()), 18))
335        );
336        assert_eq!(
337            sm.original_file_and_line(21),
338            Some(SourceLine(Some("bar.adoc".to_owned()), 19))
339        );
340        assert_eq!(
341            sm.original_file_and_line(29),
342            Some(SourceLine(Some("bar.adoc".to_owned()), 27))
343        );
344
345        assert_eq!(sm.original_file_and_line(30), Some(SourceLine(None, 11)));
346        assert_eq!(sm.original_file_and_line(40), Some(SourceLine(None, 21)));
347    }
348
349    #[test]
350    fn interns_repeated_file_names() {
351        let mut sm = SourceMap::default();
352        append(&mut sm, 1, Some("foo.adoc"), 1);
353        append(&mut sm, 5, Some("foo.adoc"), 10);
354        append(&mut sm, 9, Some("foo.adoc"), 20);
355
356        // The same file name is stored once regardless of how many segments
357        // reference it.
358        assert_eq!(sm.files, vec!["foo.adoc".to_owned()]);
359
360        assert_eq!(
361            sm.original_file_and_line(6),
362            Some(SourceLine(Some("foo.adoc".to_owned()), 11))
363        );
364    }
365
366    #[test]
367    fn origin_at_reports_column_only_when_verbatim() {
368        let mut sm = SourceMap::default();
369        sm.append(1, None, 1, Fidelity::Verbatim);
370        sm.append(
371            2,
372            Some("inc.adoc"),
373            4,
374            Fidelity::Transformed(Transform::TabExpansion),
375        );
376        sm.append(3, Some("inc.adoc"), 5, Fidelity::Verbatim);
377
378        let verbatim = sm.origin_at(1, 7);
379        assert_eq!(verbatim.file, None);
380        assert_eq!(verbatim.line, 1);
381        assert_eq!(verbatim.col, Some(7));
382        assert_eq!(verbatim.fidelity, Fidelity::Verbatim);
383
384        let transformed = sm.origin_at(2, 7);
385        assert_eq!(transformed.file, Some("inc.adoc"));
386        assert_eq!(transformed.line, 4);
387        assert_eq!(transformed.col, None);
388        assert_eq!(
389            transformed.fidelity,
390            Fidelity::Transformed(Transform::TabExpansion)
391        );
392
393        let after = sm.origin_at(3, 2);
394        assert_eq!(after.file, Some("inc.adoc"));
395        assert_eq!(after.line, 5);
396        assert_eq!(after.col, Some(2));
397    }
398
399    #[test]
400    #[cfg(debug_assertions)]
401    #[should_panic(expected = "SourceMap::append called out of order")]
402    fn append_out_of_order_panics_in_debug() {
403        let mut sm = SourceMap::default();
404        append(&mut sm, 10, None, 1);
405
406        // An earlier `output_line` than the last segment breaks the ordering
407        // that `resolve`'s binary search relies on, so it must fail loudly.
408        append(&mut sm, 5, None, 1);
409    }
410
411    #[test]
412    fn append_equal_output_line_is_allowed() {
413        let mut sm = SourceMap::default();
414        append(&mut sm, 1, None, 1);
415
416        // Equal `output_line` keeps the slice sorted, so it is permitted.
417        sm.append(1, None, 1, Fidelity::Verbatim);
418    }
419
420    #[test]
421    fn impl_debug() {
422        let mut sm = SourceMap::default();
423        append(&mut sm, 1, None, 1);
424
425        assert_eq!(
426            format!("{sm:#?}"),
427            "SourceMap(&[\n    (\n        1,\n        SourceLine(\n            None,\n            1,\n        ),\n        Verbatim,\n    ),\n])"
428        );
429    }
430}