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}