Skip to main content

meta_language/
transform.rs

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