Skip to main content

meta_language/
transform.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use crate::decorators::{DecoratorLevel, DecoratorSet, decorator_record, record_field};
5use crate::language_profile::LanguageProfile;
6use crate::link_network::{Link, LinkId, LinkNetwork, LinkType};
7use crate::query::{LinkQuery, QueryCaptures, QueryMatch, QueryPredicate, QueryPredicateHost};
8use crate::source::{ByteRange, SourceSpan};
9use crate::substitution::{SubstitutionReport, SubstitutionRule, VariableSubstitutionRule};
10
11/// Replacement rule used by the query-and-transform surface.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ReplacementRule {
14    kind: ReplacementKind,
15}
16
17impl ReplacementRule {
18    /// Replaces the source text covered by links captured under `capture_name`.
19    ///
20    /// Captured syntax links are rewritten by changing the token links inside
21    /// the captured range, so all tokens outside the captured links keep their
22    /// original text and order.
23    #[must_use]
24    pub fn captured_text(capture_name: impl Into<String>, replacement: impl Into<String>) -> Self {
25        Self {
26            kind: ReplacementKind::CapturedText {
27                capture_name: normalize_capture_name(capture_name),
28                replacement: replacement.into(),
29            },
30        }
31    }
32
33    /// Applies an exact-reference substitution via [`LinkNetwork::apply_substitution`].
34    #[must_use]
35    pub const fn substitution(rule: SubstitutionRule) -> Self {
36        Self {
37            kind: ReplacementKind::Substitution(rule),
38        }
39    }
40
41    /// Applies a variable substitution via [`LinkNetwork::apply_variable_substitution`].
42    #[must_use]
43    pub const fn variable_substitution(rule: VariableSubstitutionRule) -> Self {
44        Self {
45            kind: ReplacementKind::VariableSubstitution(rule),
46        }
47    }
48
49    /// Replaces captured source text with a quasiquote template.
50    ///
51    /// Placeholders use `{{capture_name}}` and are resolved from the same query
52    /// match before each replacement is applied.
53    #[must_use]
54    pub fn quasiquote(capture_name: impl Into<String>, template: QuasiquoteTemplate) -> Self {
55        Self {
56            kind: ReplacementKind::Quasiquote {
57                capture_name: normalize_capture_name(capture_name),
58                template,
59            },
60        }
61    }
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65enum ReplacementKind {
66    CapturedText {
67        capture_name: String,
68        replacement: String,
69    },
70    Quasiquote {
71        capture_name: String,
72        template: QuasiquoteTemplate,
73    },
74    Substitution(SubstitutionRule),
75    VariableSubstitution(VariableSubstitutionRule),
76}
77
78/// Result of replacing query-selected links.
79#[derive(Clone, Debug, Default, PartialEq, Eq)]
80pub struct ReplacementReport {
81    text_replacements: Vec<TextReplacement>,
82    template_errors: Vec<QuasiquoteError>,
83    substitution: SubstitutionReport,
84    profile_diagnostics: Vec<LinkId>,
85}
86
87impl ReplacementReport {
88    pub(crate) const fn from_substitution(substitution: SubstitutionReport) -> Self {
89        Self {
90            text_replacements: Vec::new(),
91            template_errors: Vec::new(),
92            substitution,
93            profile_diagnostics: Vec::new(),
94        }
95    }
96
97    /// Source-text replacements made for captured links.
98    #[must_use]
99    pub fn text_replacements(&self) -> &[TextReplacement] {
100        &self.text_replacements
101    }
102
103    /// Template rendering errors that prevented replacements.
104    #[must_use]
105    pub fn template_errors(&self) -> &[QuasiquoteError] {
106        &self.template_errors
107    }
108
109    /// Structural substitution result, when the rule delegates to substitution.
110    #[must_use]
111    pub const fn substitution(&self) -> &SubstitutionReport {
112        &self.substitution
113    }
114
115    /// Diagnostic links created when a language profile rejected a replacement.
116    #[must_use]
117    pub fn profile_diagnostics(&self) -> &[LinkId] {
118        &self.profile_diagnostics
119    }
120
121    /// Returns whether the replacement made no text or structural changes.
122    #[must_use]
123    pub fn is_empty(&self) -> bool {
124        self.text_replacements.is_empty()
125            && self.template_errors.is_empty()
126            && self.substitution.created().is_empty()
127            && self.substitution.updated().is_empty()
128            && self.substitution.deleted().is_empty()
129            && self.profile_diagnostics.is_empty()
130    }
131}
132
133/// One source-text replacement applied to captured token links.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct TextReplacement {
136    capture_name: String,
137    link_id: LinkId,
138    token_ids: Vec<LinkId>,
139    span: Option<SourceSpan>,
140    old_text: String,
141    new_text: String,
142}
143
144impl TextReplacement {
145    fn new(
146        capture_name: &str,
147        link_id: LinkId,
148        token_ids: Vec<LinkId>,
149        span: Option<SourceSpan>,
150        old_text: String,
151        new_text: &str,
152    ) -> Self {
153        Self {
154            capture_name: capture_name.to_string(),
155            link_id,
156            token_ids,
157            span,
158            old_text,
159            new_text: new_text.to_string(),
160        }
161    }
162
163    /// Capture name that produced this replacement.
164    #[must_use]
165    pub fn capture_name(&self) -> &str {
166        &self.capture_name
167    }
168
169    /// Captured link whose source text was replaced.
170    #[must_use]
171    pub const fn link_id(&self) -> LinkId {
172        self.link_id
173    }
174
175    /// Token links edited to perform the replacement.
176    #[must_use]
177    pub fn token_ids(&self) -> &[LinkId] {
178        &self.token_ids
179    }
180
181    /// Source span covered by the edited tokens.
182    #[must_use]
183    pub const fn span(&self) -> Option<SourceSpan> {
184        self.span
185    }
186
187    /// Source text reconstructed from the captured tokens before replacement.
188    #[must_use]
189    pub fn old_text(&self) -> &str {
190        &self.old_text
191    }
192
193    /// Replacement text written into the captured range.
194    #[must_use]
195    pub fn new_text(&self) -> &str {
196        &self.new_text
197    }
198}
199
200/// Built-in predicate host for text predicates over query captures.
201#[derive(Clone, Copy, Debug, Default)]
202pub struct SourceTextPredicateHost;
203
204impl QueryPredicateHost for SourceTextPredicateHost {
205    fn evaluate(
206        &self,
207        predicate: &QueryPredicate,
208        captures: &QueryCaptures,
209        network: &LinkNetwork,
210    ) -> bool {
211        let Some((capture_name, literal)) = capture_literal_arguments(predicate) else {
212            return false;
213        };
214        let Some(captured_text) = captured_text(network, captures.first(capture_name)) else {
215            return false;
216        };
217
218        match predicate.name() {
219            "eq?" => captured_text == literal,
220            "not-eq?" => captured_text != literal,
221            _ => false,
222        }
223    }
224}
225
226/// Quasiquote replacement template with `{{capture}}` placeholders.
227#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct QuasiquoteTemplate {
229    parts: Vec<TemplatePart>,
230}
231
232impl QuasiquoteTemplate {
233    /// Parses a template source string.
234    pub fn parse(source: impl Into<String>) -> Result<Self, QuasiquoteError> {
235        let source = source.into();
236        let mut parts = Vec::new();
237        let mut rest = source.as_str();
238        while let Some(start) = rest.find("{{") {
239            if start > 0 {
240                parts.push(TemplatePart::Literal(rest[..start].to_string()));
241            }
242            let after_open = &rest[start + 2..];
243            let Some(end) = after_open.find("}}") else {
244                return Err(QuasiquoteError::Parse(
245                    "unterminated quasiquote placeholder".to_string(),
246                ));
247            };
248            let name = normalize_capture_name(after_open[..end].trim());
249            if name.is_empty() {
250                return Err(QuasiquoteError::Parse(
251                    "quasiquote placeholder is empty".to_string(),
252                ));
253            }
254            parts.push(TemplatePart::Placeholder(name));
255            rest = &after_open[end + 2..];
256        }
257        if !rest.is_empty() {
258            parts.push(TemplatePart::Literal(rest.to_string()));
259        }
260        if parts.is_empty() {
261            parts.push(TemplatePart::Literal(source));
262        }
263        Ok(Self { parts })
264    }
265
266    fn render(
267        &self,
268        network: &LinkNetwork,
269        query_match: &QueryMatch,
270        old_text: &str,
271    ) -> Result<String, QuasiquoteError> {
272        let mut values = BTreeMap::<String, String>::new();
273        for part in &self.parts {
274            if let TemplatePart::Placeholder(name) = part {
275                if values.contains_key(name) {
276                    continue;
277                }
278                let Some(text) = captured_text(network, query_match.captures().first(name)) else {
279                    return Err(QuasiquoteError::MissingPlaceholder(name.clone()));
280                };
281                values.insert(name.clone(), text);
282            }
283        }
284
285        let mut rendered = String::new();
286        for part in &self.parts {
287            match part {
288                TemplatePart::Literal(literal) => rendered.push_str(literal),
289                TemplatePart::Placeholder(name) => {
290                    let Some(value) = values.get(name) else {
291                        return Err(QuasiquoteError::MissingPlaceholder(name.clone()));
292                    };
293                    rendered.push_str(value);
294                }
295            }
296        }
297        Ok(preserve_parentheses(old_text, rendered))
298    }
299}
300
301#[derive(Clone, Debug, PartialEq, Eq)]
302enum TemplatePart {
303    Literal(String),
304    Placeholder(String),
305}
306
307/// Error returned while parsing or rendering a quasiquote template.
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum QuasiquoteError {
310    /// Template source is malformed.
311    Parse(String),
312    /// Template references a capture that is not bound by the query match.
313    MissingPlaceholder(String),
314}
315
316impl fmt::Display for QuasiquoteError {
317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
318        match self {
319            Self::Parse(message) => formatter.write_str(message),
320            Self::MissingPlaceholder(name) => {
321                write!(formatter, "quasiquote placeholder `{name}` is not captured")
322            }
323        }
324    }
325}
326
327impl std::error::Error for QuasiquoteError {}
328
329impl LinkNetwork {
330    /// Finds query matches using the transform surface's source-text predicates.
331    ///
332    /// This delegates structural matching to [`LinkQuery`]'s S-expression
333    /// matcher. Built-in predicates such as `#eq? @capture "text"` compare the
334    /// text reconstructed from captured token links.
335    #[must_use]
336    pub fn find(&self, query: &LinkQuery) -> Vec<QueryMatch> {
337        self.query_matches_with(query, &SourceTextPredicateHost)
338    }
339
340    /// Applies a replacement rule to links selected by [`LinkNetwork::find`].
341    pub fn replace(&mut self, matches: &[QueryMatch], rule: &ReplacementRule) -> ReplacementReport {
342        self.replace_decorated(matches, rule, &DecoratorSet::empty())
343    }
344
345    /// [`LinkNetwork::replace`] with the `transformation` decorators of
346    /// `decorators`, which see each captured-text replacement as
347    /// `{ capture, old, new }`: setting `new` changes the text written, and
348    /// `drop` leaves the match as it is.
349    pub fn replace_decorated(
350        &mut self,
351        matches: &[QueryMatch],
352        rule: &ReplacementRule,
353        decorators: &DecoratorSet,
354    ) -> ReplacementReport {
355        match &rule.kind {
356            ReplacementKind::CapturedText {
357                capture_name,
358                replacement,
359            } => ReplacementReport {
360                text_replacements: self.replace_captured_text(
361                    matches,
362                    capture_name,
363                    replacement,
364                    decorators,
365                ),
366                template_errors: Vec::new(),
367                substitution: SubstitutionReport::default(),
368                profile_diagnostics: Vec::new(),
369            },
370            ReplacementKind::Quasiquote {
371                capture_name,
372                template,
373            } => {
374                let (text_replacements, template_errors) =
375                    self.replace_captured_quasiquote(matches, capture_name, template);
376                ReplacementReport {
377                    text_replacements,
378                    template_errors,
379                    substitution: SubstitutionReport::default(),
380                    profile_diagnostics: Vec::new(),
381                }
382            }
383            ReplacementKind::Substitution(rule) => {
384                if matches.is_empty() {
385                    ReplacementReport::default()
386                } else {
387                    ReplacementReport {
388                        text_replacements: Vec::new(),
389                        template_errors: Vec::new(),
390                        substitution: self.apply_substitution(rule),
391                        profile_diagnostics: Vec::new(),
392                    }
393                }
394            }
395            ReplacementKind::VariableSubstitution(rule) => {
396                if matches.is_empty() {
397                    ReplacementReport::default()
398                } else {
399                    ReplacementReport {
400                        text_replacements: Vec::new(),
401                        template_errors: Vec::new(),
402                        substitution: self.apply_variable_substitution(rule),
403                        profile_diagnostics: Vec::new(),
404                    }
405                }
406            }
407        }
408    }
409
410    /// Applies a replacement only when the result stays inside a language profile.
411    ///
412    /// The replacement is first evaluated on a cloned network. If the candidate
413    /// network validates against the profile, it is committed to `self`. If the
414    /// profile rejects it, `self` keeps its original source text and receives a
415    /// queryable `language-profile:unsupported-feature` diagnostic link.
416    pub fn replace_with_profile(
417        &mut self,
418        matches: &[QueryMatch],
419        rule: &ReplacementRule,
420        profile: &LanguageProfile,
421    ) -> ReplacementReport {
422        let mut candidate = self.clone();
423        let report = candidate.replace(matches, rule);
424        if report.is_empty() {
425            return report;
426        }
427
428        match profile.validate_transform_result(&candidate) {
429            Ok(()) => {
430                *self = candidate;
431                report
432            }
433            Err(violation) => {
434                let diagnostic = profile.insert_diagnostic(
435                    self,
436                    &violation,
437                    matches.first().map(QueryMatch::link_id),
438                );
439                ReplacementReport {
440                    profile_diagnostics: vec![diagnostic],
441                    ..ReplacementReport::default()
442                }
443            }
444        }
445    }
446
447    fn replace_captured_text(
448        &mut self,
449        matches: &[QueryMatch],
450        capture_name: &str,
451        replacement: &str,
452        decorators: &DecoratorSet,
453    ) -> Vec<TextReplacement> {
454        let mut touched_tokens = BTreeSet::new();
455        let mut replacements = Vec::new();
456
457        for query_match in matches {
458            for capture in query_match
459                .captures()
460                .iter()
461                .filter(|capture| capture.name() == capture_name)
462            {
463                let token_ids = source_token_ids(self, capture.link_id());
464                if token_ids.is_empty()
465                    || token_ids
466                        .iter()
467                        .any(|token_id| touched_tokens.contains(token_id))
468                {
469                    continue;
470                }
471
472                let old_text = text_for_tokens(self, &token_ids);
473                let record = decorator_record([
474                    ("capture", capture_name),
475                    ("old", old_text.as_str()),
476                    ("new", replacement),
477                ]);
478                let Some(decorated) = decorators.decorate(DecoratorLevel::Transformation, &record)
479                else {
480                    continue;
481                };
482                let replacement = record_field(&decorated, "new");
483                if old_text == replacement {
484                    continue;
485                }
486
487                let span = span_for_tokens(self, &token_ids);
488                let first_token = token_ids[0];
489                if !self.set_term(first_token, replacement.to_string()) {
490                    continue;
491                }
492                for token_id in token_ids.iter().skip(1) {
493                    let _ = self.set_term(*token_id, String::new());
494                }
495
496                touched_tokens.extend(token_ids.iter().copied());
497                replacements.push(TextReplacement::new(
498                    capture_name,
499                    capture.link_id(),
500                    token_ids,
501                    span,
502                    old_text,
503                    replacement,
504                ));
505            }
506        }
507
508        replacements
509    }
510
511    fn replace_captured_quasiquote(
512        &mut self,
513        matches: &[QueryMatch],
514        capture_name: &str,
515        template: &QuasiquoteTemplate,
516    ) -> (Vec<TextReplacement>, Vec<QuasiquoteError>) {
517        let mut touched_tokens = BTreeSet::new();
518        let mut replacements = Vec::new();
519        let mut errors = Vec::new();
520
521        for query_match in matches {
522            for capture in query_match
523                .captures()
524                .iter()
525                .filter(|capture| capture.name() == capture_name)
526            {
527                let token_ids = source_token_ids(self, capture.link_id());
528                if token_ids.is_empty()
529                    || token_ids
530                        .iter()
531                        .any(|token_id| touched_tokens.contains(token_id))
532                {
533                    continue;
534                }
535
536                let old_text = text_for_tokens(self, &token_ids);
537                let replacement = match template.render(self, query_match, &old_text) {
538                    Ok(replacement) => replacement,
539                    Err(error) => {
540                        errors.push(error);
541                        continue;
542                    }
543                };
544                if old_text == replacement {
545                    continue;
546                }
547
548                let span = span_for_tokens(self, &token_ids);
549                let first_token = token_ids[0];
550                if !self.set_term(first_token, replacement.clone()) {
551                    continue;
552                }
553                for token_id in token_ids.iter().skip(1) {
554                    let _ = self.set_term(*token_id, String::new());
555                }
556
557                touched_tokens.extend(token_ids.iter().copied());
558                replacements.push(TextReplacement::new(
559                    capture_name,
560                    capture.link_id(),
561                    token_ids,
562                    span,
563                    old_text,
564                    &replacement,
565                ));
566            }
567        }
568
569        (replacements, errors)
570    }
571}
572
573fn normalize_capture_name(name: impl Into<String>) -> String {
574    name.into().trim_start_matches('@').to_string()
575}
576
577fn preserve_parentheses(old_text: &str, rendered: String) -> String {
578    let trimmed_old = old_text.trim();
579    let trimmed_rendered = rendered.trim();
580    if trimmed_old.starts_with('(')
581        && trimmed_old.ends_with(')')
582        && !(trimmed_rendered.starts_with('(') && trimmed_rendered.ends_with(')'))
583    {
584        format!("({rendered})")
585    } else {
586        rendered
587    }
588}
589
590fn capture_literal_arguments(predicate: &QueryPredicate) -> Option<(&str, &str)> {
591    let [capture_argument, literal_argument] = predicate.arguments() else {
592        return None;
593    };
594    Some((
595        capture_argument.capture_name()?,
596        literal_argument.literal()?,
597    ))
598}
599
600fn captured_text(network: &LinkNetwork, link_id: Option<LinkId>) -> Option<String> {
601    let link_id = link_id?;
602    let token_ids = source_token_ids(network, link_id);
603    if token_ids.is_empty() {
604        network
605            .link(link_id)
606            .and_then(|link| link.metadata().term())
607            .map(str::to_string)
608    } else {
609        Some(text_for_tokens(network, &token_ids))
610    }
611}
612
613fn source_token_ids(network: &LinkNetwork, link_id: LinkId) -> Vec<LinkId> {
614    let mut visited = BTreeSet::new();
615    let mut token_ids = Vec::new();
616    collect_source_tokens(network, link_id, &mut visited, &mut token_ids);
617    token_ids.sort_by_key(|token_id| token_sort_key(network, *token_id));
618    token_ids.dedup();
619    token_ids
620}
621
622fn collect_source_tokens(
623    network: &LinkNetwork,
624    link_id: LinkId,
625    visited: &mut BTreeSet<LinkId>,
626    token_ids: &mut Vec<LinkId>,
627) {
628    if !visited.insert(link_id) {
629        return;
630    }
631    let Some(link) = network.link(link_id) else {
632        return;
633    };
634
635    match link.metadata().link_type() {
636        Some(LinkType::Token) => {
637            if !link.metadata().flags().is_missing() {
638                token_ids.push(link_id);
639            }
640            return;
641        }
642        Some(LinkType::Field | LinkType::Trivia) => return,
643        _ => {}
644    }
645
646    let children = network
647        .links()
648        .filter(|candidate| candidate.references().first().copied() == Some(link_id))
649        .map(Link::id)
650        .collect::<Vec<_>>();
651    for child in children {
652        collect_source_tokens(network, child, visited, token_ids);
653    }
654}
655
656fn token_sort_key(network: &LinkNetwork, token_id: LinkId) -> (usize, u64) {
657    let start = network
658        .link(token_id)
659        .and_then(|link| link.metadata().span())
660        .map_or(usize::MAX, |span| span.byte_range().start());
661    (start, token_id.as_u64())
662}
663
664fn text_for_tokens(network: &LinkNetwork, token_ids: &[LinkId]) -> String {
665    token_ids
666        .iter()
667        .filter_map(|token_id| network.link(*token_id))
668        .filter_map(|link| link.metadata().term())
669        .collect()
670}
671
672fn span_for_tokens(network: &LinkNetwork, token_ids: &[LinkId]) -> Option<SourceSpan> {
673    let spans = token_ids
674        .iter()
675        .filter_map(|token_id| network.link(*token_id))
676        .filter_map(|link| link.metadata().span())
677        .collect::<Vec<_>>();
678    let first = spans.first()?;
679    let last = spans.last()?;
680    Some(SourceSpan::new(
681        ByteRange::new(first.byte_range().start(), last.byte_range().end()),
682        first.start_point(),
683        last.end_point(),
684    ))
685}