Skip to main content

meta_language/grammar/
fidelity.rs

1//! Per-format capability profiles for grammar round-trip fidelity.
2//!
3//! Each supported grammar notation exposes a [`LanguageProfile`] over the
4//! grammar IR construct vocabulary. Constructs are classified as lossless or
5//! equivalent when the target notation represents them without a documented
6//! lossy fallback, and lossy when the emitter must degrade, synthesize helper
7//! productions, drop metadata, or reject the construct with an explicit
8//! unsupported-construct error.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::language_profile::LanguageProfile;
13use crate::link_network::LinkType;
14
15/// Grammar formats with a fidelity profile.
16///
17/// The matrix starts with BNF because it is the first import/emission pair this
18/// issue depends on. Later importer/emitter issues can add rows by extending
19/// this list and adding a profile function branch.
20pub const GRAMMAR_FORMATS: &[&str] = &["bnf"];
21
22/// The grammar IR construct vocabulary classified by the fidelity matrix.
23///
24/// Every [`GrammarFormatProfile`] must classify each construct as either
25/// lossless/equivalent support or exactly one documented lossy fallback.
26pub const GRAMMAR_CONSTRUCTS: &[&str] = &[
27    "empty-expression",
28    "sequence",
29    "ordered-choice",
30    "unordered-choice",
31    "optional-expression",
32    "zero-or-more-repetition",
33    "one-or-more-repetition",
34    "counted-repetition",
35    "character-range",
36    "character-class",
37    "any-character",
38    "terminal",
39    "case-insensitive-terminal",
40    "nonterminal",
41    "positive-predicate",
42    "negative-predicate",
43    "capture",
44    "atomic-rule",
45    "silent-rule",
46    "token-rule",
47];
48
49/// Construct names used before the readable names, each with the construct
50/// that replaced it. Profile lookups still accept a former name.
51pub const FORMER_GRAMMAR_CONSTRUCTS: &[(&str, &str)] = &[
52    ("empty", "empty-expression"),
53    ("optional", "optional-expression"),
54    ("zero-or-more", "zero-or-more-repetition"),
55    ("one-or-more", "one-or-more-repetition"),
56    ("repeat-range", "counted-repetition"),
57    ("char-range", "character-range"),
58    ("char-class", "character-class"),
59    ("any-char", "any-character"),
60    ("non-terminal", "nonterminal"),
61    ("and-predicate", "positive-predicate"),
62    ("not-predicate", "negative-predicate"),
63    ("rule-kind-atomic", "atomic-rule"),
64    ("rule-kind-silent", "silent-rule"),
65    ("rule-kind-token", "token-rule"),
66];
67
68/// The current name of a grammar construct: `construct` itself, or the name that replaced it.
69#[must_use]
70pub fn current_grammar_construct(construct: &str) -> &str {
71    FORMER_GRAMMAR_CONSTRUCTS
72        .iter()
73        .find(|(former, _)| *former == construct)
74        .map_or(construct, |(_, current)| current)
75}
76
77/// Round-trip fidelity level for one construct in one grammar format.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum GrammarFidelityLevel {
80    /// The construct is represented natively and survives same-format
81    /// import/emit/re-import without a semantic fallback.
82    Lossless,
83    /// The construct is represented by a semantically equivalent spelling or
84    /// normalization.
85    Equivalent,
86    /// The construct requires a documented fallback, metadata drop, helper
87    /// expansion, or explicit unsupported-construct error.
88    Lossy,
89}
90
91impl GrammarFidelityLevel {
92    /// Markdown cell symbol used by `docs/grammar/fidelity.md`.
93    #[must_use]
94    pub const fn symbol(self) -> &'static str {
95        match self {
96            Self::Lossless => "✅",
97            Self::Equivalent => "≈",
98            Self::Lossy => "⚠️",
99        }
100    }
101}
102
103/// Capability profile for one grammar notation.
104///
105/// The embedded [`LanguageProfile`] carries the support-or-fallback invariant
106/// used by the document fidelity matrix. `equivalent_constructs` marks
107/// supported constructs whose round trip is semantically equivalent but not
108/// byte/canonical-form lossless.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct GrammarFormatProfile {
111    format: &'static str,
112    profile: LanguageProfile,
113    equivalent_constructs: BTreeSet<String>,
114}
115
116impl GrammarFormatProfile {
117    /// Creates a grammar format profile around a [`LanguageProfile`].
118    #[must_use]
119    pub const fn new(format: &'static str, profile: LanguageProfile) -> Self {
120        Self {
121            format,
122            profile,
123            equivalent_constructs: BTreeSet::new(),
124        }
125    }
126
127    /// Returns this profile with a lossless construct.
128    #[must_use]
129    pub fn with_lossless_construct(mut self, construct: impl Into<String>) -> Self {
130        self.profile = self.profile.with_concept(construct);
131        self
132    }
133
134    /// Returns this profile with an equivalent construct.
135    #[must_use]
136    pub fn with_equivalent_construct(mut self, construct: impl Into<String>) -> Self {
137        let construct = construct.into();
138        self.profile = self.profile.with_concept(construct.clone());
139        self.equivalent_constructs.insert(construct);
140        self
141    }
142
143    /// Returns this profile with a documented lossy fallback for a construct.
144    #[must_use]
145    pub fn with_lossy_fallback(
146        mut self,
147        construct: impl Into<String>,
148        fallback: impl Into<String>,
149    ) -> Self {
150        self.profile = self.profile.with_concept_fallback(construct, fallback);
151        self
152    }
153
154    /// Canonical format label for this profile.
155    #[must_use]
156    pub const fn format(&self) -> &'static str {
157        self.format
158    }
159
160    /// Underlying language profile.
161    #[must_use]
162    pub const fn language_profile(&self) -> &LanguageProfile {
163        &self.profile
164    }
165
166    /// Lossy fallback table keyed by construct id.
167    #[must_use]
168    pub const fn fallbacks(&self) -> &BTreeMap<String, String> {
169        self.profile.fallbacks()
170    }
171
172    /// Constructs represented through equivalent spelling or normalization.
173    #[must_use]
174    pub const fn equivalent_constructs(&self) -> &BTreeSet<String> {
175        &self.equivalent_constructs
176    }
177
178    /// Whether this format represents a construct without a lossy fallback.
179    #[must_use]
180    pub fn supports_construct(&self, construct: &str) -> bool {
181        self.profile
182            .supports_concept(current_grammar_construct(construct))
183    }
184
185    /// Documented lossy fallback for a construct this format cannot represent
186    /// natively.
187    #[must_use]
188    pub fn construct_fallback(&self, construct: &str) -> Option<&str> {
189        self.profile
190            .concept_fallback(current_grammar_construct(construct))
191    }
192
193    /// Fidelity level for a construct, or `None` when the construct is outside
194    /// this profile's vocabulary.
195    #[must_use]
196    pub fn construct_fidelity(&self, construct: &str) -> Option<GrammarFidelityLevel> {
197        if self.supports_construct(construct) {
198            if self
199                .equivalent_constructs
200                .contains(current_grammar_construct(construct))
201            {
202                Some(GrammarFidelityLevel::Equivalent)
203            } else {
204                Some(GrammarFidelityLevel::Lossless)
205            }
206        } else if self.construct_fallback(construct).is_some() {
207            Some(GrammarFidelityLevel::Lossy)
208        } else {
209            None
210        }
211    }
212}
213
214/// Returns the capability profile for a grammar `format`, or `None` when the
215/// format has no fidelity row yet.
216#[must_use]
217pub fn grammar_format_profile(format: &str) -> Option<GrammarFormatProfile> {
218    let canonical = canonical_grammar_format(format)?;
219    Some(match canonical {
220        "bnf" => bnf_profile(),
221        _ => unreachable!("canonical_grammar_format only yields known formats"),
222    })
223}
224
225/// Canonicalizes a grammar format label to one of [`GRAMMAR_FORMATS`].
226#[must_use]
227pub fn canonical_grammar_format(format: &str) -> Option<&'static str> {
228    match format.to_ascii_lowercase().as_str() {
229        "bnf" | "classic-bnf" | "classic bnf" | "backus-naur form" | "backus naur form" => {
230            Some("bnf")
231        }
232        _ => None,
233    }
234}
235
236fn base_profile(format: &'static str, name: &'static str) -> GrammarFormatProfile {
237    GrammarFormatProfile::new(
238        format,
239        LanguageProfile::new(name, format)
240            .with_link_type(LinkType::Grammar)
241            .with_link_type(LinkType::Concept)
242            .with_link_type(LinkType::Token),
243    )
244}
245
246fn with_lossless_constructs<'a>(
247    mut profile: GrammarFormatProfile,
248    constructs: impl IntoIterator<Item = &'a str>,
249) -> GrammarFormatProfile {
250    for construct in constructs {
251        profile = profile.with_lossless_construct(construct);
252    }
253    profile
254}
255
256fn bnf_profile() -> GrammarFormatProfile {
257    with_lossless_constructs(
258        base_profile("bnf", "Backus-Naur Form"),
259        [
260            "empty-expression",
261            "sequence",
262            "unordered-choice",
263            "terminal",
264            "nonterminal",
265        ],
266    )
267    .with_lossy_fallback(
268        "ordered-choice",
269        "emitted as an unordered BNF alternative; priority semantics are not preserved",
270    )
271    .with_lossy_fallback(
272        "optional-expression",
273        "emitted through a synthetic helper production with an empty alternative",
274    )
275    .with_lossy_fallback(
276        "zero-or-more-repetition",
277        "emitted through a recursive synthetic helper production with an empty alternative",
278    )
279    .with_lossy_fallback(
280        "one-or-more-repetition",
281        "emitted through a recursive synthetic helper production plus one required item",
282    )
283    .with_lossy_fallback(
284        "counted-repetition",
285        "emitted as required occurrences plus optional or recursive synthetic helper productions",
286    )
287    .with_lossy_fallback(
288        "character-range",
289        "expanded to a synthetic helper production enumerating each character when the range is bounded",
290    )
291    .with_lossy_fallback(
292        "character-class",
293        "expanded to a synthetic helper production for finite non-negated classes; unsupported classes are rejected",
294    )
295    .with_lossy_fallback(
296        "any-character",
297        "unsupported by BNF emission and rejected instead of silently broadening the language",
298    )
299    .with_lossy_fallback(
300        "case-insensitive-terminal",
301        "emitted as a case-sensitive literal and reported as lossy",
302    )
303    .with_lossy_fallback(
304        "positive-predicate",
305        "unsupported by BNF emission and rejected because lookahead has no BNF equivalent",
306    )
307    .with_lossy_fallback(
308        "negative-predicate",
309        "unsupported by BNF emission and rejected because lookahead has no BNF equivalent",
310    )
311    .with_lossy_fallback(
312        "capture",
313        "emitted as the captured expression while dropping the capture label",
314    )
315    .with_lossy_fallback(
316        "atomic-rule",
317        "emitted as a normal BNF production; rule-kind metadata is dropped",
318    )
319    .with_lossy_fallback(
320        "silent-rule",
321        "emitted as a normal BNF production; rule-kind metadata is dropped",
322    )
323    .with_lossy_fallback(
324        "token-rule",
325        "emitted as a normal BNF production; rule-kind metadata is dropped",
326    )
327}