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}