1use crate::destination::{
2 bind_local_entry, bind_package_entry, export_kind_matches_role, new_destination_target,
3};
4use crate::format_serialize::{serialize_recommended, target_has_extras};
5use crate::manifest::{
6 DestinationMode, ExportRole, Manifest, ManifestLocal, ManifestPackage, ManifestTarget,
7};
8use crate::resolve::ResolvedProject;
9use crate::{PrayError, PrayResult};
10use std::collections::{BTreeMap, BTreeSet};
11
12#[derive(Debug, Clone, Default)]
13pub struct PackageFormatHint {
14 pub roles: Vec<ExportRole>,
15 pub file_path: Option<String>,
16 pub exports: Vec<String>,
18}
19
20pub fn uses_destination_dsl(manifest: &Manifest) -> bool {
21 manifest
22 .targets
23 .iter()
24 .any(|target| target.scoped || target.mode != DestinationMode::Legacy)
25 || manifest
26 .packages
27 .iter()
28 .any(|package| package.bound || package.file.is_some())
29 || manifest.local.iter().any(|local| local.bound)
30}
31
32fn has_migratable_legacy_targets(manifest: &Manifest) -> bool {
33 manifest.targets.iter().any(|target| {
34 !target.scoped
35 && target.mode == DestinationMode::Legacy
36 && (!target.outputs.is_empty() || !target.skills.is_empty())
37 })
38}
39
40pub fn classify_format_hints(project: &ResolvedProject) -> BTreeMap<String, PackageFormatHint> {
41 let mut hints = BTreeMap::new();
42 for package in &project.packages {
43 let mut roles = Vec::new();
44 let mut file_path = package.declaration.file.clone();
45 for export_name in &package.selected_exports {
46 let Some(export) = package.spec.exports.get(export_name) else {
47 continue;
48 };
49 for role in [ExportRole::Fragment, ExportRole::Folder, ExportRole::File] {
50 if export_kind_matches_role(&export.kind, role) && !roles.contains(&role) {
51 roles.push(role);
52 }
53 }
54 if file_path.is_none() && export.kind == "file" {
55 file_path = export
56 .default_path
57 .clone()
58 .or_else(|| Some(export_name.clone()));
59 }
60 }
61 let exports =
62 ambiguous_exports_for_roles(&package.selected_exports, &package.spec.exports, &roles);
63 hints.insert(
64 package.declaration.name.clone(),
65 PackageFormatHint {
66 roles,
67 file_path,
68 exports,
69 },
70 );
71 }
72 hints
73}
74
75fn ambiguous_exports_for_roles(
76 selected_exports: &[String],
77 exports: &BTreeMap<String, crate::package_spec::PackageExport>,
78 roles: &[ExportRole],
79) -> Vec<String> {
80 let mut ambiguous = Vec::new();
81 for role in roles {
82 let matching: Vec<String> = selected_exports
83 .iter()
84 .filter(|export_name| {
85 exports
86 .get(*export_name)
87 .is_some_and(|export| export_kind_matches_role(&export.kind, *role))
88 })
89 .cloned()
90 .collect();
91 if matching.len() > 1 {
92 for export_name in matching {
93 if !ambiguous.contains(&export_name) {
94 ambiguous.push(export_name);
95 }
96 }
97 }
98 }
99 ambiguous
100}
101
102pub fn recommend_manifest(
103 manifest: &Manifest,
104 hints: &BTreeMap<String, PackageFormatHint>,
105) -> Manifest {
106 let mut recommended = if has_migratable_legacy_targets(manifest) {
108 migrate_legacy_manifest(manifest, hints)
109 } else {
110 manifest.clone()
111 };
112 omit_context_resolved_exports(&mut recommended);
113 omit_default_sources(&mut recommended);
114 recommended.deprecated_keywords.clear();
115 recommended
116}
117
118fn omit_context_resolved_exports(manifest: &mut Manifest) {
119 for package in &mut manifest.packages {
120 if package.bound && package.exports.len() <= 1 {
121 package.exports.clear();
122 }
123 }
124}
125
126fn package_namespace(name: &str) -> Option<&str> {
127 name.split_once('/').map(|(namespace, _)| namespace)
128}
129
130fn omit_default_sources(manifest: &mut Manifest) {
131 let sole_source = (manifest.sources.len() == 1).then(|| manifest.sources[0].name.clone());
132 let source_names: BTreeSet<&str> = manifest
133 .sources
134 .iter()
135 .map(|source| source.name.as_str())
136 .collect();
137 for package in &mut manifest.packages {
138 let Some(source) = package.source.as_deref() else {
139 continue;
140 };
141 let matches_sole = sole_source.as_deref() == Some(source);
142 let matches_namespace =
143 package_namespace(&package.name) == Some(source) && source_names.contains(source);
144 if matches_sole || matches_namespace {
145 package.source = None;
146 }
147 }
148}
149
150pub fn format_recommended(
151 manifest: &Manifest,
152 hints: &BTreeMap<String, PackageFormatHint>,
153) -> PrayResult<String> {
154 let recommended = recommend_manifest(manifest, hints);
155 let text = serialize_recommended(&recommended);
156 let reparsed = crate::manifest::parse_manifest(&text)?;
157 if reparsed.canonicalized() != recommended.canonicalized() {
158 return Err(PrayError::Manifest(
159 "formatted Prayfile did not round-trip to an equivalent manifest".to_string(),
160 ));
161 }
162 Ok(text)
163}
164
165fn migrate_legacy_manifest(
166 manifest: &Manifest,
167 hints: &BTreeMap<String, PackageFormatHint>,
168) -> Manifest {
169 let mut next = Manifest {
170 prayfile_version: manifest.prayfile_version.clone(),
171 sources: manifest.sources.clone(),
172 targets: Vec::new(),
173 packages: manifest.packages.clone(),
174 local: manifest.local.clone(),
175 symbols: manifest.symbols.clone(),
176 render: manifest.render.clone(),
177 deprecated_keywords: Vec::new(),
178 };
179
180 apply_format_hints(&mut next.packages, hints);
181
182 let compose_paths = unique_paths(manifest.targets.iter().flat_map(|target| {
183 target
184 .outputs
185 .iter()
186 .map(move |path| (path.clone(), target.name.clone()))
187 }));
188 let tree_paths = unique_paths(manifest.targets.iter().flat_map(|target| {
189 target
190 .skills
191 .iter()
192 .map(move |path| (path.clone(), target.name.clone()))
193 }));
194
195 for (path, target_names) in &compose_paths {
196 let mut target = new_destination_target(DestinationMode::Compose, path);
197 for local in locals_for_compose(&next.local) {
198 bind_local_entry(&mut target, &local.path);
199 if let Some(entry) = next
200 .local
201 .iter_mut()
202 .find(|candidate| candidate.path == local.path)
203 {
204 entry.bound = true;
205 }
206 }
207 for package in packages_for_role(&next.packages, ExportRole::Fragment, target_names) {
208 bind_package_entry(&mut target, &package.name);
209 mark_package_bound(&mut next.packages, &package.name, ExportRole::Fragment);
210 }
211 next.targets.push(target);
212 }
213
214 for (path, target_names) in &tree_paths {
215 let mut target = new_destination_target(DestinationMode::Tree, path);
216 for package in packages_for_role(&next.packages, ExportRole::Folder, target_names) {
217 bind_package_entry(&mut target, &package.name);
218 mark_package_bound(&mut next.packages, &package.name, ExportRole::Folder);
219 }
220 next.targets.push(target);
221 }
222
223 for package in &mut next.packages {
224 if package.file.is_some() {
225 package.bound = true;
226 if !package.roles.contains(&ExportRole::File) {
227 package.roles.push(ExportRole::File);
228 }
229 }
230 }
231 for local in &mut next.local {
232 if local.bound {
233 local.position = "after".to_string();
235 }
236 }
237
238 for target in &manifest.targets {
239 if target_has_extras(target) {
240 next.targets.push(ManifestTarget {
241 name: target.name.clone(),
242 commands: target.commands.clone(),
243 rules: target.rules.clone(),
244 max_bytes: target.max_bytes,
245 ..ManifestTarget::default()
246 });
247 }
248 }
249
250 next
251}
252
253fn apply_format_hints(
254 packages: &mut [ManifestPackage],
255 hints: &BTreeMap<String, PackageFormatHint>,
256) {
257 for package in packages {
258 if let Some(hint) = hints.get(&package.name) {
259 for role in &hint.roles {
260 if !package.roles.contains(role) {
261 package.roles.push(*role);
262 }
263 }
264 if package.file.is_none() {
265 package.file = hint.file_path.clone();
266 }
267 if package.exports.is_empty() && !hint.exports.is_empty() {
268 package.exports = hint.exports.clone();
269 }
270 }
271 if package.file.is_some() && !package.roles.contains(&ExportRole::File) {
272 package.roles.push(ExportRole::File);
273 }
274 }
275}
276
277fn unique_paths(items: impl Iterator<Item = (String, String)>) -> Vec<(String, BTreeSet<String>)> {
278 let mut map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
279 for (path, target_name) in items {
280 map.entry(path).or_default().insert(target_name);
281 }
282 map.into_iter().collect()
283}
284
285fn locals_for_compose(locals: &[ManifestLocal]) -> Vec<ManifestLocal> {
286 let mut before = Vec::new();
287 let mut after = Vec::new();
288 for local in locals {
289 if local.bound {
290 continue;
291 }
292 match local.position.as_str() {
293 "start" | "before" => before.push(local.clone()),
294 _ => after.push(local.clone()),
295 }
296 }
297 before.extend(after);
298 before
299}
300
301fn packages_for_role(
302 packages: &[ManifestPackage],
303 role: ExportRole,
304 target_names: &BTreeSet<String>,
305) -> Vec<ManifestPackage> {
306 packages
307 .iter()
308 .filter(|package| {
309 if package.file.is_some() {
310 return false;
311 }
312 if !package.targets.is_empty()
313 && !package
314 .targets
315 .iter()
316 .any(|name| target_names.contains(name))
317 {
318 return false;
319 }
320 package.roles.contains(&role)
321 })
322 .cloned()
323 .collect()
324}
325
326fn mark_package_bound(packages: &mut [ManifestPackage], name: &str, role: ExportRole) {
327 if let Some(package) = packages.iter_mut().find(|package| package.name == name) {
328 package.bound = true;
329 if !package.roles.contains(&role) {
330 package.roles.push(role);
331 }
332 }
333}