Skip to main content

blazingly_aasa/
compile.rs

1//! Normalisation: turning the wire model into something matchable and comparable.
2//!
3//! Compilation resolves the three-level defaults hierarchy, merges `appID` and `appIDs`, expands
4//! `$(...)` references, and compiles every pattern — while preserving rule order and source
5//! indices, because both are semantically load-bearing.
6
7use crate::diagnostics::{Diagnostic, DiagnosticCode, ValidationReport};
8use crate::error::ParseError;
9use crate::model::{
10    AasaDocument, AppLinkDetail, AppLinks, ComponentRule, EffectiveDefaults, QueryPredicate,
11    QueryRule,
12};
13use crate::parse::ParseOptions;
14use crate::pattern::{Pattern, PatternError};
15use crate::substitution::SubstitutionTable;
16use serde::Serialize;
17use std::collections::{BTreeMap, BTreeSet};
18
19pub(crate) const SERVICES: [Service; 4] = [
20    Service::AppLinks,
21    Service::WebCredentials,
22    Service::AppClips,
23    Service::ActivityContinuation,
24];
25
26/// An Associated Domains service.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Service {
30    /// Universal links.
31    AppLinks,
32    /// Shared web credentials.
33    WebCredentials,
34    /// App Clips.
35    AppClips,
36    /// Handoff.
37    ActivityContinuation,
38}
39
40impl Service {
41    /// The key this service uses in the association file.
42    #[must_use]
43    pub const fn key(self) -> &'static str {
44        match self {
45            Self::AppLinks => "applinks",
46            Self::WebCredentials => "webcredentials",
47            Self::AppClips => "appclips",
48            Self::ActivityContinuation => "activitycontinuation",
49        }
50    }
51}
52
53impl std::fmt::Display for Service {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.write_str(self.key())
56    }
57}
58
59/// A `?` constraint reduced to its comparable form.
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum EffectiveQuery {
63    /// One pattern matched against the whole query string.
64    Whole(String),
65    /// Named predicates, all of which must hold. `None` marks a predicate Apple ignores, which
66    /// makes the whole dictionary inert.
67    Items(BTreeMap<String, Option<String>>),
68}
69
70/// A rule reduced to exactly what decides matching.
71///
72/// Two rules with the same [`EffectiveRule`] behave identically, no matter how the document
73/// distributed `caseSensitive` and `percentEncoded` across the defaults hierarchy. This is what
74/// makes [`semantic_diff`](crate::CompiledAasa::semantic_diff) able to see past a refactor.
75#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
76// Four booleans is exactly what an Apple rule carries; collapsing them into enums would obscure
77// the mapping to the file format without making anything safer.
78#[allow(clippy::struct_excessive_bools)]
79pub struct EffectiveRule {
80    /// Whether the rule blocks the URL.
81    pub exclude: bool,
82    /// Whether the rule came from legacy `paths`.
83    pub legacy: bool,
84    /// The `/` pattern, or `None` when unconstrained.
85    pub path: Option<String>,
86    /// The `?` constraint, or `None` when unconstrained.
87    pub query: Option<EffectiveQuery>,
88    /// The `#` pattern, or `None` when unconstrained.
89    pub fragment: Option<String>,
90    /// Effective `caseSensitive`.
91    pub case_sensitive: bool,
92    /// Effective `percentEncoded`.
93    pub percent_encoded: bool,
94}
95
96impl std::fmt::Display for EffectiveRule {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        let mut parts: Vec<String> = Vec::new();
99        if let Some(path) = &self.path {
100            parts.push(format!("/ = {path}"));
101        }
102        match &self.query {
103            Some(EffectiveQuery::Whole(pattern)) => parts.push(format!("? = {pattern}")),
104            Some(EffectiveQuery::Items(items)) => {
105                let rendered = items
106                    .iter()
107                    .map(|(key, value)| match value {
108                        Some(pattern) => format!("{key}={pattern}"),
109                        None => format!("{key}=<unsupported>"),
110                    })
111                    .collect::<Vec<_>>()
112                    .join(", ");
113                parts.push(format!("? = {{{rendered}}}"));
114            }
115            None => {}
116        }
117        if let Some(fragment) = &self.fragment {
118            parts.push(format!("# = {fragment}"));
119        }
120        if parts.is_empty() {
121            parts.push("<matches every URL>".to_owned());
122        }
123        if self.exclude {
124            parts.push("exclude".to_owned());
125        }
126        parts.push(format!("caseSensitive={}", self.case_sensitive));
127        parts.push(format!("percentEncoded={}", self.percent_encoded));
128        if self.legacy {
129            parts.push("legacy".to_owned());
130        }
131        f.write_str(&parts.join(", "))
132    }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub(crate) enum CompiledQuery {
137    Whole(Pattern),
138    Items(Vec<(String, Pattern)>),
139    /// A dictionary holding at least one non-string predicate.
140    ///
141    /// `swcutil` ignores the whole dictionary in that case rather than the offending entry, so a
142    /// single `"flag": true` silently drops every constraint beside it. This matches that, and
143    /// `AASA150` reports it as an error because of how much it quietly opens up.
144    IgnoredDictionary(Vec<String>),
145}
146
147/// A compiled `/` pattern together with what its shape allows.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub(crate) struct CompiledPath {
150    pub(crate) pattern: Pattern,
151    /// The same pattern minus a trailing `/*`, compiled only when the pattern has one.
152    pub(crate) parent: Option<Pattern>,
153    /// Whether the path must also be tried without its leading slash. False for the usual
154    /// `/`-rooted pattern, which can never match a path that lacks one.
155    pub(crate) try_bare_path: bool,
156}
157
158impl CompiledPath {
159    /// Whether any form of this pattern matches any form of the path.
160    pub(crate) fn matches(&self, trimmed: &str, bare: Option<&str>, case_sensitive: bool) -> bool {
161        for pattern in std::iter::once(&self.pattern).chain(self.parent.as_ref()) {
162            if pattern.matches_with(trimmed, case_sensitive) {
163                return true;
164            }
165            if self.try_bare_path {
166                if let Some(bare) = bare {
167                    if pattern.matches_with(bare, case_sensitive) {
168                        return true;
169                    }
170                }
171            }
172        }
173        false
174    }
175
176    pub(crate) fn source(&self) -> &str {
177        self.pattern.source()
178    }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub(crate) struct CompiledRule {
183    pub(crate) detail_index: usize,
184    pub(crate) rule_index: usize,
185    pub(crate) legacy: bool,
186    pub(crate) exclude: bool,
187    pub(crate) effective: EffectiveDefaults,
188    pub(crate) path: Option<CompiledPath>,
189    pub(crate) query: Option<CompiledQuery>,
190    pub(crate) fragment: Option<Pattern>,
191    pub(crate) comment: Option<String>,
192}
193
194impl CompiledRule {
195    /// Whether the rule constrains nothing and so accepts every URL.
196    pub(crate) fn is_unconstrained(&self) -> bool {
197        let path_any = self
198            .path
199            .as_ref()
200            .map_or(true, |path| path.pattern.is_any());
201        let fragment_any = self.fragment.as_ref().map_or(true, Pattern::is_any);
202        let query_any = match &self.query {
203            None | Some(CompiledQuery::IgnoredDictionary(_)) => true,
204            Some(CompiledQuery::Whole(pattern)) => pattern.is_any(),
205            Some(CompiledQuery::Items(items)) => items.is_empty(),
206        };
207        path_any && fragment_any && query_any
208    }
209
210    pub(crate) fn effective_rule(&self) -> EffectiveRule {
211        let query = self.query.as_ref().map(|query| match query {
212            CompiledQuery::Whole(pattern) => EffectiveQuery::Whole(pattern.source().to_owned()),
213            CompiledQuery::Items(items) => EffectiveQuery::Items(
214                items
215                    .iter()
216                    .map(|(key, pattern)| (key.clone(), Some(pattern.source().to_owned())))
217                    .collect(),
218            ),
219            CompiledQuery::IgnoredDictionary(keys) => {
220                EffectiveQuery::Items(keys.iter().map(|key| (key.clone(), None)).collect())
221            }
222        });
223        let path = self.path.as_ref().map(|path| path.source().to_owned());
224        let fragment = self.fragment.as_ref().map(|p| p.source().to_owned());
225        // A rule that constrains nothing behaves identically regardless of its flags, so normalise
226        // them away rather than reporting a spurious difference.
227        let unconstrained = self.is_unconstrained();
228        EffectiveRule {
229            exclude: self.exclude,
230            legacy: self.legacy,
231            path,
232            query,
233            fragment,
234            case_sensitive: !unconstrained && self.effective.case_sensitive,
235            percent_encoded: !unconstrained && self.effective.percent_encoded,
236        }
237    }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub(crate) struct CompiledDetail {
242    pub(crate) index: usize,
243    pub(crate) app_ids: Vec<String>,
244    pub(crate) rules: Vec<CompiledRule>,
245}
246
247impl CompiledDetail {
248    pub(crate) fn applies_to(&self, app_id: &str) -> bool {
249        self.app_ids.iter().any(|candidate| candidate == app_id)
250    }
251}
252
253/// A document normalised for matching, explaining, and comparing.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub struct CompiledAasa {
256    pub(crate) document: AasaDocument,
257    pub(crate) details: Vec<CompiledDetail>,
258    pub(crate) has_applinks: bool,
259    pub(crate) webcredentials: Vec<String>,
260    pub(crate) appclips: Vec<String>,
261    pub(crate) activitycontinuation: Vec<String>,
262    pub(crate) substitution_variables: BTreeMap<String, Vec<String>>,
263    pub(crate) compile_diagnostics: Vec<Diagnostic>,
264    /// Whether any rule turns `percentEncoded` off. When nothing does, matching never pays for
265    /// percent-decoding the URL.
266    pub(crate) needs_decoded: bool,
267    /// Whether any rule uses a `?` dictionary. When none does, the query is never split into items.
268    pub(crate) needs_query_items: bool,
269}
270
271impl CompiledAasa {
272    /// Parses and compiles in one step.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
277    pub fn parse(bytes: &[u8]) -> Result<Self, ParseError> {
278        Ok(AasaDocument::parse(bytes)?.compile())
279    }
280
281    /// Parses and compiles with explicit limits.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`ParseError`] for invalid JSON, a non-object root, or an oversized payload.
286    pub fn parse_with(bytes: &[u8], options: &ParseOptions) -> Result<Self, ParseError> {
287        Ok(AasaDocument::parse_with(bytes, options)?.compile())
288    }
289
290    /// The wire model this was compiled from.
291    #[must_use]
292    pub fn document(&self) -> &AasaDocument {
293        &self.document
294    }
295
296    /// Whether the document declared an `applinks` section at all.
297    #[must_use]
298    pub fn has_applinks(&self) -> bool {
299        self.has_applinks
300    }
301
302    /// Every application identifier that appears in `applinks.details`, deduplicated and sorted.
303    #[must_use]
304    pub fn applink_apps(&self) -> Vec<&str> {
305        let set: BTreeSet<&str> = self
306            .details
307            .iter()
308            .flat_map(|detail| detail.app_ids.iter().map(String::as_str))
309            .collect();
310        set.into_iter().collect()
311    }
312
313    /// Whether `app_id` can open universal links for this domain, ignoring any specific URL.
314    #[must_use]
315    pub fn has_applink_app(&self, app_id: &str) -> bool {
316        self.details.iter().any(|detail| detail.applies_to(app_id))
317    }
318
319    /// Whether `app_id` is listed under `webcredentials`.
320    #[must_use]
321    pub fn has_webcredential_app(&self, app_id: &str) -> bool {
322        self.webcredentials.iter().any(|entry| entry == app_id)
323    }
324
325    /// Whether `app_id` is listed under `appclips`.
326    #[must_use]
327    pub fn has_appclip(&self, app_id: &str) -> bool {
328        self.appclips.iter().any(|entry| entry == app_id)
329    }
330
331    /// Whether `app_id` is listed under `activitycontinuation`.
332    #[must_use]
333    pub fn has_activitycontinuation_app(&self, app_id: &str) -> bool {
334        self.activitycontinuation
335            .iter()
336            .any(|entry| entry == app_id)
337    }
338
339    /// Every service this domain grants the app built from a team prefix and bundle identifier.
340    ///
341    /// Convenience for callers that hold the two halves separately — Xcode shows them apart, and
342    /// so do most validators.
343    #[must_use]
344    pub fn services_for_bundle(&self, team_id: &str, bundle_id: &str) -> Vec<Service> {
345        self.services_for_app(&format!("{team_id}.{bundle_id}"))
346    }
347
348    /// Every application identifier in the document whose bundle identifier is `bundle_id`,
349    /// whatever its team prefix.
350    ///
351    /// Useful when an app moved between teams and you want to know which prefix the file still
352    /// names.
353    #[must_use]
354    pub fn app_ids_for_bundle(&self, bundle_id: &str) -> Vec<&str> {
355        let mut found: Vec<&str> = Vec::new();
356        for service in SERVICES {
357            for app_id in self.apps_for_service(service) {
358                if crate::split_app_id(app_id).is_some_and(|(_, bundle)| bundle == bundle_id)
359                    && !found.contains(&app_id)
360                {
361                    found.push(app_id);
362                }
363            }
364        }
365        found.sort_unstable();
366        found
367    }
368
369    /// Every service this domain grants `app_id`.
370    #[must_use]
371    pub fn services_for_app(&self, app_id: &str) -> Vec<Service> {
372        let mut services = Vec::new();
373        if self.has_applink_app(app_id) {
374            services.push(Service::AppLinks);
375        }
376        if self.has_webcredential_app(app_id) {
377            services.push(Service::WebCredentials);
378        }
379        if self.has_appclip(app_id) {
380            services.push(Service::AppClips);
381        }
382        if self.has_activitycontinuation_app(app_id) {
383            services.push(Service::ActivityContinuation);
384        }
385        services
386    }
387
388    /// The apps listed for one service.
389    #[must_use]
390    pub fn apps_for_service(&self, service: Service) -> Vec<&str> {
391        match service {
392            Service::AppLinks => self.applink_apps(),
393            Service::WebCredentials => self.webcredentials.iter().map(String::as_str).collect(),
394            Service::AppClips => self.appclips.iter().map(String::as_str).collect(),
395            Service::ActivityContinuation => self
396                .activitycontinuation
397                .iter()
398                .map(String::as_str)
399                .collect(),
400        }
401    }
402
403    /// The ordered rules that apply to `app_id`, reduced to their deciding form.
404    #[must_use]
405    pub fn effective_rules_for(&self, app_id: &str) -> Vec<EffectiveRule> {
406        self.details
407            .iter()
408            .filter(|detail| detail.applies_to(app_id))
409            .flat_map(|detail| detail.rules.iter().map(CompiledRule::effective_rule))
410            .collect()
411    }
412
413    /// The custom `substitutionVariables` table.
414    #[must_use]
415    pub fn substitution_variables(&self) -> &BTreeMap<String, Vec<String>> {
416        &self.substitution_variables
417    }
418
419    /// Validates the document, combining structural, compilation, and semantic findings.
420    #[must_use]
421    pub fn validate(&self) -> ValidationReport {
422        let mut diagnostics = self.document.structural.clone();
423        diagnostics.extend(self.compile_diagnostics.iter().cloned());
424        diagnostics.extend(crate::validate::semantic(self));
425        ValidationReport::from_diagnostics(diagnostics)
426    }
427}
428
429pub(crate) fn compile(document: &AasaDocument) -> CompiledAasa {
430    let mut diagnostics = Vec::new();
431    let mut details = Vec::new();
432    let mut substitution_variables = BTreeMap::new();
433    let has_applinks = document.applinks.is_some();
434
435    if let Some(applinks) = &document.applinks {
436        substitution_variables.clone_from(&applinks.substitution_variables);
437        let table = build_table(applinks, &mut diagnostics);
438        for (index, detail) in applinks.details.iter().enumerate() {
439            details.push(compile_detail(
440                applinks,
441                detail,
442                index,
443                &table,
444                &mut diagnostics,
445            ));
446        }
447    }
448
449    let needs_decoded = details
450        .iter()
451        .flat_map(|detail| detail.rules.iter())
452        .any(|rule| !rule.effective.percent_encoded);
453    let needs_query_items = details
454        .iter()
455        .flat_map(|detail| detail.rules.iter())
456        .any(|rule| matches!(rule.query, Some(CompiledQuery::Items(_))));
457
458    CompiledAasa {
459        document: document.clone(),
460        details,
461        has_applinks,
462        webcredentials: service_apps(document.webcredentials.as_ref()),
463        appclips: service_apps(document.appclips.as_ref()),
464        activitycontinuation: service_apps(document.activitycontinuation.as_ref()),
465        substitution_variables,
466        compile_diagnostics: diagnostics,
467        needs_decoded,
468        needs_query_items,
469    }
470}
471
472fn service_apps(service: Option<&crate::model::AppService>) -> Vec<String> {
473    service
474        .map(|service| service.apps.clone())
475        .unwrap_or_default()
476}
477
478fn build_table(applinks: &AppLinks, diagnostics: &mut Vec<Diagnostic>) -> SubstitutionTable {
479    for (name, values) in &applinks.substitution_variables {
480        let path = format!("applinks.substitutionVariables.{name}");
481        if name.contains(['$', '(', ')']) {
482            diagnostics.push(
483                Diagnostic::new(
484                    DiagnosticCode::MalformedSubstitutionName,
485                    &path,
486                    format!("`{name}` contains $, ( or ), which Apple does not allow in a variable name"),
487                )
488                .with_help("rename the variable using only characters outside $ ( )"),
489            );
490        }
491        if SubstitutionTable::is_predefined(name) {
492            diagnostics.push(
493                Diagnostic::new(
494                    DiagnosticCode::SubstitutionShadowsPredefined,
495                    &path,
496                    format!("`{name}` shadows the predefined $({name}) variable"),
497                )
498                .with_help("this crate honours your definition; rename it to avoid ambiguity"),
499            );
500        }
501        if values.is_empty() {
502            diagnostics.push(
503                Diagnostic::new(
504                    DiagnosticCode::EmptySubstitutionList,
505                    &path,
506                    format!("`{name}` has no values, so any pattern using it can never match"),
507                )
508                .with_help("remove the variable or give it at least one value"),
509            );
510        }
511        for (index, value) in values.iter().enumerate() {
512            if value.contains("$(") {
513                diagnostics.push(
514                    Diagnostic::new(
515                        DiagnosticCode::RecursiveSubstitutionValue,
516                        format!("{path}[{index}]"),
517                        format!(
518                            "`{value}` references another substitution variable, which Apple does \
519                             not allow"
520                        ),
521                    )
522                    .with_help("inline the referenced values instead"),
523                );
524            }
525            if value.is_empty() {
526                diagnostics.push(Diagnostic::new(
527                    DiagnosticCode::EmptyPatternAlternative,
528                    format!("{path}[{index}]"),
529                    format!("`{name}` contains an empty alternative"),
530                ));
531            }
532        }
533    }
534    SubstitutionTable::from_custom(applinks.substitution_variables.clone())
535}
536
537fn compile_detail(
538    applinks: &AppLinks,
539    detail: &AppLinkDetail,
540    index: usize,
541    table: &SubstitutionTable,
542    diagnostics: &mut Vec<Diagnostic>,
543) -> CompiledDetail {
544    let base = EffectiveDefaults::default()
545        .overridden_by(applinks.defaults.as_ref())
546        .overridden_by(detail.defaults.as_ref());
547
548    let mut app_ids: Vec<String> = Vec::new();
549    for app_id in detail.declared_app_ids() {
550        if !app_ids.iter().any(|existing| existing == app_id) {
551            app_ids.push(app_id.to_owned());
552        }
553    }
554
555    let mut rules = Vec::new();
556    let detail_path = || format!("applinks.details[{index}]");
557
558    if let Some(components) = &detail.components {
559        for (rule_index, component) in components.iter().enumerate() {
560            rules.push(compile_component(
561                component,
562                index,
563                rule_index,
564                base,
565                table,
566                &|| format!("{}.components[{rule_index}]", detail_path()),
567                diagnostics,
568            ));
569        }
570    }
571
572    if let Some(paths) = &detail.paths {
573        let offset = rules.len();
574        for (position, path) in paths.iter().enumerate() {
575            let rule_index = offset + position;
576            rules.push(compile_legacy_path(
577                path,
578                index,
579                rule_index,
580                base,
581                table,
582                &|| format!("{}.paths[{position}]", detail_path()),
583                diagnostics,
584            ));
585        }
586    }
587
588    CompiledDetail {
589        index,
590        app_ids,
591        rules,
592    }
593}
594
595/// Diagnostic paths are passed as closures: a healthy rule never formats one.
596type PathFn<'a> = &'a dyn Fn() -> String;
597
598fn compile_component(
599    component: &ComponentRule,
600    detail_index: usize,
601    rule_index: usize,
602    base: EffectiveDefaults,
603    table: &SubstitutionTable,
604    path: PathFn<'_>,
605    diagnostics: &mut Vec<Diagnostic>,
606) -> CompiledRule {
607    let mut effective = base;
608    if let Some(case_sensitive) = component.case_sensitive {
609        effective.case_sensitive = case_sensitive;
610    }
611    if let Some(percent_encoded) = component.percent_encoded {
612        effective.percent_encoded = percent_encoded;
613    }
614
615    // A leading run of slashes in a path pattern is not significant; `swcutil` matches `//abc`
616    // against `/abc`.
617    let compiled_path = component.path.as_ref().map(|pattern| {
618        compile_path(
619            pattern,
620            effective.case_sensitive,
621            table,
622            &|| format!("{}./", path()),
623            diagnostics,
624        )
625    });
626
627    let compiled_fragment = component.fragment.as_ref().map(|pattern| {
628        compile_pattern(
629            pattern,
630            effective.case_sensitive,
631            table,
632            &|| format!("{}.#", path()),
633            diagnostics,
634        )
635    });
636
637    let compiled_query = component.query.as_ref().map(|query| match query {
638        QueryRule::Whole(pattern) => CompiledQuery::Whole(compile_pattern(
639            pattern,
640            effective.case_sensitive,
641            table,
642            &|| format!("{}.?", path()),
643            diagnostics,
644        )),
645        QueryRule::Items(items) => {
646            if items
647                .values()
648                .any(|predicate| matches!(predicate, QueryPredicate::Unsupported { .. }))
649            {
650                CompiledQuery::IgnoredDictionary(items.keys().cloned().collect())
651            } else {
652                CompiledQuery::Items(
653                    items
654                        .iter()
655                        .filter_map(|(key, predicate)| match predicate {
656                            QueryPredicate::Pattern(pattern) => Some((
657                                key.clone(),
658                                compile_pattern(
659                                    pattern,
660                                    effective.case_sensitive,
661                                    table,
662                                    &|| format!("{}.?.{key}", path()),
663                                    diagnostics,
664                                ),
665                            )),
666                            QueryPredicate::Unsupported { .. } => None,
667                        })
668                        .collect(),
669                )
670            }
671        }
672    });
673
674    CompiledRule {
675        detail_index,
676        rule_index,
677        legacy: false,
678        exclude: component.exclude.unwrap_or(false),
679        effective,
680        path: compiled_path,
681        query: compiled_query,
682        fragment: compiled_fragment,
683        comment: component.comment.clone(),
684    }
685}
686
687fn compile_legacy_path(
688    source: &str,
689    detail_index: usize,
690    rule_index: usize,
691    base: EffectiveDefaults,
692    table: &SubstitutionTable,
693    path: PathFn<'_>,
694    diagnostics: &mut Vec<Diagnostic>,
695) -> CompiledRule {
696    let (exclude, pattern_source) = match source.strip_prefix("NOT ") {
697        Some(rest) => (true, rest.trim_start()),
698        None => (false, source),
699    };
700    let compiled = compile_path(
701        pattern_source,
702        base.case_sensitive,
703        table,
704        path,
705        diagnostics,
706    );
707    CompiledRule {
708        detail_index,
709        rule_index,
710        legacy: true,
711        exclude,
712        effective: base,
713        path: Some(compiled),
714        query: None,
715        fragment: None,
716        comment: None,
717    }
718}
719
720/// Compiles a `/` pattern, adding the parent form only when the pattern actually ends in `/*`.
721fn compile_path(
722    source: &str,
723    case_sensitive: bool,
724    table: &SubstitutionTable,
725    path: PathFn<'_>,
726    diagnostics: &mut Vec<Diagnostic>,
727) -> CompiledPath {
728    let (canonical, shape) = crate::url::normalize_path_pattern(source);
729    let pattern = compile_pattern(&canonical, case_sensitive, table, path, diagnostics);
730    let parent = shape.matches_parent.then(|| {
731        let parent = canonical
732            .strip_suffix("/*")
733            .expect("matches_parent implies a trailing /*");
734        compile_pattern(parent, case_sensitive, table, path, diagnostics)
735    });
736    CompiledPath {
737        pattern,
738        parent,
739        try_bare_path: !shape.canonical_leading_slash,
740    }
741}
742
743fn compile_pattern(
744    source: &str,
745    case_sensitive: bool,
746    table: &SubstitutionTable,
747    path: PathFn<'_>,
748    diagnostics: &mut Vec<Diagnostic>,
749) -> Pattern {
750    let mut errors = Vec::new();
751    let pattern = Pattern::compile(source, case_sensitive, table, &mut errors);
752    for error in errors {
753        diagnostics.push(match error {
754            PatternError::UnterminatedReference => Diagnostic::new(
755                DiagnosticCode::UnterminatedSubstitutionReference,
756                path(),
757                format!("`{source}` contains a `$(` that is never closed"),
758            )
759            .with_help("close the reference with `)`"),
760            PatternError::UnknownVariable(name) => Diagnostic::new(
761                DiagnosticCode::UnknownSubstitutionVariable,
762                path(),
763                format!("`$({name})` is neither a predefined variable nor declared in substitutionVariables"),
764            )
765            .with_help("declare it under applinks.substitutionVariables, or fix the spelling"),
766            PatternError::NestedSubstitution { variable, value } => Diagnostic::new(
767                DiagnosticCode::RecursiveSubstitutionValue,
768                path(),
769                format!("substitution value `{value}` references `$({variable})`"),
770            ),
771            PatternError::EmptyVariable(name) => Diagnostic::new(
772                DiagnosticCode::EmptySubstitutionList,
773                path(),
774                format!("`$({name})` has no values, so this pattern can never match"),
775            ),
776        });
777    }
778    pattern
779}