Skip to main content

typst_pack/
pack_extraction.rs

1//! Destination-independent Pack Extraction planning.
2
3use std::collections::{BTreeMap, btree_map::Entry};
4use std::fmt;
5
6use crate::CanonicalIdentity;
7use crate::pack::{Pack, font_container_path};
8use crate::payload::SharedBytes;
9
10/// The embedded dependency content selected for one Pack Extraction Plan.
11///
12/// Project files are always selected. Package Trees and Font Containers are
13/// independent explicit choices.
14#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
15pub struct PackExtractionSelection {
16    packages: bool,
17    fonts: bool,
18}
19
20impl PackExtractionSelection {
21    /// Selects whether embedded Package Trees and Font Containers are included.
22    pub const fn new(packages: bool, fonts: bool) -> Self {
23        Self { packages, fonts }
24    }
25
26    /// Whether embedded Package Trees are included.
27    pub const fn packages(self) -> bool {
28        self.packages
29    }
30
31    /// Whether embedded Font Containers are included.
32    pub const fn fonts(self) -> bool {
33        self.fonts
34    }
35}
36
37/// The semantic role of one Pack Extraction entry.
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum PackExtractionEntryRole {
40    /// A project file, included in every plan.
41    ProjectFile,
42    /// A file in an embedded Package Tree.
43    PackageFile,
44    /// One complete embedded Font Container.
45    FontContainer,
46}
47
48impl fmt::Display for PackExtractionEntryRole {
49    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
50        formatter.write_str(match self {
51            Self::ProjectFile => "project file",
52            Self::PackageFile => "package file",
53            Self::FontContainer => "font container",
54        })
55    }
56}
57
58/// One canonical destination-relative entry in a Pack Extraction Plan.
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct PackExtractionEntry {
61    relative_path: String,
62    role: PackExtractionEntryRole,
63    bytes: SharedBytes,
64}
65
66impl PackExtractionEntry {
67    /// The canonical slash-separated path relative to a future destination.
68    pub fn relative_path(&self) -> &str {
69        &self.relative_path
70    }
71
72    /// The entry's role in the extracted project tree.
73    pub fn role(&self) -> PackExtractionEntryRole {
74        self.role
75    }
76
77    /// The exact payload length.
78    pub fn len(&self) -> u64 {
79        u64::try_from(self.bytes.len()).unwrap_or(u64::MAX)
80    }
81
82    /// Whether the exact payload contains no bytes.
83    pub fn is_empty(&self) -> bool {
84        self.bytes.is_empty()
85    }
86
87    /// The exact immutable payload bytes.
88    pub fn bytes(&self) -> &[u8] {
89        self.bytes.as_slice()
90    }
91}
92
93/// An owned, immutable, destination-independent Pack Extraction Plan.
94///
95/// Entries are collision-checked and ordered by canonical relative path. The
96/// plan contains no destination, platform path, conflict policy, or write state.
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct PackExtractionPlan {
99    pack_identity: CanonicalIdentity,
100    selection: PackExtractionSelection,
101    entries: Vec<PackExtractionEntry>,
102}
103
104impl PackExtractionPlan {
105    /// The identity of the Pack projected by this plan.
106    pub fn pack_identity(&self) -> &CanonicalIdentity {
107        &self.pack_identity
108    }
109
110    /// The embedded dependency choices used to construct this plan.
111    pub fn selection(&self) -> PackExtractionSelection {
112        self.selection
113    }
114
115    /// The canonical destination-relative entries in path order.
116    pub fn entries(&self) -> &[PackExtractionEntry] {
117        &self.entries
118    }
119}
120
121/// One independently detectable issue in a Pack Extraction projection.
122#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
123#[non_exhaustive]
124pub enum PackExtractionPlanIssue {
125    /// Two different semantic roles cannot coexist in one destination tree.
126    #[error(
127        "extraction path {first_path:?} ({first_role}) conflicts with {second_path:?} ({second_role})"
128    )]
129    PathConflict {
130        first_path: String,
131        first_role: PackExtractionEntryRole,
132        second_path: String,
133        second_role: PackExtractionEntryRole,
134    },
135}
136
137impl PackExtractionPlanIssue {
138    fn sort_key(&self) -> (u8, &str, u8, &str) {
139        match self {
140            Self::PathConflict {
141                first_path,
142                first_role,
143                second_path,
144                second_role,
145            } => (
146                role_index(*first_role),
147                first_path,
148                role_index(*second_role),
149                second_path,
150            ),
151        }
152    }
153}
154
155/// A failure while constructing a Pack Extraction Plan.
156#[derive(Clone, Debug, Eq, PartialEq)]
157pub struct PackExtractionPlanError {
158    issues: Vec<PackExtractionPlanIssue>,
159}
160
161impl PackExtractionPlanError {
162    /// Every independently detectable issue in role and canonical path order.
163    pub fn issues(&self) -> &[PackExtractionPlanIssue] {
164        &self.issues
165    }
166}
167
168impl fmt::Display for PackExtractionPlanError {
169    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
170        if let [issue] = self.issues.as_slice() {
171            return issue.fmt(formatter);
172        }
173        write!(
174            formatter,
175            "Pack Extraction planning failed with {} issue(s)",
176            self.issues.len()
177        )?;
178        for issue in &self.issues {
179            write!(formatter, ": {issue}")?;
180        }
181        Ok(())
182    }
183}
184
185impl std::error::Error for PackExtractionPlanError {}
186
187/// Produces the complete semantic projection of one Pack before destination I/O.
188pub fn plan_pack_extraction(
189    pack: &Pack,
190    selection: PackExtractionSelection,
191) -> Result<PackExtractionPlan, PackExtractionPlanError> {
192    let mut entries = BTreeMap::new();
193    let mut issues = Vec::new();
194    for (path, _) in pack.files() {
195        add_entry(
196            &mut entries,
197            path.to_owned(),
198            PackExtractionEntryRole::ProjectFile,
199            pack.shared_file(path)
200                .expect("a Pack project file has shared bytes")
201                .clone(),
202            &mut issues,
203        );
204    }
205
206    if selection.packages() {
207        for (spec, files) in pack.packages() {
208            let base = format!("packages/{}/{}/{}", spec.namespace, spec.name, spec.version);
209            for (path, _) in files {
210                add_entry(
211                    &mut entries,
212                    format!("{base}/{path}"),
213                    PackExtractionEntryRole::PackageFile,
214                    pack.shared_package_file(spec, path)
215                        .expect("an embedded Package Tree file has shared bytes")
216                        .clone(),
217                    &mut issues,
218                );
219            }
220        }
221    }
222
223    if selection.fonts() {
224        for font in pack.fonts() {
225            add_entry(
226                &mut entries,
227                font_container_path(font.identity().container(), Some(font.data())),
228                PackExtractionEntryRole::FontContainer,
229                font.shared_data().clone(),
230                &mut issues,
231            );
232        }
233    }
234
235    collect_tree_conflicts(&entries, &mut issues);
236    if !issues.is_empty() {
237        issues.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
238        return Err(PackExtractionPlanError { issues });
239    }
240
241    Ok(PackExtractionPlan {
242        pack_identity: pack.identity(),
243        selection,
244        entries: entries.into_values().collect(),
245    })
246}
247
248fn add_entry(
249    entries: &mut BTreeMap<String, PackExtractionEntry>,
250    relative_path: String,
251    role: PackExtractionEntryRole,
252    bytes: SharedBytes,
253    issues: &mut Vec<PackExtractionPlanIssue>,
254) {
255    match entries.entry(relative_path) {
256        Entry::Occupied(existing) if existing.get().role != role => {
257            issues.push(PackExtractionPlanIssue::PathConflict {
258                first_path: existing.key().clone(),
259                first_role: existing.get().role,
260                second_path: existing.key().clone(),
261                second_role: role,
262            });
263        }
264        Entry::Occupied(_) => {}
265        Entry::Vacant(entry) => {
266            let relative_path = entry.key().clone();
267            entry.insert(PackExtractionEntry {
268                relative_path,
269                role,
270                bytes,
271            });
272        }
273    }
274}
275
276fn collect_tree_conflicts(
277    entries: &BTreeMap<String, PackExtractionEntry>,
278    issues: &mut Vec<PackExtractionPlanIssue>,
279) {
280    let mut ancestors = Vec::<(&str, PackExtractionEntryRole)>::new();
281
282    for (relative_path, entry) in entries {
283        while ancestors
284            .last()
285            .is_some_and(|(ancestor, _)| !is_ancestor(ancestor, relative_path))
286        {
287            ancestors.pop();
288        }
289
290        for (ancestor, ancestor_role) in ancestors.iter().filter(|(_, role)| *role != entry.role) {
291            issues.push(PackExtractionPlanIssue::PathConflict {
292                first_path: (*ancestor).to_owned(),
293                first_role: *ancestor_role,
294                second_path: relative_path.clone(),
295                second_role: entry.role,
296            });
297        }
298
299        ancestors.push((relative_path, entry.role));
300    }
301}
302
303fn is_ancestor(ancestor: &str, descendant: &str) -> bool {
304    descendant
305        .strip_prefix(ancestor)
306        .is_some_and(|suffix| suffix.starts_with('/'))
307}
308
309fn role_index(role: PackExtractionEntryRole) -> u8 {
310    match role {
311        PackExtractionEntryRole::ProjectFile => 0,
312        PackExtractionEntryRole::PackageFile => 1,
313        PackExtractionEntryRole::FontContainer => 2,
314    }
315}