Skip to main content

acdc_parser/model/
location.rs

1use std::sync::Arc;
2
3use serde::{
4    Serialize,
5    ser::{SerializeMap, SerializeSeq, Serializer},
6};
7
8/// A range where a specific leveloffset value applies.
9///
10/// When include directives use `leveloffset=+N`, we track the byte ranges where
11/// leveloffsets apply. The parser then queries these ranges to determine the effective
12/// leveloffset at any given position.
13#[derive(Debug, Clone, Default, PartialEq)]
14pub(crate) struct LeveloffsetRange {
15    /// Byte offset where this leveloffset begins (inclusive).
16    pub(crate) start_offset: usize,
17    /// Byte offset where this leveloffset ends (exclusive).
18    pub(crate) end_offset: usize,
19    /// The leveloffset value to apply in this range.
20    pub(crate) value: isize,
21}
22
23impl LeveloffsetRange {
24    /// Create a new leveloffset range.
25    #[must_use]
26    pub(crate) fn new(start_offset: usize, end_offset: usize, value: isize) -> Self {
27        Self {
28            start_offset,
29            end_offset,
30            value,
31        }
32    }
33
34    /// Check if a byte offset falls within this range.
35    #[must_use]
36    pub(crate) fn contains(&self, byte_offset: usize) -> bool {
37        byte_offset >= self.start_offset && byte_offset < self.end_offset
38    }
39}
40
41/// Calculate the total leveloffset at a given byte offset.
42///
43/// Sums all leveloffset values from ranges that contain the given offset.
44/// Ranges can nest (include within include), so we sum all applicable values.
45#[must_use]
46pub(crate) fn calculate_leveloffset_at(ranges: &[LeveloffsetRange], byte_offset: usize) -> isize {
47    ranges
48        .iter()
49        .filter_map(|r| {
50            if r.contains(byte_offset) {
51                Some(r.value)
52            } else {
53                None
54            }
55        })
56        .sum()
57}
58
59/// Maps a byte range in the preprocessed output back to its source file and starting line.
60///
61/// When `include::` directives merge content into a single string, we track which byte
62/// ranges came from which files. The parser queries these ranges to produce accurate
63/// file names and line numbers in warnings and errors.
64///
65/// The two coordinate spaces and how a `SourceRange` bridges them:
66///
67/// ```text
68/// PREPROCESSED BUFFER (parser coords)     ORIGIN FILE (ASG / diagnostics coords)
69///   byte 7        13       19               byte 0      6
70///        |        |        |                     |      |
71///    ... inc 1\n  inc 2\n ...                inc 1\n inc 2\n
72///        `---- this range ----'              `-l1-' `-l2-'
73///        [start_offset, end_offset)          start_line = 1, source_start_offset = 0
74///
75/// preproc abs 13  ->  source_offset = source_start_offset + (abs - start_offset) = 6
76///                     source_line   = start_line          + (preproc lines crossed) = 2
77/// ```
78///
79/// So `start_offset`/`end_offset` locate the span in the preprocessed buffer, while
80/// `start_line`/`source_start_offset` are its anchor in the origin file. The byte
81/// mapping is [`source_offset`](Self::source_offset); the line mapping is
82/// `LineMap::source_line` (one shared primitive for the remap and diagnostics).
83#[derive(Debug, Clone, PartialEq)]
84pub(crate) struct SourceRange {
85    /// Byte offset where this source range begins (inclusive), in the preprocessed buffer.
86    pub(crate) start_offset: usize,
87    /// Byte offset where this source range ends (exclusive), in the preprocessed buffer.
88    pub(crate) end_offset: usize,
89    /// The *resolved* path of the file this range came from, for diagnostics (so the
90    /// CLI can open it to render a snippet). `None` for the primary input when it has
91    /// no path (string/stdin), which still needs same-file line/offset remapping.
92    pub(crate) file: Option<std::path::PathBuf>,
93    /// The `include::` chain reaching this range — each element an include target *as
94    /// written* in its directive, outermost first. Empty for primary-input content
95    /// (the ASG omits the primary file). Feeds the per-`Position` ASG `file` array.
96    pub(crate) file_chain: Vec<String>,
97    /// The line number (1-indexed) of the first line in this range within the source file.
98    pub(crate) start_line: usize,
99    /// The byte offset (in the *origin file*) of the first byte in this range. Combined
100    /// with `start_offset`, this maps a preprocessed offset back to an original-file offset:
101    /// `source_offset = source_start_offset + (preproc_offset - start_offset)`.
102    pub(crate) source_start_offset: usize,
103    /// Columns added (or removed, if negative) per line by an `include::[indent=N]`
104    /// re-indent: `N − stripped_common_indent`, uniform across the block. The remap
105    /// subtracts it to recover origin columns. `0` for untransformed ranges, whose
106    /// preprocessed↔origin mapping is byte-for-byte (1:1); a non-zero shift marks the
107    /// range as **not 1:1**, so its `source_offset` no longer recovers origin offsets.
108    pub(crate) column_shift: isize,
109}
110
111impl SourceRange {
112    /// Check if a byte offset falls within this range.
113    #[must_use]
114    pub(crate) fn contains(&self, byte_offset: usize) -> bool {
115        byte_offset >= self.start_offset && byte_offset < self.end_offset
116    }
117
118    /// Find the innermost range containing `offset`. Ranges are searched in
119    /// reverse so a nested include (recorded after its parent) wins over the
120    /// enclosing range.
121    #[must_use]
122    pub(crate) fn find_containing(ranges: &[SourceRange], offset: usize) -> Option<&SourceRange> {
123        ranges.iter().rev().find(|r| r.contains(offset))
124    }
125
126    /// Original-file byte offset for a preprocessed `offset` inside this range.
127    #[must_use]
128    pub(crate) fn source_offset(&self, offset: usize) -> usize {
129        self.source_start_offset + offset.saturating_sub(self.start_offset)
130    }
131}
132
133/// A `Location` represents a location in a document.
134///
135/// After parsing, line and column positions refer to the original source, including
136/// content from nested and partial includes. Each [`Position`] carries its own include
137/// chain in `file`; primary-input positions use `None`.
138///
139/// Byte offsets normally refer to each endpoint's original file. Reindented includes
140/// are an exception: their byte offsets remain in preprocessed coordinates. Use the
141/// line and column to locate that content in its original file.
142///
143/// A span can cross files. [`Self::byte_len`] returns `None` in that case because the
144/// offsets use different coordinate spaces. JSON contains line, column, and include
145/// chain only; it omits byte offsets.
146#[derive(Debug, Default, Clone, Hash, Eq, PartialEq)]
147#[non_exhaustive]
148pub struct Location {
149    /// The absolute start position of the location.
150    pub absolute_start: usize,
151    /// The absolute end position of the location.
152    pub absolute_end: usize,
153
154    /// The start position of the location.
155    pub start: Position,
156    /// The end position of the location.
157    pub end: Position,
158}
159
160impl Location {
161    /// A zero-width location at a single `position` (`start == end`), used for
162    /// point diagnostics that only know a line/column. Byte offsets are `0`.
163    #[must_use]
164    pub fn point(position: Position) -> Self {
165        Self {
166            absolute_start: 0,
167            absolute_end: 0,
168            start: position.clone(),
169            end: position,
170        }
171    }
172
173    /// Byte length of this location (`absolute_end - absolute_start + 1`, end inclusive),
174    /// but **only when both boundaries are in the same file** (`start.file == end.file`).
175    ///
176    /// Returns `None` for a span that crosses a file boundary: its `absolute_start` and
177    /// `absolute_end` live in different files' coordinate spaces, so subtracting them is
178    /// meaningless. Prefer this over subtracting `absolute_*` directly — it can't silently
179    /// produce a garbage length across files. For a cross-file span, locate each endpoint
180    /// via its own `file`/line instead.
181    #[must_use]
182    pub fn byte_len(&self) -> Option<usize> {
183        (self.start.file == self.end.file).then(|| {
184            self.absolute_end
185                .saturating_sub(self.absolute_start)
186                .saturating_add(1)
187        })
188    }
189
190    /// Validates that this location satisfies all invariants.
191    ///
192    /// Checks:
193    /// - `absolute_start <= absolute_end` (valid range)
194    /// - `absolute_end <= input.len()` (within bounds)
195    /// - Both offsets are on UTF-8 character boundaries
196    ///
197    /// # Errors
198    /// Returned as strings for easier debugging.
199    pub fn validate(&self, input: &str) -> Result<(), String> {
200        // Check range validity using the canonical byte offsets
201        if self.absolute_start > self.absolute_end {
202            return Err(format!(
203                "Invalid range: start {} > end {}",
204                self.absolute_start, self.absolute_end
205            ));
206        }
207
208        // Check bounds
209        if self.absolute_end > input.len() {
210            return Err(format!(
211                "End offset {} exceeds input length {}",
212                self.absolute_end,
213                input.len()
214            ));
215        }
216
217        // Check UTF-8 boundaries on the canonical offsets
218        if !input.is_char_boundary(self.absolute_start) {
219            return Err(format!(
220                "Start offset {} not on UTF-8 boundary",
221                self.absolute_start
222            ));
223        }
224
225        if !input.is_char_boundary(self.absolute_end) {
226            return Err(format!(
227                "End offset {} not on UTF-8 boundary",
228                self.absolute_end
229            ));
230        }
231
232        Ok(())
233    }
234}
235
236// We serialize `Location` into the ASG format: a two-element sequence of location
237// boundaries, `[start, end]`. Each boundary is a [`Position`] (`{ line, col }` plus an
238// optional `file`). See the official schema's `locationBoundary` definition:
239// https://gitlab.eclipse.org/eclipse/asciidoc-lang/asciidoc-lang/-/blob/main/asg/schema.json
240impl Serialize for Location {
241    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
242    where
243        S: Serializer,
244    {
245        let mut state = serializer.serialize_seq(Some(2))?;
246        state.serialize_element(&self.start)?;
247        state.serialize_element(&self.end)?;
248        state.end()
249    }
250}
251
252impl std::fmt::Display for Location {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        write!(
255            f,
256            "location.start({}), location.end({})",
257            self.start, self.end
258        )
259    }
260}
261
262/// A `Position` represents a human-readable position in a document — one ASG
263/// `locationBoundary`. For byte offsets, use `Location.absolute_start` /
264/// `Location.absolute_end`.
265///
266/// Equality and hashing cover **all three** fields, `file` included: two positions at
267/// the same line/column but reached through different `include::` chains — or one from
268/// the primary input (`file: None`) versus one from an included file — are *not* equal.
269/// This is deliberate: a line/column is only a unique location together with its file.
270#[derive(Debug, Default, Clone, Hash, Eq, PartialEq)]
271#[non_exhaustive]
272pub struct Position {
273    /// The line number of the position (1-indexed).
274    pub line: u32,
275    /// The column number of the position (1-indexed, counted as Unicode scalar values).
276    pub column: u32,
277    /// The `include::` chain this position's content came through, outermost target
278    /// first and the file directly containing the content last — each element the
279    /// include target *as written* in its directive (e.g. `["a.adoc", "b.adoc"]` for
280    /// content in `b.adoc` included by `a.adoc`). `None` for content from the primary
281    /// input, which the ASG omits. Shared (`Arc`) across all positions reached through
282    /// the same chain, so the field stays a thin 8-byte pointer.
283    pub file: Option<Arc<Vec<String>>>,
284}
285
286impl Position {
287    /// A position at `line`/`column` with no originating file (primary input).
288    #[must_use]
289    pub fn new(line: u32, column: u32) -> Self {
290        Self {
291            line,
292            column,
293            file: None,
294        }
295    }
296
297    /// A position from `usize` `line`/`column`, saturating at `u32::MAX` (neither can
298    /// realistically exceed it). For callers that already hold `u32` — the hot parse
299    /// and diagnostic paths — prefer the lossless [`new`](Self::new). The named
300    /// arguments here guard against accidentally transposing line and column at the
301    /// many `usize`-scanning call sites (preprocessor line counters, byte indices).
302    #[must_use]
303    pub fn from_line_col(line: usize, column: usize) -> Self {
304        Self {
305            line: u32::try_from(line).unwrap_or(u32::MAX),
306            column: u32::try_from(column).unwrap_or(u32::MAX),
307            file: None,
308        }
309    }
310}
311
312impl std::fmt::Display for Position {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(f, "line: {}, column: {}", self.line, self.column)
315    }
316}
317
318// Serialize a `Position` as an ASG `locationBoundary`: `{ line, col }` plus an
319// optional `file` — the `include::` chain (array of targets as written) — present
320// only for `include::`d content (the primary input has no `file`).
321impl Serialize for Position {
322    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
323    where
324        S: Serializer,
325    {
326        let chain = self.file.as_deref().filter(|c| !c.is_empty());
327        let len = if chain.is_some() { 3 } else { 2 };
328        let mut map = serializer.serialize_map(Some(len))?;
329        map.serialize_entry("line", &self.line)?;
330        map.serialize_entry("col", &self.column)?;
331        if let Some(chain) = chain {
332            map.serialize_entry("file", chain)?;
333        }
334        map.end()
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341
342    fn loc(chain: &[&str]) -> Location {
343        let file = (!chain.is_empty())
344            .then(|| Arc::new(chain.iter().map(|s| (*s).to_string()).collect::<Vec<_>>()));
345        Location {
346            absolute_start: 0,
347            absolute_end: 1,
348            start: Position {
349                line: 1,
350                column: 2,
351                file: file.clone(),
352            },
353            end: Position {
354                line: 3,
355                column: 4,
356                file,
357            },
358        }
359    }
360
361    #[test]
362    fn serializes_two_boundaries_without_file_when_primary() -> Result<(), serde_json::Error> {
363        let json = serde_json::to_value(loc(&[]))?;
364        assert_eq!(
365            json,
366            serde_json::json!([{ "line": 1, "col": 2 }, { "line": 3, "col": 4 }])
367        );
368        Ok(())
369    }
370
371    #[test]
372    fn serializes_file_as_include_chain_on_each_boundary() -> Result<(), serde_json::Error> {
373        let json = serde_json::to_value(loc(&["a.adoc", "chapters/b.adoc"]))?;
374        assert_eq!(
375            json,
376            serde_json::json!([
377                { "line": 1, "col": 2, "file": ["a.adoc", "chapters/b.adoc"] },
378                { "line": 3, "col": 4, "file": ["a.adoc", "chapters/b.adoc"] }
379            ])
380        );
381        Ok(())
382    }
383
384    #[test]
385    fn position_equality_is_provenance_aware() {
386        use std::collections::hash_map::DefaultHasher;
387        use std::hash::{Hash, Hasher};
388
389        fn hash(p: &Position) -> u64 {
390            let mut h = DefaultHasher::new();
391            p.hash(&mut h);
392            h.finish()
393        }
394        // A position at line 1, col 1 reached via `chain` (empty == primary input).
395        let pos = |chain: &[&str]| {
396            let mut p = Position::new(1, 1);
397            if !chain.is_empty() {
398                p.file = Some(Arc::new(
399                    chain.iter().map(|s| (*s).to_string()).collect::<Vec<_>>(),
400                ));
401            }
402            p
403        };
404
405        let primary = pos(&[]);
406        let included = pos(&["inc.adoc"]);
407
408        // Same line/col, different (or absent) chain => not equal, and hash differs.
409        assert_ne!(primary, included, "primary input vs included differ");
410        assert_ne!(
411            included,
412            pos(&["other.adoc"]),
413            "different include chains differ"
414        );
415        assert_ne!(hash(&primary), hash(&included));
416
417        // Same line/col and same chain => equal (and equal hash, required by Eq + Hash).
418        assert_eq!(included, pos(&["inc.adoc"]));
419        assert_eq!(hash(&included), hash(&pos(&["inc.adoc"])));
420    }
421
422    #[test]
423    fn byte_len_only_within_a_single_file() {
424        let chain = |c: &[&str]| Arc::new(c.iter().map(|s| (*s).to_string()).collect::<Vec<_>>());
425
426        // Same file (both primary): inclusive length is end - start + 1.
427        let same = Location {
428            absolute_start: 10,
429            absolute_end: 19,
430            start: Position::new(1, 1),
431            end: Position::new(1, 10),
432        };
433        assert_eq!(same.byte_len(), Some(10));
434
435        // Cross-file: the offsets are in different files' spaces, so there is no span.
436        let mut start = Position::new(1, 1);
437        start.file = Some(chain(&["a.adoc"]));
438        let mut end = Position::new(5, 1);
439        end.file = Some(chain(&["b.adoc"]));
440        let cross = Location {
441            absolute_start: 10,
442            absolute_end: 400,
443            start,
444            end,
445        };
446        assert_eq!(cross.byte_len(), None);
447    }
448}