1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum ApiOperation {
31 Parse,
33 Query,
35 Transform,
37 Substitute,
39 Serialize,
41 Snapshot,
43 Translate,
45 Verify,
47}
48
49impl ApiOperation {
50 #[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum ApiStyle {
69 DirectMethod,
71 FluentChain,
73 LinkCliSubstitutionText,
75 SexpressionOrLinoText,
77}
78
79impl ApiStyle {
80 pub const ALL: &'static [Self] = &[
82 Self::DirectMethod,
83 Self::FluentChain,
84 Self::LinkCliSubstitutionText,
85 Self::SexpressionOrLinoText,
86 ];
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum ApiStyleFixture {
92 Executable(&'static str),
94 NotApplicable(&'static str),
96}
97
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct ApiStyleCell {
101 style: ApiStyle,
102 fixture: ApiStyleFixture,
103}
104
105impl ApiStyleCell {
106 #[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 #[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 #[must_use]
126 pub const fn style(self) -> ApiStyle {
127 self.style
128 }
129
130 #[must_use]
132 pub const fn fixture(self) -> ApiStyleFixture {
133 self.fixture
134 }
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub struct ApiOperationEntry {
140 operation: ApiOperation,
141 styles: &'static [ApiStyleCell],
142}
143
144impl ApiOperationEntry {
145 #[must_use]
147 pub const fn operation(self) -> ApiOperation {
148 self.operation
149 }
150
151 #[must_use]
153 pub const fn name(self) -> &'static str {
154 self.operation.name()
155 }
156
157 #[must_use]
159 pub const fn styles(self) -> &'static [ApiStyleCell] {
160 self.styles
161 }
162
163 #[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
253pub 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
289pub trait FluentNetworkApi: Sized {
291 fn into_network(self) -> LinkNetwork;
293
294 #[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#[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 #[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 #[must_use]
328 pub fn parse(text: &str, language: &str, configuration: ParseConfiguration) -> Self {
329 Self::new(LinkNetwork::parse(text, language, configuration))
330 }
331
332 #[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 #[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 #[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 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 #[must_use]
372 pub fn reconstruct(self) -> String {
373 self.network.reconstruct_text()
374 }
375
376 #[must_use]
378 pub fn serialize(&self) -> String {
379 self.network.to_lino()
380 }
381
382 #[must_use]
384 pub fn snapshot(&self, version: u64, provenance: impl Into<String>) -> NetworkSnapshot {
385 self.network.snapshot(version, provenance)
386 }
387
388 #[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 #[must_use]
402 pub fn verify(&self, region: Option<ByteRange>) -> VerificationReport {
403 self.network.verify_full_match(region)
404 }
405
406 #[must_use]
408 pub const fn last_report(&self) -> &ReplacementReport {
409 &self.last_report
410 }
411
412 #[must_use]
414 pub const fn network(&self) -> &LinkNetwork {
415 &self.network
416 }
417
418 #[must_use]
420 pub fn into_network(self) -> LinkNetwork {
421 self.network
422 }
423}
424
425impl LinkNetwork {
426 #[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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
451pub enum LinkCliSubstitutionKind {
452 Create,
454 ReadIdentity,
456 Update,
458 Delete,
460}
461
462#[derive(Clone, Debug, PartialEq, Eq)]
464pub struct LinkCliSubstitution {
465 pattern: Vec<LinkCliLinkPattern>,
466 replacement: Vec<LinkCliLinkPattern>,
467}
468
469impl LinkCliSubstitution {
470 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 #[must_use]
497 pub const fn link_id(value: u64) -> LinkId {
498 LinkId::from_u64(value)
499 }
500
501 #[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 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#[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}