Viscosity
Review
Part XI · Viscosity in Rust · Day 44

174. Emitting JavaScript from Rust

Viscosity doesn’t print your program back out. It edits it: a list of byte ranges to replace, applied back to front, so every comment and blank line survives. In Rust that means byte offsets, a stable sort, and building text with fmt::Write.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why emit.rs applies its range edits back to front, and how it detects overlaps.
  • Say why UTF-8 byte offsets in Rust and UTF-16 offsets in TypeScript cut at the same characters.
  • Write text into a String with fmt::Write, write! and writeln!, and fix the missing-import error.
  • Implement the edit-applying loop of the emitter, with tests.

Change only what you must

Chapter 94 had a choice: print the whole program from the IR, or edit the original text. Viscosity edits. Here is one of the corpus programs before and after (the container class above it is left out):

const ps = [];
for (let i = 0; i < 100; i++)
  ps.push({ x: i, y: (i, i + 1) });
const ps = new __Vsc_ps();
for (let i = 0; i < 100; i++)
  ps.push(i, (i, i + 1));

(The loop header is wrapped to fit here; the file has it on one line.) Everything not rewritten is copied byte for byte: comments, spacing, odd formatting. That makes the output easy to read and review, and it is what lets a test demand byte-identical output from two engines (chapter 175).

Intuition

Think of correcting a printed page with correction tape, starting at the bottom. A correction near the bottom can make that line longer or shorter, but every line above it is still exactly where the list of corrections says it is.

A list of edits

emit.rs walks the plan, as emit.ts does: first the collection’s creation, then each push, then element reads, then field accesses. Each step records edits, and nothing is changed yet:

// Replace start..end with text
// (an insertion when they are equal).
struct Edit {
    start: u32,
    end: u32,
    text: String,
    rank: u32, // orders insertions
}

Then it applies them all (adapted from the end of emit_js):

edits.sort_by_key(|e| {
    let back = Reverse(e.start);
    (back, Reverse(e.end), e.rank)
});
let mut out = source.to_string();
let mut last_start = u32::MAX;
for e in &edits {
    if e.end > last_start {
        return Err(overlap(e));
    }
    let r =
        e.start as usize..e.end as usize;
    out.replace_range(r, &e.text);
    last_start = e.start;
}
Step throughEdits, applied back to front
A three-line source, three range edits, and the text after applying themas collected: E1 E2 E3E1 11..13→ new P()E2 23..31→ 1E3 39..46→ ps.x[0]const ps = [];ps.push({ x: 1 });s += ps[0].x;applied: none yet
The emitter collects edits while it walks the plan: each one says “replace bytes start..end with this text”. All three offsets are positions in the original source.
1/6

The diagram simplifies one thing. The real emitter doesn’t replace { x: 1 } in one piece: it deletes the text around each value, so a value that itself needs rewriting (say ps[j].x) gets its own edit. Edits then meet at the same byte, and some are pure insertions. That is what the rest of the sort key is for: at one position, the longer replacement goes first, and insertions go by rank.

The TS version sorts on the same three keys and rebuilds the string for each edit: out = out.slice(0, e.start) + e.text + out.slice(e.end). Rust’s String::replace_range edits one buffer in place, moving only the tail. Both sorts are stable, so edits with equal keys keep the order they were added in. Rust documents that for sort_by_key[3], and ECMAScript has required it of Array.prototype.sort since 2019[4]. Both engines must apply ties in the same order.

One more detail: p.x += 1 both reads and writes p.x, which would mean two edits for one range: an overlap. So the emitter keys field edits by their range and keeps one, chosen so that an out-of-bounds access throws the same error the original would.

Bytes, not code units

The offsets come from the parser. oxc’s spans count UTF-8 bytes. acorn’s count UTF-16 code units, which is what JavaScript strings index by. On rustc 1.97:

let src = "// é😀\nps[i].x";
let b = src.find("ps").unwrap();
let u = src[..b].encode_utf16().count();
println!("bytes {b}, utf-16 {u}");
// bytes 10, utf-16 7

In Node, src.indexOf("ps") is 7. The numbers differ, yet both point at the same p, because each engine slices its own kind of string with its own unit. So the port never converts. tests/extra/unicode_offsets.js puts accented letters, an emoji and U+2028 ahead of the rewritten code, and its output matches the TS engine’s.

A wrong offset can’t quietly corrupt the text, either. replace_range panics if a range doesn’t fall on char boundaries[2]. Replacing 4..5 in that string (the middle of é) stops with start of range should be a character boundary.

Writing text with fmt::Write

The edits’ texts, and the container classes that go above the code, are built with format! and join, line for line like the TS template literals and join('\n'). format! is itself a writer: in the standard library it makes a String with a capacity estimate and calls write_fmt on it[1].

You can call that directly and append to one growing String. The trait is std::fmt::Write, and the macros are write! and writeln!:

use std::fmt::Write;

let mut s = String::new();
writeln!(s, "class {cls} {{").unwrap();

Leave out the use and rustc 1.97 says (trimmed):

error[E0599]: cannot write into `String`
help: trait `Write` which provides
  `write_fmt` is implemented but not in
  scope; perhaps you want to import it
  |
1 + use std::fmt::Write;

write! returns a fmt::Result, because some writers can fail. String can’t: its implementation calls push_str and returns Ok(()), so .unwrap() is safe here[1].

Definition

std::fmt::Write is the trait for anything that accepts formatted UTF-8 text. Its one required method is write_str(&mut self, s: &str) -> fmt::Result. write_char and write_fmt have defaults. write!(w, ...) and writeln! format into any w with a write_fmt method. String implements fmt::Write, and format! is write! into a fresh String. The trait must be in scope (use std::fmt::Write;). Byte streams such as files use the different trait std::io::Write.

Try itA container class with fmt::Write
use std::fmt::Write;

fn class(cls: &str, cols: &[&str])
    -> String {
    let t = "Float64Array";
    let mut s = String::new();
    writeln!(s, "class {cls} {{")
        .unwrap();
    s += "  constructor(cap) {\n";
    for c in cols {
        s.push_str("    ");
        writeln!(
            s,
            "this.{c} = new {t}(cap);"
        )
        .unwrap();
    }
    s.push_str("  }\n}");
    s
}

fn main() {
    let cols = ["x", "vx"];
    let c = class("__Vsc_ps", &cols);
    println!("{c}");
    println!("{} bytes", c.len());
}
Open in Rust Playground ↗
Under the hood

The container classes go after any shebang line and leading "use strict". The TS engine finds that point with the regex /^(#![^\n]*\n)?(\s*(['"])use strict\3;?\s*\n)?/. The port has no regex crate, so prelude_position matches the same pattern by hand, with is_js_space listing exactly the characters JavaScript’s \s matches. Its unit tests pin the tricky cases, such as 'use strict'; x on one line, where the regex matches nothing. The parity work also found a splicing bug in the reference: in push({ y: (a, b) }) the value was copied without its parentheses (a sequence expression’s span excludes them), so the comma split the arguments and y received a. Both engines now put the parentheses back. That is the corpus program at the top of this chapter.

Your turn

ExerciseApply the edits

Write apply, the heart of emit_js: return src with every edit applied. Offsets are byte offsets into the original src. Edits may come in any order; apply them from the largest start down. Two edits may touch (one ends where the next starts), but if one overlaps an edit already applied, return Err instead.

use std::cmp::Reverse;

// Replace bytes start..end with text.
#[derive(Clone, Debug)]
struct Edit {
    start: usize,
    end: usize,
    text: String,
}

fn apply(src: &str, edits: &[Edit])
    -> Result<String, String> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Viscosity emits by range edits on the original source. Everything outside them is copied byte for byte.
  • Edits are sorted by start, last first, and applied with replace_range, so earlier offsets stay valid. An edit that ends past the previous start overlaps, and is an internal error.
  • The sort is stable in both engines, so ties apply in the same order.
  • oxc’s UTF-8 byte offsets in a Rust String and acorn’s UTF-16 offsets in a JS string name the same characters. A bad offset panics instead of corrupting text.
  • fmt::Write (write!, writeln!) appends formatted text to a String. format! is the same machinery. Remember use std::fmt::Write;.

Check your understanding

0/6
  1. 1.

    Why doesn’t Viscosity print the whole program from its IR?

  2. 2.

    Three edits start at bytes 11, 23 and 39. In which order does emit_js apply them?

  3. 3.

    The source starts with // é and then the code to rewrite. oxc gives the code’s offset in bytes; acorn gives it in UTF-16 units. What does the port do?

  4. 4.

    Which line makes write!(s, "class {cls} {{") compile when s is a String?

  5. 5.

    What does the {{ in "class {cls} {{" produce?

  6. 6.

    What did the TS bug with push({ y: (a, b) }) do, and how is it fixed?

References

  1. [1] The Rust Project Developers. Trait std::fmt::Write, and std::fmt::format (Rust standard library). Rust standard library; source in library/core/src/fmt/mod.rs, library/alloc/src/fmt.rs and library/alloc/src/string.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/fmt/trait.Write.html (checked 2026-10-31)
  2. [2] The Rust Project Developers. String::replace_range (Rust standard library). Rust standard library; source in library/alloc/src/string.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/string/struct.String.html#method.replace_range (checked 2026-10-31)
  3. [3] The Rust Project Developers. slice::sort_by_key (Rust standard library). Rust standard library; source in library/alloc/src/slice.rs of github.com/rust-lang/rust. doc.rust-lang.org/std/primitive.slice.html#method.sort_by_key (checked 2026-10-31)
  4. [4] Ecma International, TC39. ECMAScript Language Specification: Array.prototype.sort and SortIndexedProperties. ECMA-262, current draft; source spec.html in github.com/tc39/ecma262. tc39.es/ecma262/#sec-array.prototype.sort (checked 2026-10-31)