Skip to main content

meta_language/
self_translation.rs

1//! Translates meta-language's own modules between JavaScript, TypeScript and
2//! Rust through meta-language links.
3//!
4//! The source is parsed losslessly with its default grammar; a translation
5//! into the same language writes the links back byte for byte, and a
6//! translation into the other language translates each top-level item the
7//! portable core can express and carries every other item verbatim in a
8//! marked comment. Both kinds of item keep their source as provenance, so
9//! translating an unedited translation back restores the source byte for
10//! byte.
11//!
12//! Mirrors `js/src/self-translation.js`.
13
14use std::fmt;
15use std::fmt::Write as _;
16use std::sync::LazyLock;
17
18use regex::Regex;
19use sha2::{Digest, Sha256};
20
21use crate::decorators::DecoratorSet;
22use crate::grammar::decorate_emitted;
23use crate::translation::check::check_program;
24use crate::translation::diagnostics::TranslationError;
25use crate::translation::emit_common::Emitted;
26use crate::translation::{
27    emit_javascript::emit_javascript, emit_rust::emit_rust, javascript::parse_javascript,
28    rust::parse_rust,
29};
30use crate::{LinkNetwork, ParseConfiguration};
31
32/// The languages self-translation reads and writes.
33pub const SELF_TRANSLATION_LANGUAGES: [&str; 3] = ["JavaScript", "TypeScript", "Rust"];
34
35const HEADER: &str = "// meta-language:self-translation:v1 ";
36const CARRIED: &str = "// meta-language:carried ";
37const TRANSLATED: &str = "// meta-language:translated ";
38const SOURCE_LINE: &str = "// |";
39const PRELUDE_BEGIN: &str = "// meta-language:prelude begin";
40const PRELUDE_END: &str = "// meta-language:prelude end";
41// Portable-core Rust keeps the source's parentheses and names.
42const RUST_ALLOW: &str = "#![allow(unused, unreachable_patterns, non_snake_case, non_camel_case_types, invalid_nan_comparisons)]";
43const COMMENTS: [&str; 3] = ["comment", "line_comment", "block_comment"];
44
45static RUST_EXPORT: LazyLock<Regex> =
46    LazyLock::new(|| Regex::new(r"(?m)^pub(\([^)]*\))?\s").expect("a valid pattern"));
47static JAVASCRIPT_EXPORT: LazyLock<Regex> =
48    LazyLock::new(|| Regex::new(r"(?m)^export\s").expect("a valid pattern"));
49static RUST_MAIN: LazyLock<Regex> =
50    LazyLock::new(|| Regex::new(r"\bfn\s+main\s*\(").expect("a valid pattern"));
51static LINE_BREAK: LazyLock<Regex> =
52    LazyLock::new(|| Regex::new(r"\r?\n").expect("a valid pattern"));
53
54/// An unknown language or a source the links do not reproduce.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct SelfTranslationError {
57    /// What went wrong.
58    pub message: String,
59}
60
61impl fmt::Display for SelfTranslationError {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter.write_str(&self.message)
64    }
65}
66
67impl std::error::Error for SelfTranslationError {}
68
69/// A top-level item of the source and what self-translation did with it.
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SelfTranslationItem {
72    /// The item's syntax node kind.
73    pub term: String,
74    /// The item's first byte in the source.
75    pub start: usize,
76    /// The byte after the item in the source.
77    pub end: usize,
78    /// `kept` (same language), `translated`, `restored` (provenance gave back
79    /// its source), `comment`, `provenance` (a self-translation header or
80    /// prelude) or `carried`.
81    pub status: &'static str,
82    /// Why a carried item was carried.
83    pub reason: Option<String>,
84}
85
86/// A translated module with the status of each of its top-level items.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct SelfTranslation {
89    /// The canonical source language.
90    pub source_language: &'static str,
91    /// The canonical target language.
92    pub target_language: &'static str,
93    /// The translated module.
94    pub code: String,
95    /// The source's top-level items in order.
96    pub items: Vec<SelfTranslationItem>,
97}
98
99/// The self-translation language `language` names, or `None`.
100#[must_use]
101pub fn self_translation_language(language: &str) -> Option<&'static str> {
102    match language.to_lowercase().as_str() {
103        "javascript" | "js" | "mjs" => Some("JavaScript"),
104        "typescript" | "ts" => Some("TypeScript"),
105        "rust" | "rs" => Some("Rust"),
106        _ => None,
107    }
108}
109
110fn required(language: &str) -> Result<&'static str, SelfTranslationError> {
111    self_translation_language(language).ok_or_else(|| SelfTranslationError {
112        message: format!(
113            "self-translation reads and writes {}, not {language}",
114            SELF_TRANSLATION_LANGUAGES.join(", ")
115        ),
116    })
117}
118
119// The family a language's code is written in: TypeScript items are read and
120// written as JavaScript.
121fn family(language: &str) -> &'static str {
122    if language == "Rust" {
123        "Rust"
124    } else {
125        "JavaScript"
126    }
127}
128
129fn sha256(text: &str) -> String {
130    Sha256::digest(text.as_bytes())
131        .iter()
132        .fold(String::new(), |mut hex, byte| {
133            let _ = write!(hex, "{byte:02x}");
134            hex
135        })
136}
137
138#[derive(Clone, Debug)]
139struct Item<'a> {
140    term: String,
141    start: usize,
142    end: usize,
143    comment: bool,
144    text: &'a str,
145    after: &'a str,
146}
147
148/// Translates `source` from `source_language` to `target_language`, each one
149/// of [`SELF_TRANSLATION_LANGUAGES`] or an alias of one.
150///
151/// # Errors
152///
153/// A [`SelfTranslationError`] for an unknown language or a source its links
154/// do not reproduce.
155pub fn self_translate(
156    source: &str,
157    source_language: &str,
158    target_language: &str,
159) -> Result<SelfTranslation, SelfTranslationError> {
160    self_translate_decorated(
161        source,
162        source_language,
163        target_language,
164        &DecoratorSet::default(),
165    )
166}
167
168/// [`self_translate`] with the emitter `decorators` applied.
169///
170/// They decorate the code each translated item writes before its provenance
171/// marker is hashed, so the decorated translation still translates back to
172/// `source`.
173///
174/// # Errors
175///
176/// As [`self_translate`].
177pub fn self_translate_decorated(
178    source: &str,
179    source_language: &str,
180    target_language: &str,
181    decorators: &DecoratorSet,
182) -> Result<SelfTranslation, SelfTranslationError> {
183    let from = required(source_language)?;
184    let to = required(target_language)?;
185    let reconstructed =
186        LinkNetwork::parse(source, from, ParseConfiguration::default()).reconstruct_text();
187    if reconstructed != source {
188        return Err(SelfTranslationError {
189            message: format!("the {from} links of the source do not reproduce it"),
190        });
191    }
192    let items = top_level_items(source, from);
193    if family(from) == family(to) {
194        let items = items
195            .iter()
196            .map(|item| SelfTranslationItem {
197                term: item.term.clone(),
198                start: item.start,
199                end: item.end,
200                status: "kept",
201                reason: None,
202            })
203            .collect();
204        return Ok(SelfTranslation {
205            source_language: from,
206            target_language: to,
207            code: reconstructed,
208            items,
209        });
210    }
211    let mut blocks: Vec<String> = Vec::new();
212    let mut preludes: Vec<String> = Vec::new();
213    let mut recorded = Vec::new();
214    let mut gap = String::new();
215    for group in group_items(&items, source) {
216        let out = translate_group(&group, from, to, decorators);
217        for prelude in out.preludes {
218            if !preludes.contains(&prelude) {
219                preludes.push(prelude);
220            }
221        }
222        for item in &group.items {
223            recorded.push(SelfTranslationItem {
224                term: item.term.clone(),
225                start: item.start,
226                end: item.end,
227                status: out.status,
228                reason: out.reason.clone(),
229            });
230        }
231        if let Some(code) = out.code {
232            let separator = if blocks.is_empty() {
233                String::new()
234            } else {
235                breaks(&gap)
236            };
237            blocks.push(format!("{separator}{code}"));
238            group.after.clone_into(&mut gap);
239        } else if gap.is_empty() {
240            // A dropped group keeps the layout before it.
241            group.after.clone_into(&mut gap);
242        }
243    }
244    let body = format!("{}\n", blocks.concat());
245    // An unedited translation back gives the source its header describes.
246    let header = items
247        .iter()
248        .find(|item| item.comment && item.text.starts_with(HEADER));
249    if let Some(header) = header
250        && header.text.contains(&format!(" source={to} "))
251        && header.text.contains(&format!(" sha256={} ", sha256(&body)))
252    {
253        return Ok(SelfTranslation {
254            source_language: from,
255            target_language: to,
256            code: body,
257            items: recorded,
258        });
259    }
260    if family(to) == "Rust" && recorded.iter().any(|item| item.status == "translated") {
261        preludes.insert(0, RUST_ALLOW.to_owned());
262    }
263    let mut lines = vec![
264        format!(
265            "{HEADER}source={from} target={to} sha256={} bytes={}",
266            sha256(source),
267            source.len()
268        ),
269        String::new(),
270    ];
271    if !preludes.is_empty() {
272        lines.push(PRELUDE_BEGIN.to_owned());
273        for (index, prelude) in preludes.into_iter().enumerate() {
274            if index > 0 {
275                lines.push(String::new());
276            }
277            lines.push(prelude);
278        }
279        lines.push(PRELUDE_END.to_owned());
280        lines.push(String::new());
281    }
282    Ok(SelfTranslation {
283        source_language: from,
284        target_language: to,
285        code: format!("{}\n{body}", lines.join("\n")),
286        items: recorded,
287    })
288}
289
290// The layout between two emitted blocks: its line breaks, at least one.
291fn breaks(gap: &str) -> String {
292    "\n".repeat(gap.matches('\n').count().max(1))
293}
294
295/// The top-level items of `text`, with their text and the layout after each.
296fn top_level_items<'a>(text: &'a str, language: &str) -> Vec<Item<'a>> {
297    let bytes = text.as_bytes();
298    let nodes: Vec<(String, usize, usize)> =
299        crate::tree_sitter_adapter::top_level_nodes(text, language)
300            .unwrap_or_default()
301            .into_iter()
302            .filter(|(term, _, _)| term != "whitespace")
303            .map(|(term, start, mut end)| {
304                // A Rust line comment ends with its line break, which is layout here.
305                if COMMENTS.contains(&term.as_str()) && end > 0 && bytes[end - 1] == b'\n' {
306                    end -= if end > 1 && bytes[end - 2] == b'\r' {
307                        2
308                    } else {
309                        1
310                    };
311                }
312                (term, start, end)
313            })
314            .collect();
315    nodes
316        .iter()
317        .enumerate()
318        .map(|(index, (term, start, end))| Item {
319            term: term.clone(),
320            start: *start,
321            end: *end,
322            comment: COMMENTS.contains(&term.as_str()),
323            text: &text[*start..*end],
324            after: &text[*end..nodes.get(index + 1).map_or(text.len(), |next| next.1)],
325        })
326        .collect()
327}
328
329fn line_break(layout: &str) -> bool {
330    layout == "\n" || layout == "\r\n"
331}
332
333fn source_lines(items: &[Item<'_>]) -> Vec<String> {
334    items
335        .iter()
336        .map(|item| {
337            let rest = &item.text[SOURCE_LINE.len()..];
338            rest.strip_prefix(' ').unwrap_or(rest).to_owned()
339        })
340        .collect()
341}
342
343fn is_marker(item: &Item<'_>) -> bool {
344    item.comment
345        && [HEADER, CARRIED, TRANSLATED, PRELUDE_BEGIN]
346            .iter()
347            .any(|marker| item.text.starts_with(marker))
348}
349
350#[derive(Debug)]
351enum GroupKind {
352    Provenance,
353    Carried {
354        language: String,
355        lines: Vec<String>,
356    },
357    Comment {
358        text: String,
359        term: String,
360    },
361    Item {
362        text: String,
363        term: String,
364    },
365}
366
367struct Group<'a> {
368    kind: GroupKind,
369    items: Vec<Item<'a>>,
370    after: &'a str,
371}
372
373/// Groups the items: a self-translation header, a prelude block, a carried or
374/// translated item with its provenance, and an item with the comments directly
375/// before it are one group each.
376fn group_items<'a>(items: &[Item<'a>], text: &'a str) -> Vec<Group<'a>> {
377    let mut groups = Vec::new();
378    let between = |first: &Item<'_>, last: &Item<'_>| text[first.start..last.end].to_owned();
379    // The `// |` source lines that follow the item at `at` line by line.
380    let lines_after = |at: usize| {
381        let mut end = at;
382        while end + 1 < items.len()
383            && items[end + 1].comment
384            && items[end + 1].text.starts_with(SOURCE_LINE)
385            && line_break(items[end].after)
386        {
387            end += 1;
388        }
389        end
390    };
391    let mut index = 0;
392    while index < items.len() {
393        let item = &items[index];
394        let (end, kind) = if item.comment && item.text.starts_with(HEADER) {
395            (index, GroupKind::Provenance)
396        } else if item.comment && item.text == PRELUDE_BEGIN {
397            let mut end = index;
398            while end + 1 < items.len() && items[end].text != PRELUDE_END {
399                end += 1;
400            }
401            (end, GroupKind::Provenance)
402        } else if item.comment && item.text.starts_with(CARRIED) {
403            let end = lines_after(index);
404            let language = first_word(&item.text[CARRIED.len()..]);
405            let lines = source_lines(&items[index + 1..=end]);
406            (end, GroupKind::Carried { language, lines })
407        } else if item.comment && item.text.starts_with(TRANSLATED) {
408            let rest = &item.text[TRANSLATED.len()..];
409            let field = |name: &str| {
410                rest.split(' ').skip(2).find_map(|field| {
411                    let (key, value) = field.split_once('=').unwrap_or((field, ""));
412                    (key == name).then_some(value)
413                })
414            };
415            let lines_end = lines_after(index);
416            let count = field("items").and_then(|count| count.parse::<usize>().ok());
417            match count {
418                Some(count) if count > 0 && lines_end + count < items.len() => {
419                    let last = lines_end + count;
420                    let code = between(&items[lines_end + 1], &items[last]);
421                    if field("sha256") == Some(sha256(&code).as_str()) {
422                        let lines = source_lines(&items[index + 1..=lines_end]);
423                        (
424                            last,
425                            GroupKind::Carried {
426                                language: first_word(rest),
427                                lines,
428                            },
429                        )
430                    } else {
431                        // An edited translation is translated again; its provenance is dropped.
432                        (lines_end, GroupKind::Provenance)
433                    }
434                }
435                _ => (
436                    index,
437                    GroupKind::Comment {
438                        text: item.text.to_owned(),
439                        term: item.term.clone(),
440                    },
441                ),
442            }
443        } else {
444            // Comments directly before an item document it and travel with it.
445            let mut end = index;
446            while items[end].comment
447                && end + 1 < items.len()
448                && line_break(items[end].after)
449                && !is_marker(&items[end + 1])
450            {
451                end += 1;
452            }
453            let text = between(item, &items[end]);
454            let term = items[end].term.clone();
455            // A run of comments no item follows is one comment group.
456            if items[end].comment {
457                (end, GroupKind::Comment { text, term })
458            } else {
459                (end, GroupKind::Item { text, term })
460            }
461        };
462        groups.push(Group {
463            kind,
464            items: items[index..=end].to_vec(),
465            after: items[end].after,
466        });
467        index = end + 1;
468    }
469    groups
470}
471
472fn first_word(text: &str) -> String {
473    text.split(' ').next().unwrap_or_default().to_owned()
474}
475
476struct Translated {
477    code: Option<String>,
478    preludes: Vec<String>,
479    status: &'static str,
480    reason: Option<String>,
481}
482
483fn translate_group(
484    group: &Group<'_>,
485    from: &str,
486    to: &str,
487    decorators: &DecoratorSet,
488) -> Translated {
489    let done = |code: String, status: &'static str| Translated {
490        code: Some(code),
491        preludes: Vec::new(),
492        status,
493        reason: None,
494    };
495    let (text, term) = match &group.kind {
496        GroupKind::Provenance => {
497            return Translated {
498                code: None,
499                preludes: Vec::new(),
500                status: "provenance",
501                reason: None,
502            };
503        }
504        GroupKind::Carried { language, lines } => {
505            if family(language) == family(to) {
506                return done(lines.join("\n"), "restored");
507            }
508            let mut code = vec![group.items[0].text.to_owned()];
509            code.extend(lines.iter().map(|line| source_line(line)));
510            return Translated {
511                reason: Some("carried from another language".to_owned()),
512                ..done(code.join("\n"), "carried")
513            };
514        }
515        GroupKind::Comment { text, term } => {
516            // A copied comment has no provenance, so it must also fit the source.
517            if group
518                .items
519                .iter()
520                .all(|item| comment_fits(item.text, from) && comment_fits(item.text, to))
521            {
522                return done(text.clone(), "comment");
523            }
524            return carry(text, term, from, "comment the target cannot hold");
525        }
526        GroupKind::Item { text, term } => (text, term),
527    };
528    let emitted = match emit_item(text, from, to) {
529        Ok(Some(emitted)) => emitted,
530        Ok(None) => return carry(text, term, from, "top-level statement"),
531        Err(error) => return carry(text, term, from, error.kind.as_str()),
532    };
533    if emitted.definitions.is_empty() {
534        return carry(text, term, from, "no definition");
535    }
536    let exported = family(to) == "JavaScript"
537        && if family(from) == "Rust" {
538            RUST_EXPORT.is_match(text)
539        } else {
540            JAVASCRIPT_EXPORT.is_match(text)
541        };
542    let generic = emitted
543        .definitions
544        .iter()
545        .map(|definition| {
546            if exported {
547                format!("export {definition}")
548            } else {
549                definition.clone()
550            }
551        })
552        .collect::<Vec<_>>()
553        .join("\n\n");
554    let code = decorate_emitted(to, &generic, decorators);
555    if code.trim().is_empty() {
556        return carry(text, term, from, "dropped by a decorator");
557    }
558    let marker = format!(
559        "{TRANSLATED}{from} {term} items={} sha256={}",
560        emitted.definitions.len(),
561        sha256(&code)
562    );
563    let mut lines = vec![marker];
564    lines.extend(LINE_BREAK.split(text).map(source_line));
565    lines.push(code);
566    Translated {
567        code: Some(lines.join("\n")),
568        preludes: emitted.preludes,
569        status: "translated",
570        reason: None,
571    }
572}
573
574/// The item emitted by the portable core, or `None` for a top-level statement.
575fn emit_item(text: &str, from: &str, to: &str) -> Result<Option<Emitted>, TranslationError> {
576    let surface = if family(from) == "Rust" {
577        // The Rust frontend reads a program, so an item alone gets an empty main.
578        if RUST_MAIN.is_match(text) {
579            parse_rust(text)?
580        } else {
581            parse_rust(&format!("{text}\nfn main() {{}}\n"))?
582        }
583    } else {
584        parse_javascript(text)?
585    };
586    let program = check_program(&surface)?;
587    if program
588        .main
589        .as_ref()
590        .is_some_and(|main| !main.effects.is_empty())
591    {
592        return Ok(None);
593    }
594    let emitted = if family(to) == "Rust" {
595        emit_rust(&program)?
596    } else {
597        emit_javascript(&program)?
598    };
599    Ok(Some(emitted))
600}
601
602fn carry(text: &str, term: &str, from: &str, reason: &str) -> Translated {
603    let mut lines = vec![format!("{CARRIED}{from} {term} ({reason})")];
604    lines.extend(LINE_BREAK.split(text).map(source_line));
605    Translated {
606        code: Some(lines.join("\n")),
607        preludes: Vec::new(),
608        status: "carried",
609        reason: Some(reason.to_owned()),
610    }
611}
612
613fn source_line(line: &str) -> String {
614    if line.is_empty() {
615        SOURCE_LINE.to_owned()
616    } else {
617        format!("{SOURCE_LINE} {line}")
618    }
619}
620
621// A comment keeps its text when the target reads it as an ordinary comment:
622// a Rust doc comment needs an item after it, and Rust block comments nest.
623fn comment_fits(text: &str, to: &str) -> bool {
624    if text.starts_with("/*") {
625        let inner = text
626            .get(2..text.len().saturating_sub(2))
627            .unwrap_or_default();
628        if inner.contains("/*") || inner.contains("*/") {
629            return false;
630        }
631    }
632    family(to) != "Rust"
633        || !["///", "//!", "/**", "/*!"]
634            .iter()
635            .any(|prefix| text.starts_with(prefix))
636}