Skip to main content

forma_ir/
dump.rs

1//! Human-readable text dump of an IR module for debugging and diffing.
2//!
3//! Produces a deterministic, line-oriented representation of the opcode
4//! stream that can be compared across builds or used to diagnose hydration
5//! mismatches.
6
7use crate::format::Opcode;
8use crate::parser::IrModule;
9use crate::walker::{read_tag_with_attrs, read_u16, read_u32};
10use std::fmt::Write;
11
12// ---------------------------------------------------------------------------
13// Public API
14// ---------------------------------------------------------------------------
15
16/// Produce a deterministic text dump of an IR module for debugging and diffing.
17pub fn dump_ir(module: &IrModule) -> String {
18    let mut out = String::with_capacity(module.opcodes.len() * 8);
19
20    // Header line
21    writeln!(
22        out,
23        "FMIR v{}  source_hash={:016x}  strings={}  slots={}  islands={}",
24        module.header.version,
25        module.header.source_hash,
26        module.strings.len(),
27        module.slots.len(),
28        module.islands.len(),
29    )
30    .unwrap();
31
32    // Walk opcodes — must advance pos by the same amounts as walker.rs
33    let ops = &module.opcodes;
34    let strings = &module.strings;
35    let len = ops.len();
36    let mut pos: usize = 0;
37
38    while pos < len {
39        let offset = pos;
40        let opcode = match Opcode::from_byte(ops[pos]) {
41            Ok(op) => op,
42            Err(_) => {
43                writeln!(out, "{:04x}: UNKNOWN       0x{:02x}", offset, ops[pos]).unwrap();
44                pos += 1;
45                continue;
46            }
47        };
48        pos += 1; // advance past opcode byte
49
50        match opcode {
51            Opcode::OpenTag => {
52                let (tag_str_idx, attrs, new_pos) = match read_tag_with_attrs(ops, pos, strings) {
53                    Ok(v) => v,
54                    Err(e) => {
55                        writeln!(out, "{:04x}: OPEN_TAG      <error: {}>", offset, e).unwrap();
56                        break;
57                    }
58                };
59                let tag = strings.get(tag_str_idx).unwrap_or("?");
60                write!(
61                    out,
62                    "{:04x}: OPEN_TAG      \"{}\" attrs={}",
63                    offset,
64                    tag,
65                    attrs.len()
66                )
67                .unwrap();
68                if !attrs.is_empty() {
69                    write!(out, " [").unwrap();
70                    for (i, (key, val)) in attrs.iter().enumerate() {
71                        if i > 0 {
72                            write!(out, ", ").unwrap();
73                        }
74                        write!(out, "(\"{}\",\"{}\")", key, val).unwrap();
75                    }
76                    write!(out, "]").unwrap();
77                }
78                writeln!(out).unwrap();
79                pos = new_pos;
80            }
81
82            Opcode::CloseTag => {
83                let str_idx = match read_u32(ops, pos) {
84                    Ok(v) => v,
85                    Err(e) => {
86                        writeln!(out, "{:04x}: CLOSE_TAG     <error: {}>", offset, e).unwrap();
87                        break;
88                    }
89                };
90                pos += 4;
91                let tag = strings.get(str_idx).unwrap_or("?");
92                writeln!(out, "{:04x}: CLOSE_TAG     \"{}\"", offset, tag).unwrap();
93            }
94
95            Opcode::VoidTag => {
96                let (tag_str_idx, attrs, new_pos) = match read_tag_with_attrs(ops, pos, strings) {
97                    Ok(v) => v,
98                    Err(e) => {
99                        writeln!(out, "{:04x}: VOID_TAG      <error: {}>", offset, e).unwrap();
100                        break;
101                    }
102                };
103                let tag = strings.get(tag_str_idx).unwrap_or("?");
104                write!(
105                    out,
106                    "{:04x}: VOID_TAG      \"{}\" attrs={}",
107                    offset,
108                    tag,
109                    attrs.len()
110                )
111                .unwrap();
112                if !attrs.is_empty() {
113                    write!(out, " [").unwrap();
114                    for (i, (key, val)) in attrs.iter().enumerate() {
115                        if i > 0 {
116                            write!(out, ", ").unwrap();
117                        }
118                        write!(out, "(\"{}\",\"{}\")", key, val).unwrap();
119                    }
120                    write!(out, "]").unwrap();
121                }
122                writeln!(out).unwrap();
123                pos = new_pos;
124            }
125
126            Opcode::Text => {
127                let str_idx = match read_u32(ops, pos) {
128                    Ok(v) => v,
129                    Err(e) => {
130                        writeln!(out, "{:04x}: TEXT          <error: {}>", offset, e).unwrap();
131                        break;
132                    }
133                };
134                pos += 4;
135                let text = strings.get(str_idx).unwrap_or("?");
136                writeln!(out, "{:04x}: TEXT          \"{}\"", offset, text).unwrap();
137            }
138
139            Opcode::DynText => {
140                // slot_id(u16) + marker_id(u16)
141                let slot_id = match read_u16(ops, pos) {
142                    Ok(v) => v,
143                    Err(e) => {
144                        writeln!(out, "{:04x}: DYN_TEXT      <error: {}>", offset, e).unwrap();
145                        break;
146                    }
147                };
148                let marker_id = match read_u16(ops, pos + 2) {
149                    Ok(v) => v,
150                    Err(e) => {
151                        writeln!(out, "{:04x}: DYN_TEXT      <error: {}>", offset, e).unwrap();
152                        break;
153                    }
154                };
155                pos += 4;
156                writeln!(
157                    out,
158                    "{:04x}: DYN_TEXT      slot={} marker=t{}",
159                    offset, slot_id, marker_id
160                )
161                .unwrap();
162            }
163
164            Opcode::DynAttr => {
165                // attr_str_idx(u32) + slot_id(u16) = 6 bytes
166                let attr_str_idx = match read_u32(ops, pos) {
167                    Ok(v) => v,
168                    Err(e) => {
169                        writeln!(out, "{:04x}: DYN_ATTR      <error: {}>", offset, e).unwrap();
170                        break;
171                    }
172                };
173                let slot_id = match read_u16(ops, pos + 4) {
174                    Ok(v) => v,
175                    Err(e) => {
176                        writeln!(out, "{:04x}: DYN_ATTR      <error: {}>", offset, e).unwrap();
177                        break;
178                    }
179                };
180                pos += 6;
181                let attr_name = strings.get(attr_str_idx).unwrap_or("?");
182                writeln!(
183                    out,
184                    "{:04x}: DYN_ATTR      \"{}\" slot={}",
185                    offset, attr_name, slot_id
186                )
187                .unwrap();
188            }
189
190            Opcode::ShowIf => {
191                // slot_id(2) + then_len(4) + else_len(4) = 10 bytes
192                let slot_id = match read_u16(ops, pos) {
193                    Ok(v) => v,
194                    Err(e) => {
195                        writeln!(out, "{:04x}: SHOW_IF       <error: {}>", offset, e).unwrap();
196                        break;
197                    }
198                };
199                let then_len = match read_u32(ops, pos + 2) {
200                    Ok(v) => v,
201                    Err(e) => {
202                        writeln!(out, "{:04x}: SHOW_IF       <error: {}>", offset, e).unwrap();
203                        break;
204                    }
205                };
206                let else_len = match read_u32(ops, pos + 6) {
207                    Ok(v) => v,
208                    Err(e) => {
209                        writeln!(out, "{:04x}: SHOW_IF       <error: {}>", offset, e).unwrap();
210                        break;
211                    }
212                };
213                pos += 10;
214                writeln!(
215                    out,
216                    "{:04x}: SHOW_IF       slot={} then_len={} else_len={}",
217                    offset, slot_id, then_len, else_len
218                )
219                .unwrap();
220            }
221
222            Opcode::ShowElse => {
223                // No operands
224                writeln!(out, "{:04x}: SHOW_ELSE", offset).unwrap();
225            }
226
227            Opcode::Switch => {
228                // slot_id(2) + case_count(2) = 4 bytes header
229                let slot_id = match read_u16(ops, pos) {
230                    Ok(v) => v,
231                    Err(e) => {
232                        writeln!(out, "{:04x}: SWITCH        <error: {}>", offset, e).unwrap();
233                        break;
234                    }
235                };
236                let case_count = match read_u16(ops, pos + 2) {
237                    Ok(v) => v,
238                    Err(e) => {
239                        writeln!(out, "{:04x}: SWITCH        <error: {}>", offset, e).unwrap();
240                        break;
241                    }
242                };
243                pos += 4;
244                // Skip case headers: case_count x (val_str_idx(4) + body_len(4)) = 8 bytes each
245                pos += (case_count as usize) * 8;
246                writeln!(
247                    out,
248                    "{:04x}: SWITCH        slot={} cases={}",
249                    offset, slot_id, case_count
250                )
251                .unwrap();
252            }
253
254            Opcode::List => {
255                // slot_id(2) + item_slot_id(2) + body_len(4) = 8 bytes
256                let slot_id = match read_u16(ops, pos) {
257                    Ok(v) => v,
258                    Err(e) => {
259                        writeln!(out, "{:04x}: LIST          <error: {}>", offset, e).unwrap();
260                        break;
261                    }
262                };
263                let item_slot_id = match read_u16(ops, pos + 2) {
264                    Ok(v) => v,
265                    Err(e) => {
266                        writeln!(out, "{:04x}: LIST          <error: {}>", offset, e).unwrap();
267                        break;
268                    }
269                };
270                let body_len = match read_u32(ops, pos + 4) {
271                    Ok(v) => v,
272                    Err(e) => {
273                        writeln!(out, "{:04x}: LIST          <error: {}>", offset, e).unwrap();
274                        break;
275                    }
276                };
277                pos += 8;
278                writeln!(
279                    out,
280                    "{:04x}: LIST          slot={} item_slot={} body_len={}",
281                    offset, slot_id, item_slot_id, body_len
282                )
283                .unwrap();
284            }
285
286            Opcode::IslandStart => {
287                // island_id(u16)
288                let island_id = match read_u16(ops, pos) {
289                    Ok(v) => v,
290                    Err(e) => {
291                        writeln!(out, "{:04x}: ISLAND_START  <error: {}>", offset, e).unwrap();
292                        break;
293                    }
294                };
295                pos += 2;
296                writeln!(out, "{:04x}: ISLAND_START  id={}", offset, island_id).unwrap();
297            }
298
299            Opcode::IslandEnd => {
300                // island_id(u16)
301                let island_id = match read_u16(ops, pos) {
302                    Ok(v) => v,
303                    Err(e) => {
304                        writeln!(out, "{:04x}: ISLAND_END    <error: {}>", offset, e).unwrap();
305                        break;
306                    }
307                };
308                pos += 2;
309                writeln!(out, "{:04x}: ISLAND_END    id={}", offset, island_id).unwrap();
310            }
311
312            Opcode::TryStart => {
313                // fallback_len(4) = 4 bytes
314                let fallback_len = match read_u32(ops, pos) {
315                    Ok(v) => v,
316                    Err(e) => {
317                        writeln!(out, "{:04x}: TRY_START     <error: {}>", offset, e).unwrap();
318                        break;
319                    }
320                };
321                pos += 4;
322                writeln!(
323                    out,
324                    "{:04x}: TRY_START     fallback_len={}",
325                    offset, fallback_len
326                )
327                .unwrap();
328            }
329
330            Opcode::Fallback => {
331                // No operands
332                writeln!(out, "{:04x}: FALLBACK", offset).unwrap();
333            }
334
335            Opcode::Preload => {
336                // resource_type(1) + url_str_idx(4) = 5 bytes
337                if pos >= ops.len() {
338                    writeln!(
339                        out,
340                        "{:04x}: PRELOAD       <error: buffer too short>",
341                        offset
342                    )
343                    .unwrap();
344                    break;
345                }
346                let resource_type = ops[pos];
347                let url_str_idx = match read_u32(ops, pos + 1) {
348                    Ok(v) => v,
349                    Err(e) => {
350                        writeln!(out, "{:04x}: PRELOAD       <error: {}>", offset, e).unwrap();
351                        break;
352                    }
353                };
354                pos += 5;
355                let url = strings.get(url_str_idx).unwrap_or("?");
356                writeln!(
357                    out,
358                    "{:04x}: PRELOAD       type={} url=\"{}\"",
359                    offset, resource_type, url
360                )
361                .unwrap();
362            }
363
364            Opcode::Comment => {
365                // str_idx(u32)
366                let str_idx = match read_u32(ops, pos) {
367                    Ok(v) => v,
368                    Err(e) => {
369                        writeln!(out, "{:04x}: COMMENT       <error: {}>", offset, e).unwrap();
370                        break;
371                    }
372                };
373                pos += 4;
374                let text = strings.get(str_idx).unwrap_or("?");
375                writeln!(out, "{:04x}: COMMENT       \"{}\"", offset, text).unwrap();
376            }
377
378            Opcode::ListItemKey => {
379                // key_str_idx(u32)
380                let str_idx = match read_u32(ops, pos) {
381                    Ok(v) => v,
382                    Err(e) => {
383                        writeln!(out, "{:04x}: LIST_ITEM_KEY <error: {}>", offset, e).unwrap();
384                        break;
385                    }
386                };
387                pos += 4;
388                let key = strings.get(str_idx).unwrap_or("?");
389                writeln!(out, "{:04x}: LIST_ITEM_KEY \"{}\"", offset, key).unwrap();
390            }
391
392            Opcode::Prop => {
393                // src_slot_id(u16) + prop_str_idx(u32) + target_slot_id(u16) = 8 bytes
394                let src = match read_u16(ops, pos) {
395                    Ok(v) => v,
396                    Err(e) => {
397                        writeln!(out, "{:04x}: PROP <error: {}>", offset, e).unwrap();
398                        break;
399                    }
400                };
401                let prop_idx = match read_u32(ops, pos + 2) {
402                    Ok(v) => v,
403                    Err(e) => {
404                        writeln!(out, "{:04x}: PROP <error: {}>", offset, e).unwrap();
405                        break;
406                    }
407                };
408                let target = match read_u16(ops, pos + 6) {
409                    Ok(v) => v,
410                    Err(e) => {
411                        writeln!(out, "{:04x}: PROP <error: {}>", offset, e).unwrap();
412                        break;
413                    }
414                };
415                pos += 8;
416                let prop_name = strings.get(prop_idx).unwrap_or("?");
417                writeln!(
418                    out,
419                    "{:04x}: PROP slot[{}].\"{}\" -> slot[{}]",
420                    offset, src, prop_name, target
421                )
422                .unwrap();
423            }
424        }
425    }
426
427    out
428}
429
430// ---------------------------------------------------------------------------
431// Tests
432// ---------------------------------------------------------------------------
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use crate::parser::test_helpers::{
438        build_minimal_ir, encode_close_tag, encode_open_tag, encode_text, encode_void_tag,
439    };
440
441    // -- Encoding helpers local to dump tests --------------------------------
442
443    /// Encode a DYN_TEXT opcode: opcode(1) + slot_id(2) + marker_id(2)
444    fn encode_dyn_text(slot_id: u16, marker_id: u16) -> Vec<u8> {
445        let mut buf = Vec::new();
446        buf.push(0x05); // Opcode::DynText
447        buf.extend_from_slice(&slot_id.to_le_bytes());
448        buf.extend_from_slice(&marker_id.to_le_bytes());
449        buf
450    }
451
452    /// Helper: build an IrModule from strings/slots/opcodes and dump it.
453    fn dump_static(strings: &[&str], opcodes: &[u8]) -> String {
454        let data = build_minimal_ir(strings, &[], opcodes, &[]);
455        let module = IrModule::parse(&data).unwrap();
456        dump_ir(&module)
457    }
458
459    // -- Test 1: dump_static_div --------------------------------------------
460
461    #[test]
462    fn dump_static_div() {
463        // <div class="container">Hello</div>
464        // strings: 0="div", 1="class", 2="container", 3="Hello"
465        let mut opcodes = Vec::new();
466        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
467        opcodes.extend_from_slice(&encode_text(3));
468        opcodes.extend_from_slice(&encode_close_tag(0));
469
470        let output = dump_static(&["div", "class", "container", "Hello"], &opcodes);
471
472        assert!(output.contains("OPEN_TAG"), "should contain OPEN_TAG");
473        assert!(output.contains("\"div\""), "should contain tag name");
474        assert!(output.contains("\"class\""), "should contain attr key");
475        assert!(output.contains("\"container\""), "should contain attr val");
476        assert!(output.contains("TEXT"), "should contain TEXT");
477        assert!(output.contains("\"Hello\""), "should contain text content");
478        assert!(output.contains("CLOSE_TAG"), "should contain CLOSE_TAG");
479    }
480
481    // -- Test 2: dump_with_dyn_text -----------------------------------------
482
483    #[test]
484    fn dump_with_dyn_text() {
485        // DYN_TEXT slot=0 marker=t0
486        // strings: 0="greeting" (slot name)
487        // slot decl: slot_id=0, name_str_idx=0, type=Text(0x01)
488        let opcodes = encode_dyn_text(0, 0);
489
490        let data = build_minimal_ir(&["greeting"], &[(0, 0, 0x01, 0x00, &[])], &opcodes, &[]);
491        let module = IrModule::parse(&data).unwrap();
492        let output = dump_ir(&module);
493
494        assert!(output.contains("DYN_TEXT"), "should contain DYN_TEXT");
495        assert!(output.contains("slot=0"), "should contain slot=0");
496        assert!(output.contains("marker=t0"), "should contain marker=t0");
497    }
498
499    // -- Test 3: dump_header_line -------------------------------------------
500
501    #[test]
502    fn dump_header_line() {
503        let opcodes = encode_text(0);
504        let output = dump_static(&["Hello"], &opcodes);
505
506        let first_line = output.lines().next().unwrap();
507        assert!(
508            first_line.starts_with("FMIR v2"),
509            "first line should start with FMIR v2, got: {}",
510            first_line
511        );
512        assert!(
513            first_line.contains("source_hash="),
514            "first line should contain source_hash="
515        );
516        assert!(
517            first_line.contains("strings="),
518            "first line should contain strings="
519        );
520        assert!(
521            first_line.contains("slots="),
522            "first line should contain slots="
523        );
524        assert!(
525            first_line.contains("islands="),
526            "first line should contain islands="
527        );
528    }
529
530    // -- Test 4: dump_deterministic -----------------------------------------
531
532    #[test]
533    fn dump_deterministic() {
534        let mut opcodes = Vec::new();
535        opcodes.extend_from_slice(&encode_open_tag(0, &[(1, 2)]));
536        opcodes.extend_from_slice(&encode_text(3));
537        opcodes.extend_from_slice(&encode_close_tag(0));
538
539        let data = build_minimal_ir(&["div", "class", "container", "Hello"], &[], &opcodes, &[]);
540        let module = IrModule::parse(&data).unwrap();
541
542        let dump1 = dump_ir(&module);
543        let dump2 = dump_ir(&module);
544
545        assert_eq!(
546            dump1, dump2,
547            "same IR dumped twice must produce identical output"
548        );
549    }
550
551    // -- Test 5: dump_void_tag ----------------------------------------------
552
553    #[test]
554    fn dump_void_tag() {
555        // <input type="email">
556        // strings: 0="input", 1="type", 2="email"
557        let opcodes = encode_void_tag(0, &[(1, 2)]);
558        let output = dump_static(&["input", "type", "email"], &opcodes);
559
560        assert!(output.contains("VOID_TAG"), "should contain VOID_TAG");
561        assert!(output.contains("\"input\""), "should contain tag name");
562        assert!(output.contains("\"type\""), "should contain attr key");
563        assert!(output.contains("\"email\""), "should contain attr val");
564    }
565}