Skip to main content

adoctra_core/
source.rs

1//! Source identity and checked byte ranges. A caller owns one map per document
2//! set and passes the registered id through `ParseOptions` when parsing a file.
3//! Registration performs no I/O; explicit loading delegates to a host provider.
4
5use crate::Span;
6
7#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct SourceId(pub usize);
9
10/// One-based line and Unicode scalar column (not a UTF-16 editor column).
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct SourcePosition {
13    pub line: usize,
14    pub column: usize,
15}
16
17#[derive(Debug, Clone)]
18pub struct SourceFile {
19    name: String,
20    text: String,
21    line_starts: Vec<usize>,
22    complete: bool,
23    omissions: Vec<crate::OmittedSourceLine>,
24    frames: Option<crate::resource::SourceLineFrames>,
25}
26
27impl SourceFile {
28    pub fn name(&self) -> &str {
29        &self.name
30    }
31    pub fn text(&self) -> &str {
32        &self.text
33    }
34
35    /// Whether this snapshot covers the entire decoded resource. An include
36    /// may retain an intact prefix or omit skipped undecodable lines. Ranges
37    /// address `text`; `omitted_lines` describes gaps and `position` maps them
38    /// to original physical line numbers.
39    pub fn is_complete(&self) -> bool {
40        self.complete
41    }
42
43    /// Undecodable physical lines omitted from `text`, retaining their exact
44    /// bytes and insertion points. Spans address retained UTF-8 snapshot text.
45    pub fn omitted_lines(&self) -> &[crate::OmittedSourceLine] {
46        &self.omissions
47    }
48
49    /// Resolve a diagnostic's raw byte range in an omitted physical line.
50    pub fn omitted_slice(&self, span: crate::OmittedSourceSpan) -> Option<&[u8]> {
51        self.omissions
52            .iter()
53            .find(|line| line.line == span.line)?
54            .bytes
55            .get(span.start..span.end)
56    }
57
58    pub(crate) fn include_lines(&self) -> Vec<crate::include_indent::Line> {
59        crate::resource::framed_snapshot_lines(&self.text, &self.omissions, self.frames.as_ref())
60    }
61
62    pub(crate) fn has_line_frames(&self) -> bool {
63        self.frames.is_some()
64    }
65
66    /// Whether the include path already removed Ruby's BOM (see
67    /// [`crate::resource::SourceLineFrames::normalized`]).
68    pub(crate) fn bom_normalized(&self) -> bool {
69        self.frames.as_ref().is_some_and(|frames| frames.normalized)
70    }
71
72    pub(crate) fn stored_bytes(&self) -> usize {
73        self.omissions.iter().fold(self.text.len(), |size, line| {
74            size.saturating_add(line.bytes.len())
75        })
76    }
77
78    pub fn position(&self, offset: usize) -> Option<SourcePosition> {
79        self.text.get(..offset)?;
80        let line = self.line_starts.partition_point(|&start| start <= offset) - 1;
81        Some(SourcePosition {
82            line: line
83                + 1
84                + self
85                    .omissions
86                    .iter()
87                    .filter(|omitted| omitted.offset <= offset && omitted.bytes.ends_with(b"\n"))
88                    .count(),
89            column: self.text[self.line_starts[line]..offset].chars().count() + 1,
90        })
91    }
92}
93
94#[derive(Debug, Clone, Default)]
95pub struct SourceMap {
96    files: Vec<SourceFile>,
97}
98
99impl SourceMap {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// IDs are stable within this map; two files may have the same display name.
105    pub fn add(&mut self, name: impl Into<String>, text: impl Into<String>) -> SourceId {
106        self.add_snapshot(name.into(), text.into(), true, Vec::new(), None)
107    }
108
109    fn add_snapshot(
110        &mut self,
111        name: String,
112        text: String,
113        complete: bool,
114        omissions: Vec<crate::OmittedSourceLine>,
115        frames: Option<crate::resource::SourceLineFrames>,
116    ) -> SourceId {
117        let line_starts = if let Some(frames) = &frames {
118            let mut starts: Vec<_> = frames.ranges.iter().map(|&(start, _)| start).collect();
119            if starts.is_empty() {
120                starts.push(0);
121            }
122            if frames.trailing_lf && starts.last() != Some(&text.len()) {
123                starts.push(text.len());
124            }
125            starts
126        } else {
127            let mut starts = vec![0];
128            starts.extend(text.match_indices('\n').map(|(index, _)| index + 1));
129            starts
130        };
131        let id = SourceId(self.files.len());
132        self.files.push(SourceFile {
133            name,
134            text,
135            line_starts,
136            complete,
137            omissions,
138            frames,
139        });
140        id
141    }
142
143    pub fn get(&self, id: SourceId) -> Option<&SourceFile> {
144        self.files.get(id.0)
145    }
146
147    /// Load through an explicitly supplied provider, resolving relative to a
148    /// registered source when given. Reuse an identical named snapshot; changed
149    /// contents receive a new id, preserving all previously issued spans.
150    /// A failed load never modifies the map. No implicit I/O fallback exists.
151    pub fn load(
152        &mut self,
153        target: &str,
154        relative_to: Option<SourceId>,
155        provider: &dyn crate::ResourceProvider,
156    ) -> crate::ResourceResult<SourceId> {
157        self.load_using(target, relative_to, |request| {
158            provider.load(request).map(Into::into)
159        })
160    }
161
162    /// Load a primary document like Ruby's `Asciidoctor.load_file`: the bytes
163    /// pass `prepare_source_string`, so a UTF-16LE/BE BOM decodes the whole
164    /// source (spans address the decoded UTF-8 snapshot) and other input must
165    /// be valid UTF-8 (`InvalidEncoding` otherwise). A UTF-8 BOM stays in the
166    /// snapshot for the reader. Text-only providers keep using `load`.
167    pub fn load_document(
168        &mut self,
169        target: &str,
170        relative_to: Option<SourceId>,
171        provider: &dyn crate::ResourceProvider,
172    ) -> crate::ResourceResult<SourceId> {
173        self.load_using(target, relative_to, |request| {
174            crate::resource::load_source_text(provider, request).map(Into::into)
175        })
176    }
177
178    /// Register an include after decoding its external encoding. Source spans
179    /// address the decoded UTF-8 snapshot. Repeated loads reuse an id only when
180    /// both the canonical name and decoded text match; different decoding of
181    /// the same bytes cannot invalidate earlier source ranges.
182    pub fn load_with_encoding(
183        &mut self,
184        target: &str,
185        relative_to: Option<SourceId>,
186        provider: &dyn crate::ResourceProvider,
187        encoding: Option<&str>,
188    ) -> crate::ResourceResult<SourceId> {
189        self.load_using(target, relative_to, |request| {
190            provider
191                .load_with_encoding(request, encoding)
192                .map(Into::into)
193        })
194    }
195
196    /// Register a complete include snapshot, or an explicitly incomplete
197    /// decoded prefix recovered for a finite line selection. Completeness is
198    /// part of snapshot identity, along with canonical name and decoded text.
199    pub fn load_include(
200        &mut self,
201        target: &str,
202        relative_to: Option<SourceId>,
203        provider: &dyn crate::ResourceProvider,
204        encoding: Option<&str>,
205        line_limit: Option<usize>,
206    ) -> crate::ResourceResult<SourceId> {
207        self.load_using(target, relative_to, |request| {
208            provider
209                .load_include(request, encoding, line_limit)
210                .map(Into::into)
211        })
212    }
213
214    pub(crate) fn load_selected_include(
215        &mut self,
216        target: &str,
217        relative_to: Option<SourceId>,
218        provider: &dyn crate::ResourceProvider,
219        encoding: Option<&str>,
220        selection: &crate::include_selection::Selection,
221    ) -> crate::ResourceResult<(SourceId, Option<crate::include_selection::Selected>)> {
222        let mut prepared = None;
223        let id = self.load_using(target, relative_to, |request| {
224            match provider.prepare_include(request, encoding, selection.line_limit())? {
225                crate::IncludeInput::Decoded(resource) => Ok(resource.into()),
226                crate::IncludeInput::Deferred { resource, binary } => selection
227                    .recover_bytes(resource, binary)
228                    .map(|(snapshot, selected)| {
229                        prepared = selected;
230                        snapshot
231                    }),
232                crate::IncludeInput::Unconverted { resource, encoding } => selection
233                    .recover_unconverted(resource, encoding.as_deref())
234                    .map(|(snapshot, selected)| {
235                        prepared = selected;
236                        snapshot
237                    }),
238            }
239        })?;
240        if let Some(selected) = &mut prepared {
241            for diagnostic in &mut selected.diagnostics {
242                diagnostic.span.source_id = id;
243            }
244        }
245        Ok((id, prepared))
246    }
247
248    fn load_using(
249        &mut self,
250        target: &str,
251        relative_to: Option<SourceId>,
252        load: impl FnOnce(
253            crate::ResourceRequest<'_>,
254        ) -> crate::ResourceResult<crate::resource::IncludeSnapshot>,
255    ) -> crate::ResourceResult<SourceId> {
256        let referrer = relative_to
257            .map(|id| {
258                self.get(id).map(SourceFile::name).ok_or_else(|| {
259                    crate::ResourceError::new(
260                        crate::ResourceErrorKind::InvalidTarget,
261                        target,
262                        format!("unknown referring source id {}", id.0),
263                    )
264                })
265            })
266            .transpose()?;
267        let crate::resource::IncludeSnapshot {
268            decoded: crate::IncludeResource { resource, complete },
269            omissions,
270            frames,
271        } = load(crate::ResourceRequest { target, referrer })?;
272        if let Some(index) = self.files.iter().position(|file| {
273            file.name == resource.name
274                && file.text == resource.text
275                && file.complete == complete
276                && file.omissions == omissions
277                && file.frames == frames
278        }) {
279            return Ok(SourceId(index));
280        }
281        Ok(self.add_snapshot(resource.name, resource.text, complete, omissions, frames))
282    }
283
284    /// Invalid ids, reversed ranges and non-UTF-8 boundaries return None.
285    pub fn slice(&self, span: Span) -> Option<&str> {
286        self.get(span.source_id)?.text.get(span.start..span.end)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn physical_reader_frames_participate_in_identity_without_inserting_text() {
296        fn snapshot(
297            ranges: Vec<(usize, usize)>,
298            trailing_lf: bool,
299        ) -> crate::resource::IncludeSnapshot {
300            crate::resource::IncludeSnapshot {
301                decoded: crate::IncludeResource {
302                    resource: crate::Resource {
303                        name: "/frames.adoc".into(),
304                        text: "a上b".into(),
305                    },
306                    complete: false,
307                },
308                omissions: Vec::new(),
309                frames: Some(crate::resource::SourceLineFrames {
310                    ranges,
311                    trailing_lf,
312                    normalized: false,
313                }),
314            }
315        }
316        let mut sources = SourceMap::new();
317        let first = sources
318            .load_using("/frames.adoc", None, |_| {
319                Ok(snapshot(vec![(0, 4), (4, 5)], false))
320            })
321            .unwrap();
322        assert_eq!(
323            sources
324                .load_using("/frames.adoc", None, |_| Ok(snapshot(
325                    vec![(0, 4), (4, 5)],
326                    false
327                )))
328                .unwrap(),
329            first
330        );
331        let second = sources
332            .load_using("/frames.adoc", None, |_| {
333                Ok(snapshot(vec![(0, 1), (1, 5)], false))
334            })
335            .unwrap();
336        assert_ne!(first, second);
337        assert_eq!(sources.get(first).unwrap().text(), "a上b");
338        assert_eq!(sources.get(second).unwrap().text(), "a上b");
339        assert_eq!(
340            sources.get(first).unwrap().position(4),
341            Some(SourcePosition { line: 2, column: 1 })
342        );
343        assert_eq!(
344            sources.get(second).unwrap().position(4),
345            Some(SourcePosition { line: 2, column: 2 })
346        );
347        assert_eq!(sources.get(first).unwrap().position(2), None);
348        let lines = sources.get(first).unwrap().include_lines();
349        assert_eq!(lines.len(), 2);
350        assert_eq!(lines[0].text.as_ref(), "a上");
351        assert_eq!(lines[1].text.as_ref(), "b");
352        assert_eq!(sources.slice(Span::in_source(first, 0, 4)), Some("a上"));
353        let eof = sources
354            .load_using("/frames.adoc", None, |_| {
355                Ok(snapshot(vec![(0, 4), (4, 5)], true))
356            })
357            .unwrap();
358        assert_ne!(eof, first);
359        assert_eq!(
360            sources.get(eof).unwrap().position(5),
361            Some(SourcePosition { line: 3, column: 1 })
362        );
363    }
364
365    #[test]
366    fn unicode_crlf_and_end_of_file_positions_are_checked() {
367        let mut sources = SourceMap::new();
368        let id = sources.add("example.adoc", "Я🙂\r\nnext\n");
369        let file = sources.get(id).unwrap();
370        assert_eq!(
371            file.position(6),
372            Some(SourcePosition { line: 1, column: 3 })
373        );
374        assert_eq!(
375            file.position(8),
376            Some(SourcePosition { line: 2, column: 1 })
377        );
378        assert_eq!(
379            file.position(13),
380            Some(SourcePosition { line: 3, column: 1 })
381        );
382        assert_eq!(file.position(1), None);
383        assert_eq!(file.position(14), None);
384        assert_eq!(sources.slice(Span::in_source(id, 2, 6)), Some("🙂"));
385        assert_eq!(sources.slice(Span::in_source(id, 6, 2)), None);
386        assert_eq!(sources.slice(Span::in_source(SourceId(99), 0, 0)), None);
387    }
388}