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}