Skip to main content

ox_mf2_parser/snapshot/
writer.rs

1// @license MIT
2// @author kazuya kawaguchi (a.k.a. kazupon)
3
4//! Phase 1 `ParseResult` / `BatchParseResult` → Binary AST snapshot
5//! encoder.
6//!
7//! The writer first builds section-local byte buffers, then hands them
8//! to [`crate::snapshot::sections::SnapshotAssembler`] for final layout
9//! computation, header emission, padding, and section payload copy.
10
11use crate::api::{
12    parse_batch as run_parse_batch, parse_source as run_parse_source, BatchParseOptions,
13    BatchParseResult, ParseInput, ParseOptions, ParseResult, ParseSessionResult,
14};
15use crate::diagnostic::{Diagnostic, DiagnosticCode, MESSAGE_REF_CATALOG};
16use crate::snapshot::error::SnapshotWriteError;
17use crate::snapshot::format::{
18    checked_u32, write_u16_le, write_u32_le, write_u8, RootId, SectionKind, StringId,
19    DIAGNOSTIC_LABEL_RECORD_SIZE, DIAGNOSTIC_RECORD_SIZE, EDGE_KIND_NODE, EDGE_KIND_TOKEN,
20    EDGE_RECORD_SIZE, NODE_RECORD_SIZE, NONE_REF, TOKEN_RECORD_SIZE, TRIVIA_RECORD_SIZE,
21};
22use crate::snapshot::sections::{EmittedSection, SnapshotAssembler};
23use crate::snapshot::string_table::StringTableBuilder;
24use crate::source::{SourceFileInput, SourceStore};
25use crate::span::SourceId as PhaseOneSourceId;
26use crate::tables::CstTables;
27
28/// Snapshot encoding options.
29///
30/// These flags only control which already-produced parser data is
31/// encoded into the snapshot bytes. They never change MF2 parser
32/// semantics, and they do not change the `SnapshotResult.diagnostics`
33/// field returned to the caller.
34#[derive(Debug, Clone, Copy)]
35pub struct SnapshotOptions {
36    /// Encode parser diagnostics into the snapshot. Default `true`.
37    /// When `false`, the diagnostics and diagnostic labels sections
38    /// are omitted, and diagnostic messages are not interned into the
39    /// snapshot string table.
40    pub include_diagnostics: bool,
41    /// Encode source text bytes into the snapshot. Default `false`.
42    /// When `false`, `SourceRecord.text` uses the canonical none
43    /// sentinel and the source text data section is omitted.
44    pub include_source_text: bool,
45    /// Encode trivia records into the snapshot. Default `true`. When
46    /// `false`, the trivia section is omitted and `TokenRecord`
47    /// trivia ranges are written as `0`.
48    pub include_trivia: bool,
49}
50
51impl Default for SnapshotOptions {
52    fn default() -> Self {
53        Self {
54            include_diagnostics: true,
55            include_source_text: false,
56            include_trivia: true,
57        }
58    }
59}
60
61/// Owned result of a single-input snapshot encode.
62#[derive(Debug, Clone)]
63pub struct SnapshotResult {
64    /// Snapshot wire bytes.
65    pub bytes: Vec<u8>,
66    /// Snapshot-local root id of this input. v0.1 single-input
67    /// snapshots always have `root.raw() == 0`.
68    pub root: RootId,
69    /// Parser diagnostics for caller convenience. Always returned,
70    /// even when `SnapshotOptions.include_diagnostics = false`.
71    pub diagnostics: Vec<Diagnostic>,
72}
73
74/// Owned result of a batch snapshot encode.
75#[derive(Debug, Clone)]
76pub struct BatchSnapshotResult {
77    pub bytes: Vec<u8>,
78    pub roots: Vec<RootId>,
79    pub diagnostics: Vec<Diagnostic>,
80    pub execution: crate::api::BatchExecution,
81    pub degraded: bool,
82}
83
84/// Encode an already-produced [`ParseResult`] into a Binary AST
85/// snapshot.
86///
87/// `sources` must be the same [`SourceStore`] the result was parsed
88/// against — i.e. `result` must have been produced by
89/// [`parse_source`](crate::parse_source) or [`parse_batch`](crate::parse_batch)
90/// using this very store. The encoder reads `SourceRecord` metadata
91/// (path / locale / `message_id` / `base_offset` / optional source
92/// text) from `sources.get(result.source)` and trusts the caller to
93/// have kept them in sync.
94///
95/// **Do not pass a [`ParseResult`] from
96/// [`parse_message`](crate::parse_message) here**:
97/// `parse_message` does not register the source in any store and
98/// always returns `SourceId::new(0)`, so pairing it with an
99/// unrelated store silently encodes the wrong source metadata. Use
100/// [`parse_message_to_snapshot`] for the standalone case.
101///
102/// `parse_result_to_snapshot` does not reparse the source.
103pub fn parse_result_to_snapshot(
104    sources: &SourceStore,
105    result: &ParseResult,
106    options: SnapshotOptions,
107) -> Result<SnapshotResult, SnapshotWriteError> {
108    // The owned-result path clones once, here, so the encoder body
109    // can move the `Vec` straight into `SnapshotResult.diagnostics`
110    // instead of doing a second `.to_vec()` inside `encode_single`.
111    let diagnostics = result.diagnostics.clone();
112    encode_single(sources, result.source, &result.cst, diagnostics, options)
113}
114
115/// Snapshot source metadata that pairs with
116/// [`parse_message_to_snapshot`]'s `source` parameter.
117///
118/// Deliberately omits the `source` field that
119/// [`SourceFileInput`] carries: the snapshot writer must point at
120/// the same bytes the parser saw, and a metadata struct that owned
121/// its own `source` would invite the caller to set two different
122/// strings. Bindings and language wrappers should follow the same
123/// shape.
124#[derive(Debug, Default, Clone, Copy)]
125pub struct SnapshotSourceMetadata<'a> {
126    /// Optional filesystem path, used for diagnostics.
127    pub path: Option<&'a str>,
128    /// Optional BCP-47 locale tag, used for project-aware tooling.
129    pub locale: Option<&'a str>,
130    /// Optional logical message id (e.g. translation key).
131    pub message_id: Option<&'a str>,
132    /// Optional base offset, used when the source is a substring of a
133    /// larger file (e.g. a single entry inside a locale resource).
134    pub base_offset: Option<u32>,
135}
136
137/// Parse `source` standalone and encode the result into a Binary
138/// AST snapshot. Builds a private one-entry [`SourceStore`] so
139/// callers never have to construct one to pair with
140/// [`parse_message`](crate::parse_message)'s
141/// `SourceId::new(0)` return value.
142///
143/// `metadata` lets callers attach path / locale / `message_id` /
144/// `base_offset` to the resulting `SourceRecord`. When `None`, the
145/// snapshot's `SourceRecord` carries no metadata strings and
146/// `base_offset = 0`.
147pub fn parse_message_to_snapshot(
148    source: &str,
149    metadata: Option<SnapshotSourceMetadata<'_>>,
150    parse_options: ParseOptions,
151    snapshot_options: SnapshotOptions,
152) -> Result<SnapshotResult, SnapshotWriteError> {
153    let mut sources = SourceStore::with_capacity(1);
154    let metadata = metadata.unwrap_or_default();
155    let input = SourceFileInput {
156        source,
157        path: metadata.path,
158        locale: metadata.locale,
159        message_id: metadata.message_id,
160        base_offset: metadata.base_offset,
161    };
162    // The public `parse_message_to_snapshot` signature returns
163    // `Result<_, SnapshotWriteError>`, so route oversized sources
164    // through `try_add` and convert the `SourceStoreError` into a
165    // `SnapshotWriteError::SourceTooLarge` instead of letting
166    // `SourceStore::add`'s panic escape the API boundary.
167    let id = sources
168        .try_add(input)
169        .map_err(|_| SnapshotWriteError::SourceTooLarge)?;
170    let result = run_parse_source(&sources, id, parse_options);
171    parse_result_to_snapshot(&sources, &result, snapshot_options)
172}
173
174/// Parse `source_id` from `sources` and encode the result into a
175/// Binary AST snapshot in a single call.
176pub fn parse_source_to_snapshot(
177    sources: &SourceStore,
178    source_id: PhaseOneSourceId,
179    parse_options: ParseOptions,
180    snapshot_options: SnapshotOptions,
181) -> Result<SnapshotResult, SnapshotWriteError> {
182    let result = run_parse_source(sources, source_id, parse_options);
183    parse_result_to_snapshot(sources, &result, snapshot_options)
184}
185
186/// Encode a borrowed [`ParseSessionResult`] into a Binary AST
187/// snapshot.
188///
189/// The session's [`crate::CstView`], diagnostics, and source identity
190/// are read in place without copying the underlying tables.
191pub fn parse_session_to_snapshot(
192    session: &ParseSessionResult<'_>,
193    options: SnapshotOptions,
194) -> Result<SnapshotResult, SnapshotWriteError> {
195    // Materialise diagnostics into owned values exactly once. The
196    // owned `Vec` is moved into both the writer (as a borrow) and the
197    // returned `SnapshotResult.diagnostics`, so workspace-reuse / LSP
198    // callers do not pay for a second `.to_vec()`.
199    let diagnostics: Vec<Diagnostic> = session.diagnostics.iter().collect();
200    encode_single(
201        session.cst.sources(),
202        session.source,
203        session.cst.tables(),
204        diagnostics,
205        options,
206    )
207}
208
209/// Parse `inputs` sequentially and encode the result into a single
210/// shared Binary AST snapshot.
211pub fn parse_batch_to_snapshot(
212    inputs: &[ParseInput<'_>],
213    batch_options: BatchParseOptions,
214    snapshot_options: SnapshotOptions,
215) -> Result<BatchSnapshotResult, SnapshotWriteError> {
216    // Phase 1's `parse_batch` registers each input through
217    // `SourceStore::add`, which panics on `source.len() > u32::MAX`.
218    // The public snapshot API returns `Result<_, SnapshotWriteError>`,
219    // so pre-validate every input here and convert oversized inputs
220    // to `SnapshotWriteError::SourceTooLarge` before parse runs.
221    for input in inputs {
222        if u32::try_from(input.source.len()).is_err() {
223            return Err(SnapshotWriteError::SourceTooLarge);
224        }
225    }
226    let batch = run_parse_batch(inputs, batch_options);
227    parse_batch_result_to_snapshot(&batch, snapshot_options)
228}
229
230/// Encode an already-produced [`BatchParseResult`] into a shared
231/// Binary AST snapshot.
232pub fn parse_batch_result_to_snapshot(
233    result: &BatchParseResult,
234    options: SnapshotOptions,
235) -> Result<BatchSnapshotResult, SnapshotWriteError> {
236    // Phase 1 `parse_batch` guarantees `item.source ==
237    // item.result.source`, but `BatchParseResult` / `BatchParseItem`
238    // are public + `Clone` + struct-literal-constructible, so a
239    // hand-crafted batch can mismatch the two. Encoding such an
240    // item would attach the source metadata named by `item.source`
241    // (path / locale / message_id / source text) to a CST whose
242    // spans were produced against a different source — silently
243    // producing an incoherent snapshot. Reject up front.
244    for item in &result.items {
245        if item.source != item.result.source {
246            return Err(SnapshotWriteError::InconsistentSourceId);
247        }
248    }
249    // Pre-size the writer for the batch so the string table /
250    // roots vectors don't grow during encoding.
251    let mut writer = SnapshotWriter::with_root_hint(options, result.items.len());
252    // Pre-intern every batch root's source metadata before any
253    // `add_root` runs so the string table emits source metadata
254    // strings ahead of diagnostic messages — the canonical writer
255    // order required by `design/003` §"String Table".
256    writer.pre_intern_root_sources(&result.sources, result.items.iter().map(|item| item.source))?;
257    for item in &result.items {
258        writer.add_root(
259            &result.sources,
260            item.source,
261            &item.result.cst,
262            &item.result.diagnostics,
263        )?;
264    }
265    let bytes = writer.finish(&result.sources)?;
266    let roots_count = checked_u32(result.items.len()).ok_or(SnapshotWriteError::TooManyRoots)?;
267    let roots = (0..roots_count).map(RootId::new).collect();
268    // `SnapshotResult.diagnostics` is returned for caller
269    // convenience regardless of `SnapshotOptions.include_diagnostics`,
270    // so callers can still inspect parser output without re-parsing.
271    // Reserve once for the exact total and extend with per-diagnostic
272    // clones instead of `flat_map(|item| item.result.diagnostics.clone())`,
273    // which would allocate a temporary `Vec` per batch item.
274    let diagnostic_total: usize = result
275        .items
276        .iter()
277        .map(|item| item.result.diagnostics.len())
278        .sum();
279    let mut diagnostics = Vec::with_capacity(diagnostic_total);
280    for item in &result.items {
281        diagnostics.extend(item.result.diagnostics.iter().cloned());
282    }
283    Ok(BatchSnapshotResult {
284        bytes,
285        roots,
286        diagnostics,
287        execution: result.execution,
288        degraded: result.degraded,
289    })
290}
291
292fn encode_single(
293    sources: &SourceStore,
294    source: PhaseOneSourceId,
295    cst: &CstTables,
296    diagnostics: Vec<Diagnostic>,
297    options: SnapshotOptions,
298) -> Result<SnapshotResult, SnapshotWriteError> {
299    let mut writer = SnapshotWriter::new(options);
300    // Match `parse_batch_result_to_snapshot`: register the root
301    // source metadata up front so it lands in the string table
302    // ahead of any diagnostic messages emitted during `add_root`.
303    writer.pre_intern_root_sources(sources, [source])?;
304    writer.add_root(sources, source, cst, &diagnostics)?;
305    let bytes = writer.finish(sources)?;
306    Ok(SnapshotResult {
307        bytes,
308        root: RootId::new(0),
309        diagnostics,
310    })
311}
312
313// ── internal writer state ────────────────────────────────────────────
314
315struct PendingRoot {
316    source_local: u32,
317    root_node: u32,
318    /// Offset into the snapshot diagnostics array.
319    diag_start: u32,
320    diag_count: u32,
321}
322
323/// Per-snapshot-local-source metadata `StringId`s, captured by
324/// `intern_source` the first time a Phase 1 source is registered
325/// so `finish` can emit `SourceRecord` wire bytes without a second
326/// `StringTableBuilder::intern_optional` lookup per field.
327#[derive(Debug, Clone, Copy)]
328struct SourceMetaIds {
329    path: StringId,
330    locale: StringId,
331    message_id: StringId,
332}
333
334struct SnapshotWriter {
335    options: SnapshotOptions,
336    string_table: StringTableBuilder,
337    /// Phase 1 `SourceId` per snapshot-local `SourceId` (the
338    /// `Vec` index is the snapshot-local id). Parallel to
339    /// `source_meta`. v0.1 writer does NOT deduplicate
340    /// `SourceRecord`s — see `design/003` §"Source Section".
341    /// Each `add_root` call appends one entry, even when the
342    /// Phase 1 source matches an earlier root.
343    source_phase_one: Vec<PhaseOneSourceId>,
344    /// Parallel to `source_phase_one`: metadata `StringId` triple
345    /// captured by `allocate_root_source` so `finish` can copy
346    /// `path` / `locale` / `message_id` straight into the
347    /// `SourceRecord` without a second string-table lookup.
348    source_meta: Vec<SourceMetaIds>,
349    nodes_bytes: Vec<u8>,
350    nodes_count: u32,
351    edges_bytes: Vec<u8>,
352    edges_count: u32,
353    tokens_bytes: Vec<u8>,
354    tokens_count: u32,
355    trivia_bytes: Vec<u8>,
356    trivia_count: u32,
357    diagnostics_bytes: Vec<u8>,
358    diagnostics_count: u32,
359    diagnostic_labels_bytes: Vec<u8>,
360    diagnostic_labels_count: u32,
361    roots: Vec<PendingRoot>,
362}
363
364impl SnapshotWriter {
365    fn new(options: SnapshotOptions) -> Self {
366        Self::with_root_hint(options, 1)
367    }
368
369    /// Build a writer with capacity hints derived from the expected
370    /// root count. Each root contributes up to one source and three
371    /// metadata strings (`path` / `locale` / `message_id`), so a
372    /// batch caller can hand the writer a tight reservation up
373    /// front.
374    fn with_root_hint(options: SnapshotOptions, root_hint: usize) -> Self {
375        // 3 metadata strings per source plus a per-root fixed
376        // overhead for diagnostic catalog strings (~15 entries
377        // across the catalog) — being generous on string_hint
378        // costs only a small amount of capacity in the lookup
379        // vectors and avoids growth during intern.
380        let string_hint = root_hint.saturating_mul(3).saturating_add(16);
381        let data_hint = root_hint.saturating_mul(64);
382        Self {
383            options,
384            string_table: StringTableBuilder::with_capacity(string_hint, data_hint),
385            source_phase_one: Vec::with_capacity(root_hint),
386            source_meta: Vec::with_capacity(root_hint),
387            nodes_bytes: Vec::new(),
388            nodes_count: 0,
389            edges_bytes: Vec::new(),
390            edges_count: 0,
391            tokens_bytes: Vec::new(),
392            tokens_count: 0,
393            trivia_bytes: Vec::new(),
394            trivia_count: 0,
395            diagnostics_bytes: Vec::new(),
396            diagnostics_count: 0,
397            diagnostic_labels_bytes: Vec::new(),
398            diagnostic_labels_count: 0,
399            roots: Vec::with_capacity(root_hint),
400        }
401    }
402
403    /// Allocate a fresh snapshot-local `SourceId` for `root_source`
404    /// and cache its metadata `StringId` triple. v0.1 writer does
405    /// NOT deduplicate `SourceRecord`s by Phase 1 source id — see
406    /// `design/003` §"Source Section". Every `add_root` call gets
407    /// its own slot, even when the Phase 1 source matches an
408    /// earlier root, so root identity in the snapshot is 1:1 with
409    /// the input root order.
410    fn allocate_root_source(
411        &mut self,
412        sources: &SourceStore,
413        root_source: PhaseOneSourceId,
414    ) -> Result<u32, SnapshotWriteError> {
415        let file = sources
416            .get(root_source)
417            .ok_or(SnapshotWriteError::InvalidSourceId)?;
418        let local =
419            checked_u32(self.source_phase_one.len()).ok_or(SnapshotWriteError::TooManySources)?;
420        let path = self.string_table.intern_optional(file.path.as_deref())?;
421        let locale = self.string_table.intern_optional(file.locale.as_deref())?;
422        let message_id = self
423            .string_table
424            .intern_optional(file.message_id.as_deref())?;
425        self.source_phase_one.push(root_source);
426        self.source_meta.push(SourceMetaIds {
427            path,
428            locale,
429            message_id,
430        });
431        debug_assert_eq!(self.source_meta.len(), self.source_phase_one.len());
432        Ok(local)
433    }
434
435    /// Pre-intern every root's source metadata strings into the
436    /// `StringTable` ahead of any `add_root` call. The string
437    /// table then emits source metadata strings strictly before
438    /// diagnostic messages — the canonical order called out in
439    /// `design/003` §"String Table" — even across batch items.
440    ///
441    /// Source slot allocation happens later inside `add_root` via
442    /// `allocate_root_source`; this method only touches the string
443    /// table, so repeated Phase 1 source ids are interned
444    /// idempotently and never cost extra string table entries.
445    fn pre_intern_root_sources<I>(
446        &mut self,
447        sources: &SourceStore,
448        source_ids: I,
449    ) -> Result<(), SnapshotWriteError>
450    where
451        I: IntoIterator<Item = PhaseOneSourceId>,
452    {
453        for id in source_ids {
454            let file = sources.get(id).ok_or(SnapshotWriteError::InvalidSourceId)?;
455            self.string_table.intern_optional(file.path.as_deref())?;
456            self.string_table.intern_optional(file.locale.as_deref())?;
457            self.string_table
458                .intern_optional(file.message_id.as_deref())?;
459        }
460        Ok(())
461    }
462
463    fn add_root(
464        &mut self,
465        sources: &SourceStore,
466        source: PhaseOneSourceId,
467        cst: &CstTables,
468        diagnostics: &[Diagnostic],
469    ) -> Result<(), SnapshotWriteError> {
470        // Reserve section byte buffers from the Phase 1 CST counts
471        // so the per-record `write_*` calls below don't grow the
472        // underlying `Vec`s mid-loop. Diagnostics / labels are
473        // skipped when `include_diagnostics = false` so the writer
474        // doesn't speculate on encode work it won't perform.
475        self.nodes_bytes
476            .reserve(cst.node_count() * NODE_RECORD_SIZE as usize);
477        self.edges_bytes
478            .reserve(cst.edge_count() * EDGE_RECORD_SIZE as usize);
479        self.tokens_bytes
480            .reserve(cst.token_count() * TOKEN_RECORD_SIZE as usize);
481        if self.options.include_trivia {
482            self.trivia_bytes
483                .reserve(cst.trivia_count() * TRIVIA_RECORD_SIZE as usize);
484        }
485        if self.options.include_diagnostics {
486            self.diagnostics_bytes
487                .reserve(diagnostics.len() * DIAGNOSTIC_RECORD_SIZE as usize);
488            let label_total: usize = diagnostics.iter().map(|d| d.labels.len()).sum();
489            self.diagnostic_labels_bytes
490                .reserve(label_total * DIAGNOSTIC_LABEL_RECORD_SIZE as usize);
491        }
492
493        // Allocate a fresh SourceRecord slot for THIS root (no
494        // dedup by Phase 1 SourceId — see `design/003` §"Source
495        // Section"). `emit_*` below all reference this slot
496        // directly so every token / trivia / diagnostic in this
497        // root points at this root's snapshot-local SourceId.
498        let source_local = self.allocate_root_source(sources, source)?;
499
500        // Trivia first so token records can reference snapshot-local
501        // trivia ids without a second pass. With include_trivia=false
502        // we still walk parser trivia to fill the remap with NONE_REF
503        // so the per-token leading/trailing ranges encode as `0`.
504        let trivia_remap = self.emit_trivia(cst, source_local)?;
505        // Tokens next: token records reference trivia ranges.
506        let token_remap = self.emit_tokens(cst, &trivia_remap, source_local)?;
507        // Nodes / edges share a single post-order pass: every edge
508        // refers to either a node id or token id, and node order
509        // follows parser post-order so the parser root is the last
510        // node.
511        let node_remap = self.emit_nodes_and_edges(cst, &token_remap)?;
512
513        let root_node = match cst.root_id() {
514            Some(parser_root) => node_remap[parser_root.index()],
515            None => return Err(SnapshotWriteError::MissingRoot),
516        };
517
518        // Diagnostic encoding is the writer's, not the caller's,
519        // choice. `SnapshotResult.diagnostics` is always returned to
520        // the caller from `encode_single` / batch encoders (so they
521        // can still see what the parser produced), but when
522        // `include_diagnostics = false` the writer must skip building
523        // any diagnostic section bytes and must not advance the
524        // diagnostic / label counters.
525        let (diag_start, diag_count) = if self.options.include_diagnostics {
526            let start = self.diagnostics_count;
527            for diag in diagnostics {
528                self.emit_diagnostic(diag, source_local)?;
529            }
530            let count = self
531                .diagnostics_count
532                .checked_sub(start)
533                .expect("diagnostics_count only grows");
534            (start, count)
535        } else {
536            (0, 0)
537        };
538
539        // `node_remap` / `token_remap` / `trivia_remap` are consumed
540        // entirely inside this call: tokens and trivia are emitted
541        // before nodes, and nodes remap their child edges against the
542        // already-populated maps. Drop them at scope end.
543        let _ = node_remap;
544        let _ = token_remap;
545        let _ = trivia_remap;
546
547        self.roots.push(PendingRoot {
548            source_local,
549            root_node,
550            diag_start,
551            diag_count,
552        });
553        Ok(())
554    }
555
556    fn emit_trivia(
557        &mut self,
558        cst: &CstTables,
559        root_source_local: u32,
560    ) -> Result<Vec<u32>, SnapshotWriteError> {
561        let trivia_count = cst.trivia_count();
562        let mut remap = Vec::with_capacity(trivia_count);
563        if !self.options.include_trivia || trivia_count == 0 {
564            remap.resize(trivia_count, NONE_REF);
565            return Ok(remap);
566        }
567        // Each trivia is recorded as belonging to the current
568        // root's snapshot-local `SourceRecord` — v0.1 keeps the
569        // writer to one SourceRecord per root and references each
570        // trivia / token / diagnostic span against that slot,
571        // matching the design/003 "no source dedup" contract.
572        for trivia in &cst.trivia {
573            let local = self.next_trivia_id()?;
574            write_u16_le(&mut self.trivia_bytes, trivia.kind);
575            write_u16_le(&mut self.trivia_bytes, 0); // flags
576            write_u32_le(&mut self.trivia_bytes, trivia.span_start);
577            write_u32_le(&mut self.trivia_bytes, trivia.span_end);
578            write_u32_le(&mut self.trivia_bytes, root_source_local);
579            remap.push(local);
580        }
581        Ok(remap)
582    }
583
584    fn emit_tokens(
585        &mut self,
586        cst: &CstTables,
587        trivia_remap: &[u32],
588        root_source_local: u32,
589    ) -> Result<Vec<u32>, SnapshotWriteError> {
590        let mut remap = Vec::with_capacity(cst.token_count());
591        for token in &cst.tokens {
592            let local = self.next_token_id()?;
593            let source_local = root_source_local;
594
595            let (leading_start, leading_count, trailing_start, trailing_count) =
596                if self.options.include_trivia
597                    && (token.leading_trivia_count != 0 || token.trailing_trivia_count != 0)
598                {
599                    let lead_start_parser = token.first_trivia;
600                    let trail_start_parser = lead_start_parser + token.leading_trivia_count as u32;
601                    let lead_start_snap = if token.leading_trivia_count == 0 {
602                        0
603                    } else {
604                        trivia_remap[lead_start_parser as usize]
605                    };
606                    let trail_start_snap = if token.trailing_trivia_count == 0 {
607                        0
608                    } else {
609                        trivia_remap[trail_start_parser as usize]
610                    };
611                    (
612                        lead_start_snap,
613                        token.leading_trivia_count as u32,
614                        trail_start_snap,
615                        token.trailing_trivia_count as u32,
616                    )
617                } else {
618                    (0, 0, 0, 0)
619                };
620
621            write_u16_le(&mut self.tokens_bytes, token.kind);
622            write_u16_le(&mut self.tokens_bytes, 0); // flags
623            write_u32_le(&mut self.tokens_bytes, token.span_start);
624            write_u32_le(&mut self.tokens_bytes, token.span_end);
625            write_u32_le(&mut self.tokens_bytes, source_local);
626            write_u32_le(&mut self.tokens_bytes, leading_start);
627            write_u32_le(&mut self.tokens_bytes, leading_count);
628            write_u32_le(&mut self.tokens_bytes, trailing_start);
629            write_u32_le(&mut self.tokens_bytes, trailing_count);
630            write_u32_le(&mut self.tokens_bytes, 0); // reserved_tail
631            remap.push(local);
632        }
633        Ok(remap)
634    }
635
636    fn emit_nodes_and_edges(
637        &mut self,
638        cst: &CstTables,
639        token_remap: &[u32],
640    ) -> Result<Vec<u32>, SnapshotWriteError> {
641        let mut remap = Vec::with_capacity(cst.node_count());
642        for node in &cst.nodes {
643            let snapshot_node_id = self.next_node_id()?;
644            let child_start = self.edges_count;
645            // Edges first — node references in edges always point at
646            // ids that are smaller than the current node id (post-order),
647            // so `remap` already contains them.
648            for edge in cst.edges_for(node) {
649                let snapshot_edge_id = self.next_edge_id()?;
650                let _ = snapshot_edge_id;
651                match edge.kind {
652                    k if k == EDGE_KIND_NODE => {
653                        let snap_id = remap[edge.ref_id as usize];
654                        write_u16_le(&mut self.edges_bytes, EDGE_KIND_NODE);
655                        write_u16_le(&mut self.edges_bytes, 0); // flags
656                        write_u32_le(&mut self.edges_bytes, snap_id);
657                    }
658                    k if k == EDGE_KIND_TOKEN => {
659                        let snap_id = token_remap[edge.ref_id as usize];
660                        write_u16_le(&mut self.edges_bytes, EDGE_KIND_TOKEN);
661                        write_u16_le(&mut self.edges_bytes, 0); // flags
662                        write_u32_le(&mut self.edges_bytes, snap_id);
663                    }
664                    _ => {
665                        // Phase 1 builder never produces other edge
666                        // kinds; treat as an internal invariant
667                        // violation by remapping the edge as a token
668                        // reference. (Decoder will reject if reached.)
669                        return Err(SnapshotWriteError::InvalidSourceId);
670                    }
671                }
672            }
673            let child_count = self
674                .edges_count
675                .checked_sub(child_start)
676                .expect("edges_count only grows");
677
678            write_u16_le(&mut self.nodes_bytes, node.kind);
679            write_u16_le(&mut self.nodes_bytes, 0); // flags
680            write_u32_le(&mut self.nodes_bytes, node.span_start);
681            write_u32_le(&mut self.nodes_bytes, node.span_end);
682            write_u32_le(&mut self.nodes_bytes, child_start);
683            write_u32_le(&mut self.nodes_bytes, child_count);
684            write_u32_le(&mut self.nodes_bytes, NONE_REF); // data_ref
685            remap.push(snapshot_node_id);
686        }
687        Ok(remap)
688    }
689
690    fn emit_diagnostic(
691        &mut self,
692        diagnostic: &Diagnostic,
693        root_source_local: u32,
694    ) -> Result<(), SnapshotWriteError> {
695        // v0.1 writer policy: diagnostics and diagnostic labels
696        // belong to the root's snapshot-local `SourceRecord` (see
697        // `design/003` §"Diagnostics Section"). The wire format
698        // keeps an explicit `source_id` field on every
699        // `DiagnosticRecord` / `DiagnosticLabelRecord` so a future
700        // writer can opt into multi-source diagnostics within a
701        // single root, but v0.1 always emits the root's snapshot-
702        // local source. Phase 1 only generates diagnostics against
703        // the parsed source anyway; a caller-supplied
704        // `Diagnostic.source` or `DiagnosticLabel.source` that
705        // names a different Phase 1 source is intentionally
706        // collapsed here — the snapshot is single-source per root.
707        let source_local = root_source_local;
708        let _ = diagnostic.source; // intentional: collapsed (see policy above)
709        let label_start = self.diagnostic_labels_count;
710        for label in &diagnostic.labels {
711            let label_source = root_source_local;
712            let _ = label.source; // intentional: collapsed (see policy above)
713            let msg_id = if self.options.include_diagnostics {
714                self.string_table.intern(label.message)?
715            } else {
716                StringId::NONE
717            };
718            self.next_diagnostic_label_id()?;
719            write_u32_le(&mut self.diagnostic_labels_bytes, label_source);
720            write_u32_le(&mut self.diagnostic_labels_bytes, label.span.start);
721            write_u32_le(&mut self.diagnostic_labels_bytes, label.span.end);
722            write_u32_le(&mut self.diagnostic_labels_bytes, msg_id.raw());
723        }
724        let label_count = self
725            .diagnostic_labels_count
726            .checked_sub(label_start)
727            .expect("label count only grows");
728
729        let message_id = if self.options.include_diagnostics {
730            self.string_table.intern(diagnostic.message)?
731        } else {
732            StringId::NONE
733        };
734
735        self.next_diagnostic_id()?;
736        write_u32_le(&mut self.diagnostics_bytes, source_local);
737        write_u32_le(&mut self.diagnostics_bytes, diagnostic.span.start);
738        write_u32_le(&mut self.diagnostics_bytes, diagnostic.span.end);
739        write_u8(&mut self.diagnostics_bytes, diagnostic.severity as u8);
740        write_u8(&mut self.diagnostics_bytes, 0); // reserved
741        write_u16_le(&mut self.diagnostics_bytes, diagnostic.code.as_u16());
742        write_u32_le(&mut self.diagnostics_bytes, message_id.raw());
743        write_u32_le(&mut self.diagnostics_bytes, label_start);
744        write_u32_le(&mut self.diagnostics_bytes, label_count);
745        Ok(())
746    }
747
748    fn finish(self, sources: &SourceStore) -> Result<Vec<u8>, SnapshotWriteError> {
749        let Self {
750            options,
751            string_table,
752            source_phase_one,
753            source_meta,
754            nodes_bytes,
755            nodes_count,
756            edges_bytes,
757            edges_count,
758            tokens_bytes,
759            tokens_count,
760            trivia_bytes,
761            trivia_count,
762            diagnostics_bytes,
763            diagnostics_count,
764            diagnostic_labels_bytes,
765            diagnostic_labels_count,
766            roots,
767        } = self;
768
769        if roots.is_empty() {
770            return Err(SnapshotWriteError::MissingRoot);
771        }
772
773        debug_assert_eq!(source_meta.len(), source_phase_one.len());
774
775        // ── Sources section + optional source text data ──────────────
776        let mut sources_bytes = Vec::with_capacity(source_phase_one.len() * 32);
777        let mut sources_count: u32 = 0;
778        let include_source_text = options.include_source_text;
779        // Pre-size the source text data buffer from the actual
780        // per-source text lengths so `extend_from_slice` below does
781        // not grow the underlying `Vec`. Sum with checked arithmetic
782        // so callers see `SectionTooLarge` instead of a panic when
783        // the total exceeds the `u32` byte-offset domain.
784        let mut source_text_bytes: Vec<u8> = if include_source_text {
785            let mut total: u32 = 0;
786            for &phase_one in &source_phase_one {
787                let file = sources
788                    .get(phase_one)
789                    .ok_or(SnapshotWriteError::InvalidSourceId)?;
790                total = total
791                    .checked_add(file.len())
792                    .ok_or(SnapshotWriteError::SectionTooLarge)?;
793            }
794            Vec::with_capacity(total as usize)
795        } else {
796            Vec::new()
797        };
798        for (snapshot_local, (&phase_one, meta)) in
799            source_phase_one.iter().zip(source_meta.iter()).enumerate()
800        {
801            let snapshot_local =
802                checked_u32(snapshot_local).ok_or(SnapshotWriteError::TooManySources)?;
803            let file = sources
804                .get(phase_one)
805                .ok_or(SnapshotWriteError::InvalidSourceId)?;
806            let (text_source, text_offset, text_len) = if include_source_text {
807                let offset = checked_u32(source_text_bytes.len())
808                    .ok_or(SnapshotWriteError::SectionTooLarge)?;
809                let len = file.len();
810                source_text_bytes.extend_from_slice(file.text.as_bytes());
811                (snapshot_local, offset, len)
812            } else {
813                (NONE_REF, 0, 0)
814            };
815            write_u32_le(&mut sources_bytes, snapshot_local);
816            write_u32_le(&mut sources_bytes, meta.path.raw());
817            write_u32_le(&mut sources_bytes, meta.locale.raw());
818            write_u32_le(&mut sources_bytes, meta.message_id.raw());
819            write_u32_le(&mut sources_bytes, file.base_offset);
820            // SourceTextRef { source_id, offset, len }
821            write_u32_le(&mut sources_bytes, text_source);
822            write_u32_le(&mut sources_bytes, text_offset);
823            write_u32_le(&mut sources_bytes, text_len);
824            sources_count = sources_count
825                .checked_add(1)
826                .ok_or(SnapshotWriteError::TooManySources)?;
827        }
828
829        // ── Roots section ────────────────────────────────────────────
830        let roots_count = checked_u32(roots.len()).ok_or(SnapshotWriteError::TooManyRoots)?;
831        let mut roots_bytes = Vec::with_capacity(roots.len() * 16);
832        for root in &roots {
833            write_u32_le(&mut roots_bytes, root.root_node);
834            write_u32_le(&mut roots_bytes, root.source_local);
835            if options.include_diagnostics {
836                write_u32_le(&mut roots_bytes, root.diag_start);
837                write_u32_le(&mut roots_bytes, root.diag_count);
838            } else {
839                write_u32_le(&mut roots_bytes, 0);
840                write_u32_le(&mut roots_bytes, 0);
841            }
842        }
843
844        // ── String offsets and string data ───────────────────────────
845        let offsets = string_table.offsets();
846        let strings_count = checked_u32(offsets.len()).ok_or(SnapshotWriteError::TooManyStrings)?;
847        let mut string_offsets_bytes = Vec::with_capacity(offsets.len() * 8);
848        for entry in offsets {
849            write_u32_le(&mut string_offsets_bytes, entry.offset);
850            write_u32_le(&mut string_offsets_bytes, entry.len);
851        }
852        let string_data = string_table.data().to_vec();
853
854        // ── Assemble ─────────────────────────────────────────────────
855        let mut assembler = SnapshotAssembler::new();
856        assembler.push(EmittedSection {
857            kind: SectionKind::Roots,
858            bytes: roots_bytes,
859            count: roots_count,
860        });
861        assembler.push(EmittedSection {
862            kind: SectionKind::Sources,
863            bytes: sources_bytes,
864            count: sources_count,
865        });
866        assembler.push(EmittedSection {
867            kind: SectionKind::Nodes,
868            bytes: nodes_bytes,
869            count: nodes_count,
870        });
871        assembler.push(EmittedSection {
872            kind: SectionKind::Edges,
873            bytes: edges_bytes,
874            count: edges_count,
875        });
876        assembler.push(EmittedSection {
877            kind: SectionKind::Tokens,
878            bytes: tokens_bytes,
879            count: tokens_count,
880        });
881        // Trivia is omitted when empty (even when include_trivia=true).
882        if options.include_trivia && trivia_count > 0 {
883            assembler.push(EmittedSection {
884                kind: SectionKind::Trivia,
885                bytes: trivia_bytes,
886                count: trivia_count,
887            });
888        }
889        if options.include_diagnostics && diagnostics_count > 0 {
890            assembler.push(EmittedSection {
891                kind: SectionKind::Diagnostics,
892                bytes: diagnostics_bytes,
893                count: diagnostics_count,
894            });
895        }
896        if options.include_diagnostics && diagnostic_labels_count > 0 {
897            assembler.push(EmittedSection {
898                kind: SectionKind::DiagnosticLabels,
899                bytes: diagnostic_labels_bytes,
900                count: diagnostic_labels_count,
901            });
902        }
903        assembler.push(EmittedSection {
904            kind: SectionKind::StringOffsets,
905            bytes: string_offsets_bytes,
906            count: strings_count,
907        });
908        assembler.push(EmittedSection {
909            kind: SectionKind::StringData,
910            bytes: string_data,
911            count: 0,
912        });
913        // When `include_source_text = true`, every SourceRecord
914        // writes a non-`NONE_REF` text_source above, even for empty
915        // source text. The section must therefore be emitted (even
916        // empty) so the decoder's `text_source != NONE_REF` branch
917        // can resolve an in-range `offset + len` and `SourceView::text`
918        // can round-trip `Some("")` back instead of returning `None`.
919        if include_source_text {
920            assembler.push(EmittedSection {
921                kind: SectionKind::SourceTextData,
922                bytes: source_text_bytes,
923                count: 0,
924            });
925        }
926
927        assembler.finish()
928    }
929
930    // ── id allocation helpers ───────────────────────────────────────
931
932    fn next_node_id(&mut self) -> Result<u32, SnapshotWriteError> {
933        let id = self.nodes_count;
934        self.nodes_count = self
935            .nodes_count
936            .checked_add(1)
937            .ok_or(SnapshotWriteError::TooManyNodes)?;
938        Ok(id)
939    }
940
941    fn next_edge_id(&mut self) -> Result<u32, SnapshotWriteError> {
942        let id = self.edges_count;
943        self.edges_count = self
944            .edges_count
945            .checked_add(1)
946            .ok_or(SnapshotWriteError::TooManyEdges)?;
947        Ok(id)
948    }
949
950    fn next_token_id(&mut self) -> Result<u32, SnapshotWriteError> {
951        let id = self.tokens_count;
952        self.tokens_count = self
953            .tokens_count
954            .checked_add(1)
955            .ok_or(SnapshotWriteError::TooManyTokens)?;
956        Ok(id)
957    }
958
959    fn next_trivia_id(&mut self) -> Result<u32, SnapshotWriteError> {
960        let id = self.trivia_count;
961        self.trivia_count = self
962            .trivia_count
963            .checked_add(1)
964            .ok_or(SnapshotWriteError::TooManyTrivia)?;
965        Ok(id)
966    }
967
968    fn next_diagnostic_id(&mut self) -> Result<u32, SnapshotWriteError> {
969        let id = self.diagnostics_count;
970        self.diagnostics_count = self
971            .diagnostics_count
972            .checked_add(1)
973            .ok_or(SnapshotWriteError::TooManyDiagnostics)?;
974        Ok(id)
975    }
976
977    fn next_diagnostic_label_id(&mut self) -> Result<u32, SnapshotWriteError> {
978        let id = self.diagnostic_labels_count;
979        self.diagnostic_labels_count = self
980            .diagnostic_labels_count
981            .checked_add(1)
982            .ok_or(SnapshotWriteError::TooManyDiagnosticLabels)?;
983        Ok(id)
984    }
985}
986
987#[allow(dead_code)] // accept Phase 1 catalog sentinel so the writer can
988                    // intern catalog message strings later if needed.
989fn diagnostic_catalog_str(code: DiagnosticCode) -> &'static str {
990    code.static_message()
991}
992
993#[allow(dead_code)]
994const _ASSERT_MESSAGE_REF_CATALOG: u32 = MESSAGE_REF_CATALOG;