Skip to main content

meta_language/
language_profile.rs

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