Skip to main content

meta_language/
language_profile.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::error::Error;
3use std::fmt;
4
5use crate::{LinkId, LinkMetadata, LinkNetwork, LinkType, ParseConfiguration, TranslationRuleSet};
6
7const PROFILE_TERM: &str = "language-profile";
8const PROFILE_LINK_TYPE_TERM: &str = "language-profile:link-type";
9const PROFILE_CONCEPT_TERM: &str = "language-profile:concept";
10const PROFILE_TRANSLATION_RULE_TERM: &str = "language-profile:translation-rule";
11const PROFILE_DIAGNOSTIC_TERM: &str = "language-profile:unsupported-feature";
12
13/// Per-language capability profile for restricting transforms to supported features.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct LanguageProfile {
16    name: String,
17    language: String,
18    link_types: BTreeSet<LinkType>,
19    concepts: BTreeSet<String>,
20    translation_rules: BTreeSet<String>,
21    /// Concepts the target cannot represent natively, each mapped to the
22    /// documented lossy fallback applied when the concept is encountered.
23    fallbacks: BTreeMap<String, String>,
24}
25
26impl LanguageProfile {
27    /// Creates an empty profile for a target language.
28    #[must_use]
29    pub fn new(name: impl Into<String>, language: impl Into<String>) -> Self {
30        Self {
31            name: name.into(),
32            language: language.into(),
33            link_types: BTreeSet::new(),
34            concepts: BTreeSet::new(),
35            translation_rules: BTreeSet::new(),
36            fallbacks: BTreeMap::new(),
37        }
38    }
39
40    /// Built-in JavaScript same-language profile.
41    #[must_use]
42    pub fn javascript() -> Self {
43        let mut profile = Self::new("JavaScript", "JavaScript");
44        for link_type in [
45            LinkType::Link,
46            LinkType::Reference,
47            LinkType::Relation,
48            LinkType::Language,
49            LinkType::Grammar,
50            LinkType::Type,
51            LinkType::Concept,
52            LinkType::Syntax,
53            LinkType::Field,
54            LinkType::Trivia,
55            LinkType::Token,
56            LinkType::Document,
57            LinkType::Semantic,
58            LinkType::Region,
59            LinkType::Object,
60        ] {
61            profile = profile.with_link_type(link_type);
62        }
63        profile
64    }
65
66    /// Looks up a built-in profile by name.
67    #[must_use]
68    pub fn builtin(name: &str) -> Option<Self> {
69        match name.to_ascii_lowercase().as_str() {
70            "javascript" | "js" => Some(Self::javascript()),
71            _ => None,
72        }
73    }
74
75    /// Computes a profile domain from a translation rule set.
76    ///
77    /// Rule query link-type filters become supported link types, query term
78    /// filters become supported concept/feature terms, and every rule name is
79    /// recorded as a supported translation rule.
80    #[must_use]
81    pub fn from_rule_set(
82        name: impl Into<String>,
83        language: impl Into<String>,
84        rule_set: &TranslationRuleSet,
85    ) -> Self {
86        let mut profile = Self::new(name, language);
87        for rule in rule_set.rules() {
88            profile = profile.with_translation_rule(rule.name());
89            if let Some(link_type) = rule.query().link_type_filter() {
90                profile = profile.with_link_type(link_type);
91            }
92            if let Some(term) = rule.query().term_filter() {
93                profile = profile.with_concept(term);
94            }
95        }
96        profile
97    }
98
99    /// Profile name.
100    #[must_use]
101    pub fn name(&self) -> &str {
102        &self.name
103    }
104
105    /// Target language this profile constrains.
106    #[must_use]
107    pub fn language(&self) -> &str {
108        &self.language
109    }
110
111    /// Supported link types.
112    #[must_use]
113    pub const fn link_types(&self) -> &BTreeSet<LinkType> {
114        &self.link_types
115    }
116
117    /// Supported concept or feature terms.
118    #[must_use]
119    pub const fn concepts(&self) -> &BTreeSet<String> {
120        &self.concepts
121    }
122
123    /// Supported translation rule names.
124    #[must_use]
125    pub const fn translation_rules(&self) -> &BTreeSet<String> {
126        &self.translation_rules
127    }
128
129    /// Unsupported concepts mapped to their documented lossy fallback.
130    ///
131    /// Each entry records a concept the target cannot represent natively
132    /// together with the fallback applied when the concept is encountered (for
133    /// example a heading rendered as a plain paragraph in `txt`). This is the
134    /// per-target fidelity report required by issue #86.
135    #[must_use]
136    pub const fn fallbacks(&self) -> &BTreeMap<String, String> {
137        &self.fallbacks
138    }
139
140    /// Returns a copy with a supported link type.
141    #[must_use]
142    pub fn with_link_type(mut self, link_type: LinkType) -> Self {
143        self.link_types.insert(link_type);
144        self
145    }
146
147    /// Returns a copy with a supported concept or feature term.
148    #[must_use]
149    pub fn with_concept(mut self, concept: impl Into<String>) -> Self {
150        self.concepts.insert(concept.into());
151        self
152    }
153
154    /// Returns a copy with a supported translation rule name.
155    #[must_use]
156    pub fn with_translation_rule(mut self, rule: impl Into<String>) -> Self {
157        self.translation_rules.insert(rule.into());
158        self
159    }
160
161    /// Returns a copy that records an unsupported concept and its lossy fallback.
162    ///
163    /// Use this to declare features the target cannot represent natively, so the
164    /// profile can report them as documented lossy fallbacks rather than silent
165    /// data loss.
166    #[must_use]
167    pub fn with_concept_fallback(
168        mut self,
169        concept: impl Into<String>,
170        fallback: impl Into<String>,
171    ) -> Self {
172        self.fallbacks.insert(concept.into(), fallback.into());
173        self
174    }
175
176    /// The documented lossy fallback for a concept the target cannot represent,
177    /// or `None` when the concept is natively supported or unknown.
178    #[must_use]
179    pub fn concept_fallback(&self, concept: &str) -> Option<&str> {
180        self.fallbacks.get(concept).map(String::as_str)
181    }
182
183    /// Whether this profile supports a link type.
184    #[must_use]
185    pub fn supports_link_type(&self, link_type: LinkType) -> bool {
186        self.link_types.contains(&link_type)
187    }
188
189    /// Whether this profile supports a concept or feature term.
190    #[must_use]
191    pub fn supports_concept(&self, concept: &str) -> bool {
192        self.concepts.contains(concept)
193    }
194
195    /// Whether this profile supports a translation rule name.
196    #[must_use]
197    pub fn supports_translation_rule(&self, rule: &str) -> bool {
198        self.translation_rules.contains(rule)
199    }
200
201    /// Declares this profile as queryable links inside a network.
202    pub fn declare_in(&self, network: &mut LinkNetwork) -> LanguageProfileLinks {
203        let profile = self.profile_link(network).unwrap_or_else(|| {
204            network.insert_link(
205                [],
206                LinkMetadata::new()
207                    .with_link_type(LinkType::Semantic)
208                    .with_named(true)
209                    .with_term(PROFILE_TERM)
210                    .with_language(&self.language)
211                    .with_definition(&self.name),
212            )
213        });
214        let mut capabilities = Vec::new();
215
216        for link_type in &self.link_types {
217            capabilities.push(self.ensure_capability_link(
218                network,
219                profile,
220                PROFILE_LINK_TYPE_TERM,
221                &link_type.to_string(),
222            ));
223        }
224        for concept in &self.concepts {
225            capabilities.push(self.ensure_capability_link(
226                network,
227                profile,
228                PROFILE_CONCEPT_TERM,
229                concept,
230            ));
231        }
232        for rule in &self.translation_rules {
233            capabilities.push(self.ensure_capability_link(
234                network,
235                profile,
236                PROFILE_TRANSLATION_RULE_TERM,
237                rule,
238            ));
239        }
240
241        LanguageProfileLinks {
242            profile,
243            capabilities,
244        }
245    }
246
247    /// Validates that all typed links in a network stay inside this profile.
248    ///
249    /// # Errors
250    ///
251    /// Returns [`LanguageProfileViolation`] for the first unsupported link
252    /// type found in identifier order.
253    pub fn validate_network(&self, network: &LinkNetwork) -> Result<(), LanguageProfileViolation> {
254        for link in network.links() {
255            if let Some(link_type) = link.metadata().link_type() {
256                if !self.supports_link_type(link_type) {
257                    return Err(LanguageProfileViolation::new(
258                        format!("link type `{link_type}`"),
259                        format!(
260                            "Profile `{}` for `{}` does not support link type `{link_type}`.",
261                            self.name, self.language
262                        ),
263                    ));
264                }
265            }
266
267            if self.concepts.is_empty()
268                || !matches!(
269                    link.metadata().link_type(),
270                    Some(LinkType::Concept | LinkType::Semantic)
271                )
272            {
273                continue;
274            }
275            let Some(term) = link.metadata().term() else {
276                continue;
277            };
278            if is_profile_control_term(term) || self.supports_concept(term) {
279                continue;
280            }
281            return Err(LanguageProfileViolation::new(
282                format!("concept `{term}`"),
283                format!(
284                    "Profile `{}` for `{}` does not support concept `{term}`.",
285                    self.name, self.language
286                ),
287            ));
288        }
289        Ok(())
290    }
291
292    pub(crate) fn validate_transform_result(
293        &self,
294        network: &LinkNetwork,
295    ) -> Result<(), LanguageProfileViolation> {
296        self.validate_network(network)?;
297
298        let source = network.reconstruct_text();
299        if source.is_empty() {
300            return Ok(());
301        }
302
303        let parsed = LinkNetwork::parse(&source, &self.language, ParseConfiguration::default());
304        let report = parsed.verify_full_match(None);
305        if report.issues().is_empty() {
306            Ok(())
307        } else {
308            Err(LanguageProfileViolation::new(
309                format!("{} syntax", self.language),
310                format!(
311                    "Profile `{}` for `{}` rejects source text that is not valid {}.",
312                    self.name, self.language, self.language
313                ),
314            ))
315        }
316    }
317
318    pub(crate) fn insert_diagnostic(
319        &self,
320        network: &mut LinkNetwork,
321        violation: &LanguageProfileViolation,
322        subject: Option<LinkId>,
323    ) -> LinkId {
324        let profile = self.declare_in(network).profile();
325        let metadata = LinkMetadata::new()
326            .with_link_type(LinkType::Semantic)
327            .with_named(true)
328            .with_term(PROFILE_DIAGNOSTIC_TERM)
329            .with_language(&self.language)
330            .with_definition(violation.to_string());
331
332        match subject {
333            Some(subject) => network.insert_link([profile, subject], metadata),
334            None => network.insert_link([profile], metadata),
335        }
336    }
337
338    fn profile_link(&self, network: &LinkNetwork) -> Option<LinkId> {
339        network
340            .links()
341            .find(|link| {
342                link.metadata().link_type() == Some(LinkType::Semantic)
343                    && link.metadata().term() == Some(PROFILE_TERM)
344                    && link.metadata().language() == Some(self.language())
345                    && link.metadata().definition() == Some(self.name())
346            })
347            .map(crate::Link::id)
348    }
349
350    fn ensure_capability_link(
351        &self,
352        network: &mut LinkNetwork,
353        profile: LinkId,
354        term: &str,
355        definition: &str,
356    ) -> LinkId {
357        if let Some(existing) = network
358            .links()
359            .find(|link| {
360                link.references() == [profile]
361                    && link.metadata().link_type() == Some(LinkType::Semantic)
362                    && link.metadata().term() == Some(term)
363                    && link.metadata().language() == Some(self.language())
364                    && link.metadata().definition() == Some(definition)
365            })
366            .map(crate::Link::id)
367        {
368            return existing;
369        }
370
371        network.insert_link(
372            [profile],
373            LinkMetadata::new()
374                .with_link_type(LinkType::Semantic)
375                .with_named(true)
376                .with_term(term)
377                .with_language(&self.language)
378                .with_definition(definition),
379        )
380    }
381}
382
383/// Links inserted when a language profile is declared in a network.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub struct LanguageProfileLinks {
386    profile: LinkId,
387    capabilities: Vec<LinkId>,
388}
389
390impl LanguageProfileLinks {
391    /// Root profile link.
392    #[must_use]
393    pub const fn profile(&self) -> LinkId {
394        self.profile
395    }
396
397    /// Capability child links.
398    #[must_use]
399    pub fn capabilities(&self) -> &[LinkId] {
400        &self.capabilities
401    }
402}
403
404/// A profile validation failure that can be recorded as a diagnostic link.
405#[derive(Clone, Debug, PartialEq, Eq)]
406pub struct LanguageProfileViolation {
407    feature: String,
408    message: String,
409}
410
411impl LanguageProfileViolation {
412    fn new(feature: impl Into<String>, message: impl Into<String>) -> Self {
413        Self {
414            feature: feature.into(),
415            message: message.into(),
416        }
417    }
418
419    /// Unsupported feature that caused the violation.
420    #[must_use]
421    pub fn feature(&self) -> &str {
422        &self.feature
423    }
424}
425
426impl fmt::Display for LanguageProfileViolation {
427    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
428        write!(
429            formatter,
430            "{} Unsupported feature: {}.",
431            self.message, self.feature
432        )
433    }
434}
435
436impl Error for LanguageProfileViolation {}
437
438fn is_profile_control_term(term: &str) -> bool {
439    term.starts_with("language-profile")
440        || term.starts_with("translation-rule:")
441        || term == "translation-rule"
442        || term == "translation-rule-set"
443}