Skip to main content

meta_language/
api_styles.rs

1//! API-style parity registry and adapters.
2//!
3//! The registry makes issue #62's "same operations through every applicable
4//! style" requirement executable: each operation has an explicit cell for each
5//! supported style, and applicable cells point at a runnable fixture.
6
7use std::collections::BTreeSet;
8use std::error::Error;
9use std::fmt;
10
11use links_notation::{parse_lino_to_links, LiNo};
12
13use crate::configuration::ParseConfiguration;
14use crate::link_network::{LinkId, LinkMetadata, LinkNetwork, LinkType};
15use crate::query::LinkQuery;
16use crate::snapshots::NetworkSnapshot;
17use crate::source::ByteRange;
18use crate::substitution::{SubstitutionReport, SubstitutionRule};
19use crate::transform::{ReplacementReport, ReplacementRule};
20use crate::translation_rules::TranslationRuleSet;
21use crate::verification::VerificationReport;
22
23mod fixtures;
24
25pub use fixtures::run_api_style_fixture;
26
27/// Operation families that must remain reachable through the supported API
28/// styles.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ApiOperation {
31    /// Parse source text into a links network.
32    Parse,
33    /// Query links in a network.
34    Query,
35    /// Transform query-selected links or source ranges.
36    Transform,
37    /// Apply structural substitutions.
38    Substitute,
39    /// Serialize and load network data.
40    Serialize,
41    /// Capture immutable network versions.
42    Snapshot,
43    /// Reconstruct through translation rules.
44    Translate,
45    /// Verify parse and structural diagnostics.
46    Verify,
47}
48
49impl ApiOperation {
50    /// Stable registry label.
51    #[must_use]
52    pub const fn name(self) -> &'static str {
53        match self {
54            Self::Parse => "parse",
55            Self::Query => "query",
56            Self::Transform => "transform",
57            Self::Substitute => "substitute",
58            Self::Serialize => "serialize",
59            Self::Snapshot => "snapshot",
60            Self::Translate => "translate",
61            Self::Verify => "verify",
62        }
63    }
64}
65
66/// API surface styles tracked for parity.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum ApiStyle {
69    /// Direct Rust methods on core types.
70    DirectMethod,
71    /// Fluent chain over the same executor methods.
72    FluentChain,
73    /// link-cli-compatible substitution text.
74    LinkCliSubstitutionText,
75    /// S-expression or `LiNo` text surfaces.
76    SexpressionOrLinoText,
77}
78
79impl ApiStyle {
80    /// All styles that must appear in every operation row.
81    pub const ALL: &'static [Self] = &[
82        Self::DirectMethod,
83        Self::FluentChain,
84        Self::LinkCliSubstitutionText,
85        Self::SexpressionOrLinoText,
86    ];
87}
88
89/// Coverage state for one operation/style cell.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum ApiStyleFixture {
92    /// The style applies and is covered by a runnable fixture.
93    Executable(&'static str),
94    /// The style does not apply to this operation, with an explicit reason.
95    NotApplicable(&'static str),
96}
97
98/// One operation/style registry cell.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct ApiStyleCell {
101    style: ApiStyle,
102    fixture: ApiStyleFixture,
103}
104
105impl ApiStyleCell {
106    /// Creates a cell covered by an executable fixture.
107    #[must_use]
108    pub const fn executable(style: ApiStyle, fixture_name: &'static str) -> Self {
109        Self {
110            style,
111            fixture: ApiStyleFixture::Executable(fixture_name),
112        }
113    }
114
115    /// Creates an explicit N/A cell.
116    #[must_use]
117    pub const fn not_applicable(style: ApiStyle, reason: &'static str) -> Self {
118        Self {
119            style,
120            fixture: ApiStyleFixture::NotApplicable(reason),
121        }
122    }
123
124    /// Style represented by this cell.
125    #[must_use]
126    pub const fn style(self) -> ApiStyle {
127        self.style
128    }
129
130    /// Fixture coverage for this cell.
131    #[must_use]
132    pub const fn fixture(self) -> ApiStyleFixture {
133        self.fixture
134    }
135}
136
137/// One operation row in the API-style parity matrix.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct ApiOperationEntry {
140    operation: ApiOperation,
141    styles: &'static [ApiStyleCell],
142}
143
144impl ApiOperationEntry {
145    /// Operation represented by this row.
146    #[must_use]
147    pub const fn operation(self) -> ApiOperation {
148        self.operation
149    }
150
151    /// Stable operation label.
152    #[must_use]
153    pub const fn name(self) -> &'static str {
154        self.operation.name()
155    }
156
157    /// Style cells in this row.
158    #[must_use]
159    pub const fn styles(self) -> &'static [ApiStyleCell] {
160        self.styles
161    }
162
163    /// Returns the cell for `style`, when present.
164    #[must_use]
165    pub fn style(self, style: ApiStyle) -> Option<ApiStyleCell> {
166        self.styles
167            .iter()
168            .copied()
169            .find(|cell| cell.style() == style)
170    }
171}
172
173const LINK_CLI_PARSE_NA: &str =
174    "link-cli substitution text mutates existing links; it is not a source parser";
175const LINK_CLI_SERIALIZE_NA: &str =
176    "link-cli substitution text is an operation command, not a network serializer";
177const LINK_CLI_SNAPSHOT_NA: &str =
178    "link-cli substitution text has no immutable versioning primitive";
179const LINK_CLI_TRANSLATE_NA: &str =
180    "link-cli substitution text rewrites links and does not select target languages";
181const LINK_CLI_VERIFY_NA: &str =
182    "link-cli substitution text has no diagnostic verification primitive";
183const TEXT_SNAPSHOT_NA: &str =
184    "snapshots carry runtime version provenance and are not a standalone text DSL";
185const TEXT_VERIFY_NA: &str =
186    "verification consumes an existing network rather than a standalone text DSL";
187
188const PARSE_STYLES: &[ApiStyleCell] = &[
189    ApiStyleCell::executable(ApiStyle::DirectMethod, "parse.direct"),
190    ApiStyleCell::executable(ApiStyle::FluentChain, "parse.fluent"),
191    ApiStyleCell::not_applicable(ApiStyle::LinkCliSubstitutionText, LINK_CLI_PARSE_NA),
192    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "parse.lino_text"),
193];
194
195const QUERY_STYLES: &[ApiStyleCell] = &[
196    ApiStyleCell::executable(ApiStyle::DirectMethod, "query.direct"),
197    ApiStyleCell::executable(ApiStyle::FluentChain, "query.fluent"),
198    ApiStyleCell::executable(
199        ApiStyle::LinkCliSubstitutionText,
200        "query.link_cli_read_identity",
201    ),
202    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "query.sexpression"),
203];
204
205const TRANSFORM_STYLES: &[ApiStyleCell] = &[
206    ApiStyleCell::executable(ApiStyle::DirectMethod, "transform.direct"),
207    ApiStyleCell::executable(ApiStyle::FluentChain, "transform.fluent"),
208    ApiStyleCell::executable(
209        ApiStyle::LinkCliSubstitutionText,
210        "transform.link_cli_update",
211    ),
212    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "transform.sexpression"),
213];
214
215const SUBSTITUTE_STYLES: &[ApiStyleCell] = &[
216    ApiStyleCell::executable(ApiStyle::DirectMethod, "substitute.direct"),
217    ApiStyleCell::executable(ApiStyle::FluentChain, "substitute.fluent"),
218    ApiStyleCell::executable(
219        ApiStyle::LinkCliSubstitutionText,
220        "substitute.link_cli_crud",
221    ),
222    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "substitute.lino_text"),
223];
224
225const SERIALIZE_STYLES: &[ApiStyleCell] = &[
226    ApiStyleCell::executable(ApiStyle::DirectMethod, "serialize.direct"),
227    ApiStyleCell::executable(ApiStyle::FluentChain, "serialize.fluent"),
228    ApiStyleCell::not_applicable(ApiStyle::LinkCliSubstitutionText, LINK_CLI_SERIALIZE_NA),
229    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "serialize.lino_roundtrip"),
230];
231
232const SNAPSHOT_STYLES: &[ApiStyleCell] = &[
233    ApiStyleCell::executable(ApiStyle::DirectMethod, "snapshot.direct"),
234    ApiStyleCell::executable(ApiStyle::FluentChain, "snapshot.fluent"),
235    ApiStyleCell::not_applicable(ApiStyle::LinkCliSubstitutionText, LINK_CLI_SNAPSHOT_NA),
236    ApiStyleCell::not_applicable(ApiStyle::SexpressionOrLinoText, TEXT_SNAPSHOT_NA),
237];
238
239const TRANSLATE_STYLES: &[ApiStyleCell] = &[
240    ApiStyleCell::executable(ApiStyle::DirectMethod, "translate.direct"),
241    ApiStyleCell::executable(ApiStyle::FluentChain, "translate.fluent"),
242    ApiStyleCell::not_applicable(ApiStyle::LinkCliSubstitutionText, LINK_CLI_TRANSLATE_NA),
243    ApiStyleCell::executable(ApiStyle::SexpressionOrLinoText, "translate.lino_rules"),
244];
245
246const VERIFY_STYLES: &[ApiStyleCell] = &[
247    ApiStyleCell::executable(ApiStyle::DirectMethod, "verify.direct"),
248    ApiStyleCell::executable(ApiStyle::FluentChain, "verify.fluent"),
249    ApiStyleCell::not_applicable(ApiStyle::LinkCliSubstitutionText, LINK_CLI_VERIFY_NA),
250    ApiStyleCell::not_applicable(ApiStyle::SexpressionOrLinoText, TEXT_VERIFY_NA),
251];
252
253/// Operation/style parity matrix.
254pub const API_OPERATIONS: &[ApiOperationEntry] = &[
255    ApiOperationEntry {
256        operation: ApiOperation::Parse,
257        styles: PARSE_STYLES,
258    },
259    ApiOperationEntry {
260        operation: ApiOperation::Query,
261        styles: QUERY_STYLES,
262    },
263    ApiOperationEntry {
264        operation: ApiOperation::Transform,
265        styles: TRANSFORM_STYLES,
266    },
267    ApiOperationEntry {
268        operation: ApiOperation::Substitute,
269        styles: SUBSTITUTE_STYLES,
270    },
271    ApiOperationEntry {
272        operation: ApiOperation::Serialize,
273        styles: SERIALIZE_STYLES,
274    },
275    ApiOperationEntry {
276        operation: ApiOperation::Snapshot,
277        styles: SNAPSHOT_STYLES,
278    },
279    ApiOperationEntry {
280        operation: ApiOperation::Translate,
281        styles: TRANSLATE_STYLES,
282    },
283    ApiOperationEntry {
284        operation: ApiOperation::Verify,
285        styles: VERIFY_STYLES,
286    },
287];
288
289/// Fluent adapter over [`LinkNetwork`] operations.
290pub trait FluentNetworkApi: Sized {
291    /// Converts `self` into the underlying network executor.
292    fn into_network(self) -> LinkNetwork;
293
294    /// Starts a fluent chain over the same network executor.
295    #[must_use]
296    fn into_fluent(self) -> FluentPipeline {
297        FluentPipeline::new(self.into_network())
298    }
299}
300
301impl FluentNetworkApi for LinkNetwork {
302    fn into_network(self) -> LinkNetwork {
303        self
304    }
305}
306
307/// Fluent parse/query/transform/reconstruct pipeline.
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub struct FluentPipeline {
310    network: LinkNetwork,
311    matches: Vec<crate::query::QueryMatch>,
312    last_report: ReplacementReport,
313}
314
315impl FluentPipeline {
316    /// Starts a fluent chain from an existing network.
317    #[must_use]
318    pub fn new(network: LinkNetwork) -> Self {
319        Self {
320            network,
321            matches: Vec::new(),
322            last_report: ReplacementReport::default(),
323        }
324    }
325
326    /// Parses source text and starts a fluent chain.
327    #[must_use]
328    pub fn parse(text: &str, language: &str, configuration: ParseConfiguration) -> Self {
329        Self::new(LinkNetwork::parse(text, language, configuration))
330    }
331
332    /// Selects links with a structural query.
333    #[must_use]
334    pub fn find(mut self, query: impl Into<LinkQuery>) -> Self {
335        let query = query.into();
336        self.matches = self.network.find(&query);
337        self
338    }
339
340    /// Replaces links selected by the most recent [`Self::find`] call.
341    #[must_use]
342    pub fn replace(mut self, rule: impl Into<ReplacementRule>) -> Self {
343        let rule = rule.into();
344        self.last_report = self.network.replace(&self.matches, &rule);
345        self
346    }
347
348    /// Applies a structural substitution rule.
349    #[must_use]
350    pub fn substitute(mut self, rule: impl Into<SubstitutionRule>) -> Self {
351        let rule = rule.into();
352        self.last_report = report_from_substitution(self.network.apply_substitution(&rule));
353        self
354    }
355
356    /// Applies a link-cli-style substitution command.
357    ///
358    /// # Errors
359    ///
360    /// Returns [`LinkCliSubstitutionError`] when the command text is malformed.
361    pub fn link_cli_substitution_text(
362        mut self,
363        source: &str,
364    ) -> Result<Self, LinkCliSubstitutionError> {
365        self.last_report =
366            report_from_substitution(self.network.apply_link_cli_substitution_text(source)?);
367        Ok(self)
368    }
369
370    /// Reconstructs source text from the current network.
371    #[must_use]
372    pub fn reconstruct(self) -> String {
373        self.network.reconstruct_text()
374    }
375
376    /// Serializes the current network to canonical `LiNo` text.
377    #[must_use]
378    pub fn serialize(&self) -> String {
379        self.network.to_lino()
380    }
381
382    /// Captures an immutable snapshot of the current network.
383    #[must_use]
384    pub fn snapshot(&self, version: u64, provenance: impl Into<String>) -> NetworkSnapshot {
385        self.network.snapshot(version, provenance)
386    }
387
388    /// Reconstructs text for a target language.
389    #[must_use]
390    pub fn translate(
391        &self,
392        target_language: &str,
393        configuration: ParseConfiguration,
394        rules: &TranslationRuleSet,
395    ) -> String {
396        self.network
397            .reconstruct_text_as_with_rules(target_language, configuration, rules)
398    }
399
400    /// Verifies the current network.
401    #[must_use]
402    pub fn verify(&self, region: Option<ByteRange>) -> VerificationReport {
403        self.network.verify_full_match(region)
404    }
405
406    /// Last transform or substitution report.
407    #[must_use]
408    pub const fn last_report(&self) -> &ReplacementReport {
409        &self.last_report
410    }
411
412    /// Borrows the current network.
413    #[must_use]
414    pub const fn network(&self) -> &LinkNetwork {
415        &self.network
416    }
417
418    /// Ends the fluent chain and returns the current network.
419    #[must_use]
420    pub fn into_network(self) -> LinkNetwork {
421        self.network
422    }
423}
424
425impl LinkNetwork {
426    /// Parses source text and starts a fluent chain.
427    #[must_use]
428    pub fn parse_fluent(
429        text: &str,
430        language: &str,
431        configuration: ParseConfiguration,
432    ) -> FluentPipeline {
433        FluentPipeline::parse(text, language, configuration)
434    }
435
436    /// Applies a link-cli-style substitution command.
437    ///
438    /// # Errors
439    ///
440    /// Returns [`LinkCliSubstitutionError`] when the command text is malformed.
441    pub fn apply_link_cli_substitution_text(
442        &mut self,
443        source: &str,
444    ) -> Result<SubstitutionReport, LinkCliSubstitutionError> {
445        LinkCliSubstitution::parse(source)?.apply(self)
446    }
447}
448
449/// Operation kind represented by a link-cli-style substitution command.
450#[derive(Clone, Copy, Debug, PartialEq, Eq)]
451pub enum LinkCliSubstitutionKind {
452    /// Empty match side creates replacement links.
453    Create,
454    /// Identical match and replacement sides read/echo matches without changing references.
455    ReadIdentity,
456    /// Non-empty match and replacement sides update matched links.
457    Update,
458    /// Empty replacement side deletes matched links.
459    Delete,
460}
461
462/// Parsed link-cli-style substitution command.
463#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct LinkCliSubstitution {
465    pattern: Vec<LinkCliLinkPattern>,
466    replacement: Vec<LinkCliLinkPattern>,
467}
468
469impl LinkCliSubstitution {
470    /// Parses `(match) (substitution)` `LiNo` text.
471    ///
472    /// # Errors
473    ///
474    /// Returns [`LinkCliSubstitutionError`] when the text is not a two-sided
475    /// link-cli substitution command.
476    pub fn parse(source: &str) -> Result<Self, LinkCliSubstitutionError> {
477        let statements = parse_lino_to_links(source)
478            .map_err(|error| LinkCliSubstitutionError::new(error.to_string()))?;
479        let (pattern, replacement) = match statements.as_slice() {
480            [pattern, replacement] => (pattern, replacement),
481            [LiNo::Link { id: None, values }] if values.len() == 2 => (&values[0], &values[1]),
482            _ => {
483                return Err(LinkCliSubstitutionError::new(
484                    "link-cli substitution requires exactly two LiNo lists",
485                ))
486            }
487        };
488
489        Ok(Self {
490            pattern: parse_substitution_side(pattern, "match")?,
491            replacement: parse_substitution_side(replacement, "replacement")?,
492        })
493    }
494
495    /// Builds a link id from a numeric link-cli reference.
496    #[must_use]
497    pub const fn link_id(value: u64) -> LinkId {
498        LinkId::from_u64(value)
499    }
500
501    /// Classifies this command.
502    #[must_use]
503    pub fn kind(&self) -> LinkCliSubstitutionKind {
504        match (self.pattern.is_empty(), self.replacement.is_empty()) {
505            (true, false) => LinkCliSubstitutionKind::Create,
506            (false, true) => LinkCliSubstitutionKind::Delete,
507            (false, false) if self.pattern == self.replacement => {
508                LinkCliSubstitutionKind::ReadIdentity
509            }
510            _ => LinkCliSubstitutionKind::Update,
511        }
512    }
513
514    /// Applies this command to `network`.
515    ///
516    /// # Errors
517    ///
518    /// Currently reserved for malformed parsed states; valid parsed commands
519    /// apply infallibly.
520    pub fn apply(
521        &self,
522        network: &mut LinkNetwork,
523    ) -> Result<SubstitutionReport, LinkCliSubstitutionError> {
524        let report = match self.kind() {
525            LinkCliSubstitutionKind::Create => self.apply_create(network),
526            LinkCliSubstitutionKind::Delete => self.apply_delete(network),
527            LinkCliSubstitutionKind::ReadIdentity | LinkCliSubstitutionKind::Update => {
528                self.apply_update(network)
529            }
530        };
531        Ok(report)
532    }
533
534    fn apply_create(&self, network: &mut LinkNetwork) -> SubstitutionReport {
535        let mut report = SubstitutionReport::default();
536        for replacement in &self.replacement {
537            let created = network.insert_dynamic_link(
538                &replacement.references,
539                LinkMetadata::new().with_link_type(LinkType::Relation),
540            );
541            report.created.push(created);
542        }
543        report
544    }
545
546    fn apply_delete(&self, network: &mut LinkNetwork) -> SubstitutionReport {
547        let mut report = SubstitutionReport::default();
548        for id in self.matching_ids(network) {
549            if network.links.remove(&id).is_some() {
550                report.deleted.push(id);
551            }
552        }
553        report
554    }
555
556    fn apply_update(&self, network: &mut LinkNetwork) -> SubstitutionReport {
557        let mut report = SubstitutionReport::default();
558        for (pattern, replacement) in self.pattern.iter().zip(&self.replacement) {
559            for id in matching_ids_for_pattern(network, pattern) {
560                if replacement
561                    .id
562                    .is_some_and(|replacement_id| replacement_id != id)
563                {
564                    continue;
565                }
566                if network.set_references(id, &replacement.references) {
567                    report.updated.push(id);
568                }
569            }
570        }
571        report
572    }
573
574    fn matching_ids(&self, network: &LinkNetwork) -> Vec<LinkId> {
575        let mut seen = BTreeSet::new();
576        let mut ids = Vec::new();
577        for pattern in &self.pattern {
578            for id in matching_ids_for_pattern(network, pattern) {
579                if seen.insert(id) {
580                    ids.push(id);
581                }
582            }
583        }
584        ids
585    }
586}
587
588/// Error returned while parsing or applying link-cli-style substitution text.
589#[derive(Clone, Debug, PartialEq, Eq)]
590pub struct LinkCliSubstitutionError {
591    message: String,
592}
593
594impl LinkCliSubstitutionError {
595    fn new(message: impl Into<String>) -> Self {
596        Self {
597            message: message.into(),
598        }
599    }
600}
601
602impl fmt::Display for LinkCliSubstitutionError {
603    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
604        formatter.write_str(&self.message)
605    }
606}
607
608impl Error for LinkCliSubstitutionError {}
609
610#[derive(Clone, Debug, PartialEq, Eq)]
611struct LinkCliLinkPattern {
612    id: Option<LinkId>,
613    references: Vec<LinkId>,
614}
615
616fn parse_substitution_side(
617    node: &LiNo<String>,
618    label: &str,
619) -> Result<Vec<LinkCliLinkPattern>, LinkCliSubstitutionError> {
620    let LiNo::Link { id: None, values } = node else {
621        return Err(LinkCliSubstitutionError::new(format!(
622            "{label} side must be an anonymous LiNo list"
623        )));
624    };
625
626    values
627        .iter()
628        .map(|value| parse_link_pattern(value, label))
629        .collect()
630}
631
632fn parse_link_pattern(
633    node: &LiNo<String>,
634    label: &str,
635) -> Result<LinkCliLinkPattern, LinkCliSubstitutionError> {
636    let LiNo::Link { id, values } = node else {
637        return Err(LinkCliSubstitutionError::new(format!(
638            "{label} side entries must be LiNo links"
639        )));
640    };
641
642    let id = id.as_deref().map(parse_link_id).transpose()?;
643    let references = values
644        .iter()
645        .map(parse_link_reference)
646        .collect::<Result<Vec<_>, _>>()?;
647    Ok(LinkCliLinkPattern { id, references })
648}
649
650fn parse_link_reference(node: &LiNo<String>) -> Result<LinkId, LinkCliSubstitutionError> {
651    let LiNo::Ref(reference) = node else {
652        return Err(LinkCliSubstitutionError::new(
653            "link-cli substitution references must be numeric refs",
654        ));
655    };
656    parse_link_id(reference)
657}
658
659fn parse_link_id(value: &str) -> Result<LinkId, LinkCliSubstitutionError> {
660    value
661        .parse::<u64>()
662        .map(LinkId::from_u64)
663        .map_err(|_| LinkCliSubstitutionError::new(format!("invalid link id `{value}`")))
664}
665
666fn matching_ids_for_pattern(network: &LinkNetwork, pattern: &LinkCliLinkPattern) -> Vec<LinkId> {
667    if let Some(id) = pattern.id {
668        return network
669            .link(id)
670            .filter(|link| link.references() == pattern.references.as_slice())
671            .map(|link| vec![link.id()])
672            .unwrap_or_default();
673    }
674
675    network
676        .links()
677        .filter(|link| link.references() == pattern.references.as_slice())
678        .map(crate::link_network::Link::id)
679        .collect()
680}
681
682const fn report_from_substitution(substitution: SubstitutionReport) -> ReplacementReport {
683    ReplacementReport::from_substitution(substitution)
684}