Skip to main content

adoctra_core/
resource.rs

1//! Host-controlled text and binary resources. Core provides no filesystem or
2//! network loader.
3//!
4//! Providers resolve a target relative to the referring resource's canonical
5//! name and return a UTF-8 snapshot. Include selection, normalization,
6//! attribute substitution and recursion belong to the reader, not the provider.
7use std::{collections::BTreeMap, fmt};
8
9/// A request after target attribute substitution. `referrer` is a canonical
10/// name returned by this same provider, or None for a top-level resource.
11#[derive(Debug, Clone, Copy)]
12pub struct ResourceRequest<'a> {
13    pub target: &'a str,
14    pub referrer: Option<&'a str>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Resource {
19    /// Canonical identity within the provider. Nested requests use this name
20    /// as their referrer; aliases must resolve to the same name.
21    pub name: String,
22    /// UTF-8 text, including its BOM and line endings, if present. For an encoded
23    /// include this is the decoded snapshot; spans address these UTF-8 bytes,
24    /// not the byte offsets in the original encoded file.
25    pub text: String,
26}
27
28/// An include's immutable decoded source. A finite line selection may recover
29/// an intact prefix when the unread tail cannot be decoded. The prefix starts
30/// at byte zero, so physical line numbers and decoded UTF-8 offsets stay valid.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct IncludeResource {
33    pub resource: Resource,
34    /// False when this resource is an incomplete decoded view. No replacement
35    /// characters or fabricated lines enter the snapshot text.
36    pub complete: bool,
37}
38
39/// A whole undecodable line excluded from an include snapshot. `offset` is
40/// the insertion point in its retained UTF-8 text; `line` is the original
41/// one-based physical line. Bytes include the original line ending, if any.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct OmittedSourceLine {
44    pub offset: usize,
45    pub line: usize,
46    pub bytes: Vec<u8>,
47}
48
49/// A byte range within an omitted physical line, including neither an invented
50/// UTF-8 decoding nor a Unicode column. The source ID is the diagnostic's.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct OmittedSourceSpan {
53    pub line: usize,
54    pub start: usize,
55    pub end: usize,
56}
57
58pub(crate) struct IncludeSnapshot {
59    pub decoded: IncludeResource,
60    pub omissions: Vec<OmittedSourceLine>,
61    pub frames: Option<SourceLineFrames>,
62}
63
64/// Reader IO can split an encoded character's LF byte without a Unicode LF.
65/// Keep those physical boundaries separately; never invent snapshot text.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) struct SourceLineFrames {
68    pub ranges: Vec<(usize, usize)>,
69    pub trailing_lf: bool,
70    /// The provider path already removed the BOM Ruby removes (an OpenURI
71    /// response converted by `prepare_source_*`); a U+FEFF left in the text
72    /// is content and the reader must not strip it again.
73    pub normalized: bool,
74}
75
76impl From<IncludeResource> for IncludeSnapshot {
77    fn from(decoded: IncludeResource) -> Self {
78        Self {
79            decoded,
80            omissions: Vec::new(),
81            frames: None,
82        }
83    }
84}
85
86impl From<Resource> for IncludeSnapshot {
87    fn from(resource: Resource) -> Self {
88        IncludeResource::from(resource).into()
89    }
90}
91
92/// Selection sees physical line slots; undecodable slots are empty internal
93/// sentinels and must be discarded before normalization or parsing. They are
94/// never inserted into the exported source text.
95pub(crate) fn snapshot_lines(
96    text: &str,
97    omissions: &[OmittedSourceLine],
98) -> Vec<crate::include_indent::Line> {
99    framed_snapshot_lines(text, omissions, None)
100}
101
102pub(crate) fn framed_snapshot_lines(
103    text: &str,
104    omissions: &[OmittedSourceLine],
105    frames: Option<&SourceLineFrames>,
106) -> Vec<crate::include_indent::Line> {
107    use crate::include_indent::Line;
108    let strip_bom = omissions.first().is_none_or(|line| line.line != 1)
109        && !frames.is_some_and(|frames| frames.normalized);
110    let lines = if let Some(frames) = frames {
111        frames
112            .ranges
113            .iter()
114            .enumerate()
115            .map(|(index, &(start, end))| {
116                Line::from_bounds(text, start, end, strip_bom && index == 0)
117            })
118            .collect()
119    } else {
120        Line::from_source_with_bom(text, strip_bom)
121    };
122    if omissions.is_empty() {
123        return lines;
124    }
125    let count = lines.len() + omissions.len();
126    let mut lines = lines.into_iter();
127    let mut omitted = omissions.iter().peekable();
128    (1..=count)
129        .map(|number| {
130            if omitted.peek().is_some_and(|line| line.line == number) {
131                let omission = omitted.next().expect("omitted line");
132                let mut line = Line::generated(String::new());
133                line.original = Some(omission.offset);
134                line
135            } else {
136                lines.next().expect("retained physical line")
137            }
138        })
139        .collect()
140}
141
142/// Provider input awaiting the reader's line/tag selection. Only UTF-8 and
143/// ASCII-8BIT defer validation; transcoding codecs validate consumed bytes.
144#[derive(Debug, Clone)]
145pub enum IncludeInput {
146    Decoded(IncludeResource),
147    Deferred {
148        resource: BinaryResource,
149        binary: bool,
150    },
151    /// Bytes read without transcoding, tagged with an external encoding,
152    /// like the pinned Ruby's OpenURI responses: see
153    /// [`BinaryResource::prepare_uri_include`].
154    Unconverted {
155        resource: BinaryResource,
156        encoding: Option<String>,
157    },
158}
159
160impl From<Resource> for IncludeResource {
161    fn from(resource: Resource) -> Self {
162        Self {
163            resource,
164            complete: true,
165        }
166    }
167}
168
169/// Original resource bytes (an encoded include or an embedded image). `content_type` is a
170/// type the source reports (e.g. an HTTP `Content-Type`), if any.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct BinaryResource {
173    pub name: String,
174    pub bytes: Vec<u8>,
175    pub content_type: Option<String>,
176}
177
178/// Text normalized by the consumer like Ruby's `Helpers.prepare_source_string`
179/// (the primary document, docinfo): [`ResourceProvider::load_asset_text`]
180/// decodes a UTF-16 BOM, while a text-only provider without binary support
181/// keeps its own snapshot through `load`.
182pub(crate) fn load_source_text(
183    provider: &dyn ResourceProvider,
184    request: ResourceRequest<'_>,
185) -> ResourceResult<Resource> {
186    provider
187        .load_asset_text(request)
188        .or_else(|error| match error.kind {
189            ResourceErrorKind::Unsupported => provider.load(request),
190            _ => Err(error),
191        })
192}
193
194pub trait ResourceProvider {
195    /// Enable URI caching for this provider's session. Called only for an
196    /// allowed URI include or asset read with `cache-uri`; removing the attribute does not
197    /// disable an already enabled cache, like Ruby's OpenURI adapter.
198    /// Providers choose cache storage/lifetime and retain access policy.
199    /// The default leaves existing host providers unchanged.
200    fn enable_uri_cache(&self) {}
201
202    /// Load an asset whose text will be normalized by the consumer (e.g. inline
203    /// SVG). Decode original bytes strictly, with include-style BOM detection,
204    /// retaining BOM/line endings for the caller. Existing binary providers
205    /// work through this default; URI providers may preserve cache-hit encoding
206    /// semantics. This does not select lines or register a source snapshot.
207    fn load_asset_text(&self, request: ResourceRequest<'_>) -> ResourceResult<Resource> {
208        self.load_binary(request)?.decode(None)
209    }
210
211    /// Load one snapshot. Missing and denied resources must be distinguished,
212    /// so optional includes can suppress only the appropriate failures.
213    /// Providers own access policy; the parser must not fall back to ambient I/O.
214    fn load(&self, request: ResourceRequest<'_>) -> ResourceResult<Resource>;
215
216    /// Load an include with its declared external encoding, before line/tag
217    /// selection. The default keeps existing text-only providers working:
218    /// without a selector it uses `load`; with a selector it interprets that
219    /// snapshot's UTF-8 bytes as the requested encoding. Providers with original
220    /// bytes should override this method and decode those bytes instead.
221    /// Hosts may implement additional codecs here. Access policy and canonical
222    /// identity must be the same as for `load`; no ambient I/O fallback exists.
223    fn load_with_encoding(
224        &self,
225        request: ResourceRequest<'_>,
226        encoding: Option<&str>,
227    ) -> ResourceResult<Resource> {
228        let resource = self.load(request)?;
229        if encoding.is_none() {
230            return Ok(resource);
231        }
232        BinaryResource {
233            name: resource.name,
234            bytes: resource.text.into_bytes(),
235            content_type: None,
236        }
237        .decode(encoding)
238    }
239
240    /// Load an include, optionally stopping decoding after this many physical
241    /// LF-terminated lines. `line_limit` is set only for a finite, positive
242    /// `lines` selection; tag selection and open ranges require the whole file.
243    /// A complete snapshot is preferred when the entire resource is valid.
244    /// Existing host decoders keep their behavior through the default method.
245    /// Byte providers can opt in using [`BinaryResource::decode_include`].
246    /// This is a decoding bound, not permission to bypass access policy.
247    fn load_include(
248        &self,
249        request: ResourceRequest<'_>,
250        encoding: Option<&str>,
251        line_limit: Option<usize>,
252    ) -> ResourceResult<IncludeResource> {
253        let _ = line_limit;
254        self.load_with_encoding(request, encoding).map(Into::into)
255    }
256
257    /// Prepare input for reader-owned selection. The default preserves existing
258    /// custom decoders. Original-byte providers may defer UTF-8/BINARY checks
259    /// using [`BinaryResource::prepare_include`]; the reader validates selected
260    /// lines before registering a snapshot. Access policy remains the provider's.
261    fn prepare_include(
262        &self,
263        request: ResourceRequest<'_>,
264        encoding: Option<&str>,
265        line_limit: Option<usize>,
266    ) -> ResourceResult<IncludeInput> {
267        self.load_include(request, encoding, line_limit)
268            .map(IncludeInput::Decoded)
269    }
270
271    /// Opt into document path attributes for a registered canonical name.
272    /// Return an absolute, slash-separated file path in this provider's
273    /// namespace (which may be virtual). This must not load the resource again.
274    /// The default leaves opaque identities alone. The path is metadata only;
275    /// include resolution still uses the original canonical name as referrer.
276    fn document_path(&self, _name: &str) -> Option<String> {
277        None
278    }
279
280    /// Load the bytes of a resource, resolved like [`ResourceProvider::load`]
281    /// (e.g. an image for `data-uri`). The default supports none.
282    fn load_binary(&self, request: ResourceRequest<'_>) -> ResourceResult<BinaryResource> {
283        Err(ResourceError::new(
284            ResourceErrorKind::Unsupported,
285            request.target,
286            "binary resources are not supported",
287        ))
288    }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292#[non_exhaustive]
293pub enum ResourceErrorKind {
294    NotFound,
295    AccessDenied,
296    InvalidTarget,
297    InvalidEncoding,
298    Io,
299    /// The provider does not load this kind of resource.
300    Unsupported,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct ResourceError {
305    pub kind: ResourceErrorKind,
306    pub target: String,
307    pub message: String,
308}
309
310impl ResourceError {
311    pub fn new(
312        kind: ResourceErrorKind,
313        target: impl Into<String>,
314        message: impl Into<String>,
315    ) -> Self {
316        Self {
317            kind,
318            target: target.into(),
319            message: message.into(),
320        }
321    }
322}
323
324impl fmt::Display for ResourceError {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        write!(f, "{}: {}", self.target, self.message)
327    }
328}
329
330impl std::error::Error for ResourceError {}
331
332pub type ResourceResult<T> = std::result::Result<T, ResourceError>;
333
334/// Application-owned resources in a virtual directory tree. Names use `/` on
335/// every platform; an initial slash addresses the virtual root. `.` and `..`
336/// are normalized, but traversal above that root is rejected. No URL decoding,
337/// filesystem access or network access is performed.
338#[derive(Debug, Clone, Default)]
339pub struct MemoryResources {
340    files: BTreeMap<String, String>,
341    binaries: BTreeMap<String, Vec<u8>>,
342}
343
344impl MemoryResources {
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Insert or replace a UTF-8 buffer. Returns the previous text, if any.
350    pub fn insert(
351        &mut self,
352        name: &str,
353        text: impl Into<String>,
354    ) -> ResourceResult<Option<String>> {
355        let name = virtual_name(ResourceRequest {
356            target: name,
357            referrer: None,
358        })?;
359        Ok(self.files.insert(name, text.into()))
360    }
361
362    /// Insert or replace original bytes (e.g. an image or encoded include).
363    /// `load_binary` and `load_with_encoding` prefer these bytes to a text
364    /// buffer with the same name. Returns the previous bytes, if any.
365    pub fn insert_binary(
366        &mut self,
367        name: &str,
368        bytes: impl Into<Vec<u8>>,
369    ) -> ResourceResult<Option<Vec<u8>>> {
370        let name = virtual_name(ResourceRequest {
371            target: name,
372            referrer: None,
373        })?;
374        Ok(self.binaries.insert(name, bytes.into()))
375    }
376
377    pub fn remove_binary(&mut self, name: &str) -> ResourceResult<Option<Vec<u8>>> {
378        let name = virtual_name(ResourceRequest {
379            target: name,
380            referrer: None,
381        })?;
382        Ok(self.binaries.remove(&name))
383    }
384
385    pub fn remove(&mut self, name: &str) -> ResourceResult<Option<String>> {
386        let name = virtual_name(ResourceRequest {
387            target: name,
388            referrer: None,
389        })?;
390        Ok(self.files.remove(&name))
391    }
392}
393
394impl ResourceProvider for MemoryResources {
395    fn prepare_include(
396        &self,
397        request: ResourceRequest<'_>,
398        encoding: Option<&str>,
399        line_limit: Option<usize>,
400    ) -> ResourceResult<IncludeInput> {
401        self.load_binary(request)?
402            .prepare_include(encoding, line_limit)
403    }
404
405    fn load_include(
406        &self,
407        request: ResourceRequest<'_>,
408        encoding: Option<&str>,
409        line_limit: Option<usize>,
410    ) -> ResourceResult<IncludeResource> {
411        self.load_binary(request)?
412            .decode_include(encoding, line_limit)
413    }
414
415    fn load_with_encoding(
416        &self,
417        request: ResourceRequest<'_>,
418        encoding: Option<&str>,
419    ) -> ResourceResult<Resource> {
420        self.load_binary(request)?.decode(encoding)
421    }
422
423    fn document_path(&self, name: &str) -> Option<String> {
424        Some(name.to_owned())
425    }
426
427    fn load_binary(&self, request: ResourceRequest<'_>) -> ResourceResult<BinaryResource> {
428        let name = virtual_name(request)?;
429        let bytes = match (self.binaries.get(&name), self.files.get(&name)) {
430            (Some(bytes), _) => bytes.clone(),
431            (None, Some(text)) => text.clone().into_bytes(),
432            (None, None) => {
433                return Err(ResourceError::new(
434                    ResourceErrorKind::NotFound,
435                    name,
436                    "resource not found",
437                ))
438            }
439        };
440        Ok(BinaryResource {
441            name,
442            bytes,
443            content_type: None,
444        })
445    }
446
447    fn load(&self, request: ResourceRequest<'_>) -> ResourceResult<Resource> {
448        let name = virtual_name(request)?;
449        match self.files.get(&name) {
450            Some(text) => Ok(Resource {
451                name,
452                text: text.clone(),
453            }),
454            None => Err(ResourceError::new(
455                ResourceErrorKind::NotFound,
456                name,
457                "resource not found",
458            )),
459        }
460    }
461}
462
463fn virtual_name(request: ResourceRequest<'_>) -> ResourceResult<String> {
464    let target = request.target;
465    // A virtual resource is a file, not a directory or an OS/URI path.
466    if target.is_empty() || target.ends_with('/') || target.contains(['\\', '\0', ':']) {
467        return Err(ResourceError::new(
468            ResourceErrorKind::InvalidTarget,
469            target,
470            "expected a virtual file path using '/' separators",
471        ));
472    }
473    let mut parts = Vec::new();
474    if !target.starts_with('/') {
475        if let Some(referrer) = request.referrer {
476            let parent = referrer.rsplit_once('/').map_or("", |(parent, _)| parent);
477            parts.extend(parent.split('/').filter(|part| !part.is_empty()));
478        }
479    }
480    for part in target.split('/') {
481        match part {
482            "" | "." => {}
483            ".." => {
484                if parts.pop().is_none() {
485                    return Err(ResourceError::new(
486                        ResourceErrorKind::AccessDenied,
487                        target,
488                        "path escapes the virtual root",
489                    ));
490                }
491            }
492            part => parts.push(part),
493        }
494    }
495    if parts.is_empty() || matches!(target.rsplit('/').next(), Some("." | "..")) {
496        return Err(ResourceError::new(
497            ResourceErrorKind::InvalidTarget,
498            target,
499            "expected a file name",
500        ));
501    }
502    Ok(format!("/{}", parts.join("/")))
503}