Skip to main content

ergo_sbe/
resolve.rs

1//! Offset / block-length resolution and schema integrity checks on [`Ir`].
2//!
3//! Called automatically by [`crate::parse`] / [`crate::parse_file`]. Direct use
4//! is only needed if you build an [`Ir`] by hand.
5//!
6//! Passes: unique template ids, `sinceVersion` ≤ schema version, default
7//! null/min/max for primitives, sequential offsets, block lengths for
8//! composites/messages (groups/var-data live in the tail).
9
10use crate::ir::{Ir, PrimitiveType, Signal, Token};
11
12/// Errors raised during schema resolution/validation.
13///
14/// Each variant carries an optional [`miette::NamedSource`] for source-code
15/// rendering in diagnostics. When the resolver is invoked from the XML
16/// parser the source text is attached; direct callers may leave it as
17/// `None`.
18#[derive(Debug, thiserror::Error, miette::Diagnostic)]
19pub enum ResolveError {
20    /// Two messages share the same template ID.
21    #[error("duplicate template id {id} for message {name}")]
22    #[diagnostic(code(ergo_sbe::resolve::duplicate_template_id))]
23    #[diagnostic(help("each message must have a unique template id"))]
24    DuplicateTemplateId {
25        /// The duplicate ID.
26        id: u16,
27        /// Message name.
28        name: String,
29        /// Source document for miette span rendering.
30        #[source_code]
31        source_code: Option<miette::NamedSource<String>>,
32        /// Span pointing at the first definition.
33        #[label("first defined here")]
34        first_label: Option<miette::SourceSpan>,
35        /// Span pointing at the duplicate definition.
36        #[label("duplicate definition")]
37        second_label: Option<miette::SourceSpan>,
38    },
39    /// A referenced type was not found in the registry.
40    #[error("unknown type reference {name}")]
41    #[diagnostic(code(ergo_sbe::resolve::unknown_type))]
42    #[diagnostic(help("ensure the type is defined in the schema or an include"))]
43    UnknownType {
44        /// Type name.
45        name: String,
46        /// Source document for miette span rendering.
47        #[source_code]
48        source_code: Option<miette::NamedSource<String>>,
49        /// Span pointing at the reference.
50        #[label("unknown type")]
51        span: Option<miette::SourceSpan>,
52    },
53    /// Field offsets are overlapping or unaligned.
54    #[error("overlapping offsets or invalid alignment at offset {offset}")]
55    #[diagnostic(code(ergo_sbe::resolve::invalid_offset))]
56    #[diagnostic(help("check explicit offset attributes for clashes"))]
57    InvalidOffset {
58        /// The invalid offset.
59        offset: usize,
60        /// Source document for miette span rendering.
61        #[source_code]
62        source_code: Option<miette::NamedSource<String>>,
63        /// Span pointing at the overlap.
64        #[label("overlap here")]
65        span: Option<miette::SourceSpan>,
66    },
67    /// A composite type definition is empty.
68    #[error("composite {name} has no fields")]
69    #[diagnostic(code(ergo_sbe::resolve::empty_composite))]
70    #[diagnostic(help("add at least one <type> member to the composite"))]
71    EmptyComposite {
72        /// Composite name.
73        name: String,
74        /// Source document for miette span rendering.
75        #[source_code]
76        source_code: Option<miette::NamedSource<String>>,
77        /// Span pointing at the empty composite.
78        #[label("empty composite")]
79        span: Option<miette::SourceSpan>,
80    },
81    /// A field or message has a sinceVersion greater than the schema version.
82    #[error("sinceVersion {version} exceeds schema version {schema_version} for {name}")]
83    #[diagnostic(code(ergo_sbe::resolve::since_version_beyond))]
84    #[diagnostic(help("the sinceVersion must be <= the schema version"))]
85    SinceVersionBeyondSchema {
86        /// The sinceVersion value found.
87        version: u16,
88        /// The schema version.
89        schema_version: u16,
90        /// The token name.
91        name: String,
92        /// Source document for miette span rendering.
93        #[source_code]
94        source_code: Option<miette::NamedSource<String>>,
95        /// Span pointing at the token.
96        #[label("sinceVersion too high")]
97        span: Option<miette::SourceSpan>,
98    },
99    /// A declared message or group block length cannot contain its fixed fields.
100    #[error("declared blockLength {declared} is smaller than required {required} for {name}")]
101    #[diagnostic(code(ergo_sbe::resolve::block_length_too_short))]
102    #[diagnostic(help("increase blockLength to at least the end offset of the final fixed field"))]
103    BlockLengthTooShort {
104        /// Message or group name.
105        name: String,
106        /// Schema-declared block length.
107        declared: usize,
108        /// Minimum block length required by the fixed fields.
109        required: usize,
110        /// Source document for miette span rendering.
111        #[source_code]
112        source_code: Option<miette::NamedSource<String>>,
113        /// Span pointing at the declaration, when available.
114        #[label("insufficient blockLength")]
115        span: Option<miette::SourceSpan>,
116    },
117}
118
119impl ResolveError {
120    /// Take the source code field, leaving `None` in its place.
121    /// Used when wrapping this error in [`ParseError::Resolve`] to
122    /// transfer the source code to the outer error's `#[source_code]`.
123    pub(crate) fn take_source_code(&mut self) -> Option<miette::NamedSource<String>> {
124        match self {
125            ResolveError::DuplicateTemplateId { source_code, .. } => source_code.take(),
126            ResolveError::UnknownType { source_code, .. } => source_code.take(),
127            ResolveError::InvalidOffset { source_code, .. } => source_code.take(),
128            ResolveError::EmptyComposite { source_code, .. } => source_code.take(),
129            ResolveError::SinceVersionBeyondSchema { source_code, .. } => source_code.take(),
130            ResolveError::BlockLengthTooShort { source_code, .. } => source_code.take(),
131        }
132    }
133
134    /// Take the label spans, leaving `None` in their place.
135    /// Returns `(primary, secondary)` for forwarding to [`ParseError::Resolve`].
136    pub(crate) fn take_spans(
137        &mut self,
138    ) -> (Option<miette::SourceSpan>, Option<miette::SourceSpan>) {
139        match self {
140            ResolveError::DuplicateTemplateId {
141                first_label,
142                second_label,
143                ..
144            } => (first_label.take(), second_label.take()),
145            ResolveError::UnknownType { span, .. }
146            | ResolveError::InvalidOffset { span, .. }
147            | ResolveError::EmptyComposite { span, .. }
148            | ResolveError::SinceVersionBeyondSchema { span, .. }
149            | ResolveError::BlockLengthTooShort { span, .. } => (span.take(), None),
150        }
151    }
152}
153
154/// Resolve offsets, block lengths, and default null/min/max on `ir` in place.
155///
156/// Already invoked by [`crate::parse`]. Pass `source` for miette snippets on
157/// [`ResolveError`].
158///
159/// # Errors
160///
161/// Duplicate template ids, unknown types, bad offsets, empty composites,
162/// or `sinceVersion` beyond schema version.
163pub fn resolve_schema(ir: &mut Ir, source: Option<&str>) -> Result<(), ResolveError> {
164    let src = source.map(|s| miette::NamedSource::new("schema.xml", s.to_owned()));
165
166    {
167        let mut seen_ids: std::collections::HashMap<u16, (&str, Option<std::ops::Range<usize>>)> =
168            std::collections::HashMap::new();
169        for token in &ir.tokens {
170            if token.signal == Signal::BeginMessage {
171                if let Some(id) = token.id {
172                    if let Some((_prev_name, prev_span)) =
173                        seen_ids.insert(id, (&token.name, token.span.clone()))
174                    {
175                        return Err(ResolveError::DuplicateTemplateId {
176                            id,
177                            name: token.name.clone(),
178                            source_code: src.clone(),
179                            first_label: prev_span.map(miette::SourceSpan::from),
180                            second_label: token.span.clone().map(miette::SourceSpan::from),
181                        });
182                    }
183                }
184            }
185        }
186    }
187
188    for token in &ir.tokens {
189        let sv = token.encoding.since_version;
190        if sv > ir.version {
191            return Err(ResolveError::SinceVersionBeyondSchema {
192                version: sv,
193                schema_version: ir.version,
194                name: token.name.clone(),
195                source_code: src.clone(),
196                span: token.span.clone().map(miette::SourceSpan::from),
197            });
198        }
199    }
200
201    for token in &mut ir.tokens {
202        if let Some(prim) = token.encoding.primitive_type {
203            if token.encoding.null_value.is_none() {
204                token.encoding.null_value = default_null(prim);
205            }
206            if token.encoding.min_value.is_none() {
207                token.encoding.min_value = default_min(prim);
208            }
209            if token.encoding.max_value.is_none() {
210                token.encoding.max_value = default_max(prim);
211            }
212        }
213    }
214
215    let mut i = 0;
216    while i < ir.tokens.len() {
217        match ir.tokens[i].signal {
218            Signal::BeginComposite => {
219                let end_idx =
220                    find_matching_end(&ir.tokens, i, Signal::BeginComposite, Signal::EndComposite);
221                resolve_composite_offsets(&mut ir.tokens[i..=end_idx], &src)?;
222                i = end_idx + 1;
223            }
224            Signal::BeginMessage => {
225                let end_idx =
226                    find_matching_end(&ir.tokens, i, Signal::BeginMessage, Signal::EndMessage);
227                resolve_message_offsets(&mut ir.tokens[i..=end_idx], &src)?;
228                i = end_idx + 1;
229            }
230            _ => {
231                i += 1;
232            }
233        }
234    }
235
236    Ok(())
237}
238
239fn find_matching_end(tokens: &[Token], start: usize, begin: Signal, end: Signal) -> usize {
240    let mut depth = 1;
241    for j in (start + 1)..tokens.len() {
242        if tokens[j].signal == begin {
243            depth += 1;
244        } else if tokens[j].signal == end {
245            depth -= 1;
246            if depth == 0 {
247                return j;
248            }
249        }
250    }
251    tokens.len() - 1
252}
253
254fn get_token_block_size(tokens: &[Token], start: usize) -> (usize, usize) {
255    match tokens[start].signal {
256        Signal::BeginField => {
257            let end_idx = find_matching_end(tokens, start, Signal::BeginField, Signal::EndField);
258            // Variable-length fields (varData composite members) don't occupy fixed block space.
259            if tokens[start].encoding.is_variable_length {
260                return (0, end_idx + 1);
261            }
262            // Size is the size of the contents
263            // constant fields return 0 size (no wire footprint), add schema validation if runtime constants ever change
264            if tokens[start].encoding.presence == crate::ir::Presence::Constant {
265                return (0, end_idx + 1);
266            }
267            if end_idx > start + 1 {
268                // Nested composite, enum, set etc.
269                let mut size = 0;
270                let mut j = start + 1;
271                while j < end_idx {
272                    let (s, next_j) = get_token_block_size(tokens, j);
273                    size += s;
274                    j = next_j;
275                }
276                (size, end_idx + 1)
277            } else {
278                let count = tokens[start].encoding.length.unwrap_or(1);
279                let size = tokens[start]
280                    .encoding
281                    .primitive_type
282                    .map_or(0, |p| p.size())
283                    * count;
284                (size, end_idx + 1)
285            }
286        }
287        Signal::BeginComposite => {
288            let end_idx =
289                find_matching_end(tokens, start, Signal::BeginComposite, Signal::EndComposite);
290            let mut current = 0;
291            let mut size = 0;
292            let mut j = start + 1;
293            while j < end_idx {
294                let (s, next_j) = get_token_block_size(tokens, j);
295                // Honour explicit offsets on composite members (e.g. offset="8").
296                // Without this, composites with gaps (padding between members)
297                // report a size that is too small, which cascades into wrong
298                // message block_length and encoded-length constants.
299                let member_offset = tokens[j].encoding.offset.unwrap_or(current);
300                current = member_offset + s;
301                size = size.max(current);
302                j = next_j;
303            }
304            (size, end_idx + 1)
305        }
306        Signal::BeginEnum | Signal::BeginSet => {
307            let end_idx = find_matching_end(
308                tokens,
309                start,
310                tokens[start].signal,
311                match tokens[start].signal {
312                    Signal::BeginEnum => Signal::EndEnum,
313                    _ => Signal::EndSet,
314                },
315            );
316            let size = tokens[start]
317                .encoding
318                .primitive_type
319                .map_or(0, |p| p.size());
320            (size, end_idx + 1)
321        }
322        // composite children always Begin* signals (Encoding never Composite), add Composite check if schema validation weakens
323        // tokens are nested inside BeginEnum/BeginSet/EndEnum/EndSet and
324        // never appear as direct children.
325        _ => (0, start + 1),
326    }
327}
328
329#[allow(clippy::only_used_in_recursion)]
330fn resolve_composite_offsets(
331    tokens: &mut [Token],
332    src: &Option<miette::NamedSource<String>>,
333) -> Result<(), ResolveError> {
334    let mut current_offset = 0;
335    let mut i = 1; // skip BeginComposite
336    let end_limit = tokens.len() - 1; // skip EndComposite
337
338    while i < end_limit {
339        let (size, next_i) = get_token_block_size(tokens, i);
340
341        let resolved_offset = if let Some(off) = tokens[i].encoding.offset {
342            off
343        } else {
344            current_offset
345        };
346        if resolved_offset < current_offset {
347            return Err(ResolveError::InvalidOffset {
348                offset: resolved_offset,
349                source_code: src.clone(),
350                span: tokens[i].span.clone().map(miette::SourceSpan::from),
351            });
352        }
353
354        tokens[i].encoding.offset = Some(resolved_offset);
355
356        // Inlined nested composites (via `<ref type="Composite"/>` or
357        // nested type expansion) store their wire size on BeginComposite
358        // `encoding.offset`. Codegen reads that as `MemberType::Composite.size`.
359        // Cloned registry tokens often still have `offset: None` here — resolve
360        // the nested layout so size is never left as 0 (`read_bytes::<0>`).
361        if tokens[i].signal == Signal::BeginField && i + 1 < next_i {
362            if tokens[i + 1].signal == Signal::BeginComposite {
363                let nested_end =
364                    find_matching_end(tokens, i + 1, Signal::BeginComposite, Signal::EndComposite);
365                resolve_composite_offsets(&mut tokens[i + 1..=nested_end], src)?;
366            }
367        }
368
369        // For nested composite tokens, we need to cascade offsets relative to parent if needed,
370        // but inside SBE all member offsets are absolute or sequential.
371        current_offset = resolved_offset + size;
372        i = next_i;
373    }
374
375    let composite_size = current_offset;
376    tokens[0].encoding.offset = Some(composite_size);
377    Ok(())
378}
379
380fn resolve_message_offsets(
381    tokens: &mut [Token],
382    src: &Option<miette::NamedSource<String>>,
383) -> Result<(), ResolveError> {
384    let mut current_offset = 0;
385    let mut i = 1; // skip BeginMessage
386    let end_limit = tokens.len() - 1; // skip EndMessage
387
388    while i < end_limit {
389        // If we hit variable-length tail elements (Signal::BeginGroup, Signal::BeginVarData),
390        // we stop sequential fixed offset calculation because they are placed in the tail.
391        if tokens[i].signal == Signal::BeginGroup || tokens[i].signal == Signal::BeginVarData {
392            // Resolve nested group/var-data offsets starting at 0
393            if tokens[i].signal == Signal::BeginGroup {
394                let end_idx = find_matching_end(tokens, i, Signal::BeginGroup, Signal::EndGroup);
395                resolve_group_offsets(&mut tokens[i..=end_idx], src)?;
396                i = end_idx + 1;
397            } else {
398                let end_idx =
399                    find_matching_end(tokens, i, Signal::BeginVarData, Signal::EndVarData);
400                resolve_vardata_offsets(&mut tokens[i..=end_idx], src)?;
401                i = end_idx + 1;
402            }
403            continue;
404        }
405
406        let (size, next_i) = get_token_block_size(tokens, i);
407
408        let resolved_offset = if let Some(off) = tokens[i].encoding.offset {
409            off
410        } else {
411            current_offset
412        };
413        if resolved_offset < current_offset {
414            return Err(ResolveError::InvalidOffset {
415                offset: resolved_offset,
416                source_code: src.clone(),
417                span: tokens[i].span.clone().map(miette::SourceSpan::from),
418            });
419        }
420
421        tokens[i].encoding.offset = Some(resolved_offset);
422        current_offset = resolved_offset + size;
423        i = next_i;
424    }
425
426    // Honor schema `blockLength` when larger than the sum of fixed fields
427    // (padding). Matches sbe-tool / official SBE: the header and encoder walk
428    // use the declared root block length, not the tight field packing size.
429    let declared = tokens[0].encoding.offset;
430    if let Some(declared) = declared
431        && declared != 0
432        && declared < current_offset
433    {
434        return Err(ResolveError::BlockLengthTooShort {
435            name: tokens[0].name.clone(),
436            declared,
437            required: current_offset,
438            source_code: src.clone(),
439            span: tokens[0].span.clone().map(miette::SourceSpan::from),
440        });
441    }
442    let block_length = match declared {
443        Some(d) if d > current_offset => d,
444        _ => current_offset,
445    };
446    tokens[0].encoding.offset = Some(block_length);
447    Ok(())
448}
449
450fn resolve_group_offsets(
451    tokens: &mut [Token],
452    src: &Option<miette::NamedSource<String>>,
453) -> Result<(), ResolveError> {
454    // A group has BeginGroup, followed by dimensionType composite tokens,
455    // followed by group entry tokens, followed by EndGroup.
456    // First, resolve the dimensionType composite offsets:
457    let mut i = 1;
458    if tokens[i].signal == Signal::BeginComposite {
459        let dim_end = find_matching_end(tokens, i, Signal::BeginComposite, Signal::EndComposite);
460        resolve_composite_offsets(&mut tokens[i..=dim_end], src)?;
461        i = dim_end + 1;
462    }
463
464    // Now resolve group entry fields (relative to 0)
465    let mut current_offset = 0;
466    let end_limit = tokens.len() - 1;
467    while i < end_limit {
468        if tokens[i].signal == Signal::BeginGroup || tokens[i].signal == Signal::BeginVarData {
469            if tokens[i].signal == Signal::BeginGroup {
470                let end_idx = find_matching_end(tokens, i, Signal::BeginGroup, Signal::EndGroup);
471                resolve_group_offsets(&mut tokens[i..=end_idx], src)?;
472                i = end_idx + 1;
473            } else {
474                let end_idx =
475                    find_matching_end(tokens, i, Signal::BeginVarData, Signal::EndVarData);
476                resolve_vardata_offsets(&mut tokens[i..=end_idx], src)?;
477                i = end_idx + 1;
478            }
479            continue;
480        }
481
482        let (size, next_i) = get_token_block_size(tokens, i);
483        let resolved_offset = if let Some(off) = tokens[i].encoding.offset {
484            off
485        } else {
486            current_offset
487        };
488        if resolved_offset < current_offset {
489            return Err(ResolveError::InvalidOffset {
490                offset: resolved_offset,
491                source_code: src.clone(),
492                span: tokens[i].span.clone().map(miette::SourceSpan::from),
493            });
494        }
495        tokens[i].encoding.offset = Some(resolved_offset);
496        current_offset = resolved_offset + size;
497        i = next_i;
498    }
499
500    // The group's entry blockLength is the final offset of entry fields,
501    // but honour a schema-declared blockLength when it is larger (padding).
502    let declared = tokens[0].encoding.offset;
503    if let Some(declared) = declared
504        && declared != 0
505        && declared < current_offset
506    {
507        return Err(ResolveError::BlockLengthTooShort {
508            name: tokens[0].name.clone(),
509            declared,
510            required: current_offset,
511            source_code: src.clone(),
512            span: tokens[0].span.clone().map(miette::SourceSpan::from),
513        });
514    }
515    let block_length = match declared {
516        Some(d) if d > current_offset => d,
517        _ => current_offset,
518    };
519    tokens[0].encoding.offset = Some(block_length);
520    Ok(())
521}
522
523fn resolve_vardata_offsets(
524    tokens: &mut [Token],
525    src: &Option<miette::NamedSource<String>>,
526) -> Result<(), ResolveError> {
527    // A var-data field has BeginVarData, followed by type composite tokens,
528    // followed by EndVarData.
529    let i = 1;
530    if tokens[i].signal == Signal::BeginComposite {
531        let type_end = find_matching_end(tokens, i, Signal::BeginComposite, Signal::EndComposite);
532        resolve_composite_offsets(&mut tokens[i..=type_end], src)?;
533    }
534    Ok(())
535}
536
537fn default_null(prim: PrimitiveType) -> Option<u64> {
538    match prim {
539        PrimitiveType::Char => Some(0),
540        PrimitiveType::Int8 => Some(-128i8 as u64),
541        PrimitiveType::UInt8 => Some(255),
542        PrimitiveType::Int16 => Some(-32768i16 as u64),
543        PrimitiveType::UInt16 => Some(65535),
544        PrimitiveType::Int32 => Some(-2147483648i32 as u64),
545        PrimitiveType::UInt32 => Some(4294967295),
546        PrimitiveType::Int64 => Some(-9223372036854775808i64 as u64),
547        PrimitiveType::UInt64 => Some(18446744073709551615),
548        // Match sbe-tool / IEEE quiet NaN so null images are wire-identical.
549        PrimitiveType::Float => Some(f32::NAN.to_bits() as u64),
550        PrimitiveType::Double => Some(f64::NAN.to_bits()),
551    }
552}
553
554fn default_min(prim: PrimitiveType) -> Option<u64> {
555    match prim {
556        PrimitiveType::Char => Some(0x20),
557        PrimitiveType::Int8 => Some(-127i8 as u64),
558        PrimitiveType::UInt8 => Some(0),
559        PrimitiveType::Int16 => Some(-32767i16 as u64),
560        PrimitiveType::UInt16 => Some(0),
561        PrimitiveType::Int32 => Some(-2147483647i32 as u64),
562        PrimitiveType::UInt32 => Some(0),
563        PrimitiveType::Int64 => Some(-9223372036854775807i64 as u64),
564        PrimitiveType::UInt64 => Some(0),
565        PrimitiveType::Float => Some(f32::MIN.to_bits() as u64), // neg max float
566        PrimitiveType::Double => Some(f64::MIN.to_bits()),       // neg max double
567    }
568}
569
570fn default_max(prim: PrimitiveType) -> Option<u64> {
571    match prim {
572        PrimitiveType::Char => Some(0x7E),
573        PrimitiveType::Int8 => Some(127),
574        PrimitiveType::UInt8 => Some(254),
575        PrimitiveType::Int16 => Some(32767),
576        PrimitiveType::UInt16 => Some(65534),
577        PrimitiveType::Int32 => Some(2147483647),
578        PrimitiveType::UInt32 => Some(4294967294),
579        PrimitiveType::Int64 => Some(9223372036854775807),
580        PrimitiveType::UInt64 => Some(18446744073709551614),
581        PrimitiveType::Float => Some(f32::MAX.to_bits() as u64), // max float
582        PrimitiveType::Double => Some(f64::MAX.to_bits()),       // max double
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn minimal_schema() -> Ir {
591        crate::parse(
592            r#"<?xml version="1.0"?>
593<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe"
594    package="test" id="1" version="0" byteOrder="littleEndian">
595  <types>
596    <composite name="messageHeader">
597      <type name="blockLength" primitiveType="uint16"/>
598      <type name="templateId" primitiveType="uint16"/>
599      <type name="schemaId" primitiveType="uint16"/>
600      <type name="version" primitiveType="uint16"/>
601    </composite>
602  </types>
603  <sbe:message name="A" id="1"><field name="x" id="1" type="uint32"/></sbe:message>
604</sbe:messageSchema>"#,
605        )
606        .unwrap()
607    }
608
609    #[test]
610    fn duplicate_template_id_rejected() -> Result<(), Box<dyn std::error::Error>> {
611        let result = crate::parse(
612            r#"<?xml version="1.0"?>
613<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe"
614    package="test" id="1" version="0" byteOrder="littleEndian">
615  <types><composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite></types>
616  <sbe:message name="A" id="1"><field name="x" id="1" type="uint32"/></sbe:message>
617  <sbe:message name="B" id="1"><field name="y" id="1" type="uint32"/></sbe:message>
618</sbe:messageSchema>"#,
619        );
620        assert!(result.is_err());
621
622        Ok(())
623    }
624
625    #[test]
626    fn since_version_beyond_schema_rejected() -> Result<(), Box<dyn std::error::Error>> {
627        let mut ir = minimal_schema();
628        ir.tokens[5].encoding.since_version = 5;
629        let result = resolve_schema(&mut ir, None);
630        assert!(matches!(
631            result,
632            Err(ResolveError::SinceVersionBeyondSchema { .. })
633        ));
634        Ok(())
635    }
636
637    #[test]
638    fn resolve_schema_ok_on_valid_schema() -> Result<(), Box<dyn std::error::Error>> {
639        let mut ir = minimal_schema();
640        assert!(resolve_schema(&mut ir, None).is_ok());
641
642        Ok(())
643    }
644
645    #[test]
646    fn resolve_schema_with_source_code() -> Result<(), Box<dyn std::error::Error>> {
647        let xml = r#"<?xml version="1.0"?>
648<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
649<types><composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite></types>
650<sbe:message name="A" id="1"><field name="x" id="1" type="uint32"/></sbe:message>
651</sbe:messageSchema>"#;
652        let mut ir = crate::parse(xml).unwrap();
653        assert!(resolve_schema(&mut ir, Some(xml)).is_ok());
654
655        Ok(())
656    }
657
658    #[test]
659    fn default_null_all_primitives() -> Result<(), Box<dyn std::error::Error>> {
660        assert_eq!(default_null(PrimitiveType::Char), Some(0));
661        assert_eq!(default_null(PrimitiveType::Int8), Some(-128i8 as u64));
662        assert_eq!(default_null(PrimitiveType::UInt8), Some(255));
663        assert_eq!(default_null(PrimitiveType::Int16), Some(-32768i16 as u64));
664        assert_eq!(default_null(PrimitiveType::UInt16), Some(65535));
665        assert_eq!(
666            default_null(PrimitiveType::Int32),
667            Some(-2147483648i32 as u64)
668        );
669        assert_eq!(default_null(PrimitiveType::UInt32), Some(4294967295));
670        assert_eq!(
671            default_null(PrimitiveType::Int64),
672            Some(9223372036854775808u64)
673        ); // i64::MIN as u64
674        assert_eq!(default_null(PrimitiveType::UInt64), Some(u64::MAX));
675        assert!(default_null(PrimitiveType::Float).is_some());
676        assert!(default_null(PrimitiveType::Double).is_some());
677
678        Ok(())
679    }
680
681    #[test]
682    fn default_min_all_primitives() -> Result<(), Box<dyn std::error::Error>> {
683        assert_eq!(default_min(PrimitiveType::Char), Some(0x20));
684        assert_eq!(default_min(PrimitiveType::UInt8), Some(0));
685        assert_eq!(default_min(PrimitiveType::UInt16), Some(0));
686        assert_eq!(default_min(PrimitiveType::UInt32), Some(0));
687        assert_eq!(default_min(PrimitiveType::UInt64), Some(0));
688        assert!(default_min(PrimitiveType::Int8).is_some());
689        assert!(default_min(PrimitiveType::Int16).is_some());
690        assert!(default_min(PrimitiveType::Int32).is_some());
691        assert!(default_min(PrimitiveType::Int64).is_some());
692        assert!(default_min(PrimitiveType::Float).is_some());
693        assert!(default_min(PrimitiveType::Double).is_some());
694
695        Ok(())
696    }
697
698    #[test]
699    fn default_max_all_primitives() -> Result<(), Box<dyn std::error::Error>> {
700        assert_eq!(default_max(PrimitiveType::Char), Some(0x7E));
701        assert_eq!(default_max(PrimitiveType::Int8), Some(127));
702        assert_eq!(default_max(PrimitiveType::UInt8), Some(254));
703        assert_eq!(default_max(PrimitiveType::Int16), Some(32767));
704        assert_eq!(default_max(PrimitiveType::UInt16), Some(65534));
705        assert_eq!(default_max(PrimitiveType::Int32), Some(2147483647));
706        assert_eq!(default_max(PrimitiveType::UInt32), Some(4294967294));
707        assert_eq!(default_max(PrimitiveType::Int64), Some(9223372036854775807));
708        assert_eq!(
709            default_max(PrimitiveType::UInt64),
710            Some(18446744073709551614)
711        );
712        assert!(default_max(PrimitiveType::Float).is_some());
713        assert!(default_max(PrimitiveType::Double).is_some());
714
715        Ok(())
716    }
717
718    #[test]
719    fn composite_offsets_assigned_sequentially() -> Result<(), Box<dyn std::error::Error>> {
720        let mut ir = minimal_schema();
721        resolve_schema(&mut ir, None).unwrap();
722        let hdr = ir
723            .tokens
724            .iter()
725            .find(|t| t.name == "messageHeader")
726            .unwrap();
727        // Should have a non-zero offset (block length)
728        assert!(hdr.encoding.offset.is_some());
729
730        Ok(())
731    }
732
733    #[test]
734    fn message_offsets_assigned_correctly() -> Result<(), Box<dyn std::error::Error>> {
735        let mut ir = minimal_schema();
736        resolve_schema(&mut ir, None).unwrap();
737        let msg = ir
738            .tokens
739            .iter()
740            .find(|t| t.signal == Signal::BeginMessage)
741            .unwrap();
742        assert!(msg.encoding.offset.is_some());
743        // uint32 field should be at offset 0
744        let field = ir.tokens.iter().find(|t| t.name == "x").unwrap();
745        assert_eq!(field.encoding.offset, Some(0));
746
747        Ok(())
748    }
749
750    #[test]
751    fn explicit_offset_preserved() -> Result<(), Box<dyn std::error::Error>> {
752        let ir = crate::parse(
753            r#"<?xml version="1.0"?>
754<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
755<types><composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite></types>
756<sbe:message name="A" id="1"><field name="x" id="1" type="uint32" offset="0"/><field name="y" id="2" type="uint16" offset="4"/></sbe:message>
757</sbe:messageSchema>"#,
758        ).unwrap();
759        let mut ir = ir;
760        resolve_schema(&mut ir, None).unwrap();
761        let x = ir.tokens.iter().find(|t| t.name == "x").unwrap();
762        assert_eq!(x.encoding.offset, Some(0));
763        let y = ir.tokens.iter().find(|t| t.name == "y").unwrap();
764        assert_eq!(y.encoding.offset, Some(4));
765
766        Ok(())
767    }
768
769    #[test]
770    fn group_offsets_resolved() -> Result<(), Box<dyn std::error::Error>> {
771        let ir = crate::parse(
772            r#"<?xml version="1.0"?>
773<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
774<types>
775  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
776  <composite name="groupSizeEncoding"><type name="blockLength" primitiveType="uint16"/><type name="numInGroup" primitiveType="uint16"/></composite>
777</types>
778<sbe:message name="A" id="1">
779  <field name="x" id="1" type="uint32"/>
780  <group name="items" id="2" dimensionType="groupSizeEncoding">
781    <field name="a" id="1" type="uint32"/>
782    <field name="b" id="2" type="uint16"/>
783  </group>
784</sbe:message>
785</sbe:messageSchema>"#,
786        ).unwrap();
787        let mut ir = ir;
788        resolve_schema(&mut ir, None).unwrap();
789        let a = ir.tokens.iter().find(|t| t.name == "a").unwrap();
790        assert_eq!(a.encoding.offset, Some(0));
791        let b = ir.tokens.iter().find(|t| t.name == "b").unwrap();
792        assert_eq!(b.encoding.offset, Some(4));
793
794        Ok(())
795    }
796
797    #[test]
798    fn vardata_offsets_resolved() -> Result<(), Box<dyn std::error::Error>> {
799        let ir = crate::parse(
800            r#"<?xml version="1.0"?>
801<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
802<types>
803  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
804  <composite name="varDataEncoding"><type name="length" primitiveType="uint32"/><type name="varData" primitiveType="uint8" length="0"/></composite>
805</types>
806<sbe:message name="A" id="1">
807  <field name="x" id="1" type="uint32"/>
808  <data name="payload" id="2" type="varDataEncoding"/>
809</sbe:message>
810</sbe:messageSchema>"#,
811        ).unwrap();
812        let mut ir = ir;
813        resolve_schema(&mut ir, None).unwrap();
814        let msg = ir
815            .tokens
816            .iter()
817            .find(|t| t.signal == Signal::BeginMessage)
818            .unwrap();
819        assert!(msg.encoding.offset.is_some());
820
821        Ok(())
822    }
823
824    #[test]
825    fn enum_block_size_calculated() -> Result<(), Box<dyn std::error::Error>> {
826        let ir = crate::parse(
827            r#"<?xml version="1.0"?>
828<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
829<types>
830  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
831  <enum name="Colour" encodingType="uint8"><validValue name="R">1</validValue></enum>
832</types>
833<sbe:message name="A" id="1"><field name="c" id="1" type="Colour"/></sbe:message>
834</sbe:messageSchema>"#,
835        ).unwrap();
836        let mut ir = ir;
837        resolve_schema(&mut ir, None).unwrap();
838        let msg = ir
839            .tokens
840            .iter()
841            .find(|t| t.signal == Signal::BeginMessage)
842            .unwrap();
843        // Block length should be 1 (uint8 enum)
844        assert_eq!(msg.encoding.offset, Some(1));
845
846        Ok(())
847    }
848
849    #[test]
850    fn set_block_size_calculated() -> Result<(), Box<dyn std::error::Error>> {
851        let ir = crate::parse(
852            r#"<?xml version="1.0"?>
853<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
854<types>
855  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
856  <set name="Flags" encodingType="uint8"><choice name="A">0</choice></set>
857</types>
858<sbe:message name="A" id="1"><field name="f" id="1" type="Flags"/></sbe:message>
859</sbe:messageSchema>"#,
860        ).unwrap();
861        let mut ir = ir;
862        resolve_schema(&mut ir, None).unwrap();
863        let msg = ir
864            .tokens
865            .iter()
866            .find(|t| t.signal == Signal::BeginMessage)
867            .unwrap();
868        assert_eq!(msg.encoding.offset, Some(1));
869
870        Ok(())
871    }
872
873    #[test]
874    fn constant_field_does_not_affect_block_length() -> Result<(), Box<dyn std::error::Error>> {
875        let ir = crate::parse(
876            r#"<?xml version="1.0"?>
877<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
878<types>
879  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
880  <type name="ConstVal" primitiveType="char" presence="constant">X</type>
881</types>
882<sbe:message name="A" id="1"><field name="x" id="1" type="uint32"/><field name="c" id="2" type="ConstVal"/></sbe:message>
883</sbe:messageSchema>"#,
884        ).unwrap();
885        let mut ir = ir;
886        resolve_schema(&mut ir, None).unwrap();
887        let msg = ir
888            .tokens
889            .iter()
890            .find(|t| t.signal == Signal::BeginMessage)
891            .unwrap();
892        // Block length should be 4 (uint32 only, constant doesn't occupy wire)
893        assert_eq!(msg.encoding.offset, Some(4));
894        Ok(())
895    }
896
897    #[test]
898    fn nested_composite_offsets_resolved() -> Result<(), Box<dyn std::error::Error>> {
899        let ir = crate::parse(
900            r#"<?xml version="1.0"?>
901<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
902<types>
903  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
904  <composite name="Point"><type name="px" primitiveType="int32"/><type name="py" primitiveType="int32"/></composite>
905</types>
906<sbe:message name="A" id="1"><field name="p" id="1" type="Point"/></sbe:message>
907</sbe:messageSchema>"#,
908        ).unwrap();
909        let mut ir = ir;
910        resolve_schema(&mut ir, None).unwrap();
911        let msg = ir
912            .tokens
913            .iter()
914            .find(|t| t.signal == Signal::BeginMessage)
915            .unwrap();
916        // Point composite = 2 × int32 = 8 bytes
917        assert_eq!(msg.encoding.offset, Some(8));
918
919        Ok(())
920    }
921
922    #[test]
923    fn take_source_code_from_duplicate_template_id() -> Result<(), Box<dyn std::error::Error>> {
924        let mut err = ResolveError::DuplicateTemplateId {
925            id: 1,
926            name: "test".to_string(),
927            source_code: None,
928            first_label: None,
929            second_label: None,
930        };
931        assert!(err.take_source_code().is_none()); // was None
932
933        Ok(())
934    }
935
936    #[test]
937    fn take_source_code_from_unknown_type() -> Result<(), Box<dyn std::error::Error>> {
938        let mut err = ResolveError::UnknownType {
939            name: "Foo".to_string(),
940            source_code: None,
941            span: None,
942        };
943        assert!(err.take_source_code().is_none());
944
945        Ok(())
946    }
947
948    #[test]
949    fn take_source_code_from_invalid_offset() -> Result<(), Box<dyn std::error::Error>> {
950        let mut err = ResolveError::InvalidOffset {
951            offset: 99,
952            source_code: None,
953            span: None,
954        };
955        assert!(err.take_source_code().is_none());
956
957        Ok(())
958    }
959
960    #[test]
961    fn take_source_code_from_empty_composite() -> Result<(), Box<dyn std::error::Error>> {
962        let mut err = ResolveError::EmptyComposite {
963            name: "Empty".to_string(),
964            source_code: None,
965            span: None,
966        };
967        assert!(err.take_source_code().is_none());
968
969        Ok(())
970    }
971
972    #[test]
973    fn take_source_code_from_since_version() -> Result<(), Box<dyn std::error::Error>> {
974        let mut err = ResolveError::SinceVersionBeyondSchema {
975            version: 5,
976            schema_version: 0,
977            name: "field".to_string(),
978            source_code: None,
979            span: None,
980        };
981        assert!(err.take_source_code().is_none());
982
983        Ok(())
984    }
985
986    #[test]
987    fn resolve_error_displays() -> Result<(), Box<dyn std::error::Error>> {
988        let err = ResolveError::DuplicateTemplateId {
989            id: 1,
990            name: "A".to_string(),
991            source_code: None,
992            first_label: None,
993            second_label: None,
994        };
995        assert!(format!("{err}").contains("duplicate template id 1"));
996
997        let err = ResolveError::UnknownType {
998            name: "Foo".to_string(),
999            source_code: None,
1000            span: None,
1001        };
1002        assert!(format!("{err}").contains("unknown type reference Foo"));
1003
1004        let err = ResolveError::InvalidOffset {
1005            offset: 42,
1006            source_code: None,
1007            span: None,
1008        };
1009        assert!(format!("{err}").contains("offset 42"));
1010
1011        let err = ResolveError::EmptyComposite {
1012            name: "X".to_string(),
1013            source_code: None,
1014            span: None,
1015        };
1016        assert!(format!("{err}").contains("composite X"));
1017
1018        let err = ResolveError::SinceVersionBeyondSchema {
1019            version: 3,
1020            schema_version: 0,
1021            name: "y".to_string(),
1022            source_code: None,
1023            span: None,
1024        };
1025        assert!(format!("{err}").contains("sinceVersion 3"));
1026
1027        Ok(())
1028    }
1029
1030    #[test]
1031    fn fixed_array_field_offset() -> Result<(), Box<dyn std::error::Error>> {
1032        let ir = crate::parse(
1033            r#"<?xml version="1.0"?>
1034<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
1035<types>
1036  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
1037  <type name="int32array4" primitiveType="int32" length="4"/>
1038</types>
1039<sbe:message name="A" id="1"><field name="nums" id="1" type="int32array4"/></sbe:message>
1040</sbe:messageSchema>"#,
1041        ).unwrap();
1042        let mut ir = ir;
1043        resolve_schema(&mut ir, None).unwrap();
1044        let msg = ir
1045            .tokens
1046            .iter()
1047            .find(|t| t.signal == Signal::BeginMessage)
1048            .unwrap();
1049        // 4 × int32 = 16 bytes
1050        assert_eq!(msg.encoding.offset, Some(16));
1051
1052        Ok(())
1053    }
1054
1055    #[test]
1056    fn nested_group_offsets_resolved() -> Result<(), Box<dyn std::error::Error>> {
1057        let ir = crate::parse(
1058            r#"<?xml version="1.0"?>
1059<sbe:messageSchema xmlns:sbe="http://fixprotocol.io/2016/sbe" package="t" id="1" version="0" byteOrder="littleEndian">
1060<types>
1061  <composite name="messageHeader"><type name="blockLength" primitiveType="uint16"/><type name="templateId" primitiveType="uint16"/><type name="schemaId" primitiveType="uint16"/><type name="version" primitiveType="uint16"/></composite>
1062  <composite name="groupSizeEncoding"><type name="blockLength" primitiveType="uint16"/><type name="numInGroup" primitiveType="uint16"/></composite>
1063</types>
1064<sbe:message name="A" id="1">
1065  <group name="outer" id="2" dimensionType="groupSizeEncoding">
1066    <field name="a" id="1" type="uint32"/>
1067    <group name="inner" id="3" dimensionType="groupSizeEncoding">
1068      <field name="b" id="1" type="uint16"/>
1069    </group>
1070  </group>
1071</sbe:message>
1072</sbe:messageSchema>"#,
1073        ).unwrap();
1074        let mut ir = ir;
1075        resolve_schema(&mut ir, None).unwrap();
1076        let b = ir.tokens.iter().find(|t| t.name == "b").unwrap();
1077        assert_eq!(b.encoding.offset, Some(0));
1078
1079        Ok(())
1080    }
1081
1082    #[test]
1083    fn begin_message_without_id_skips_duplicate_check() -> Result<(), Box<dyn std::error::Error>> {
1084        // Construct a minimal IR with a BeginMessage that has no id.
1085        // The duplicate check should skip it (the `if let Some(id)` false branch).
1086        let mut ir = Ir {
1087            package: "t".to_string(),
1088            id: 1,
1089            version: 0,
1090            byte_order: crate::ir::ByteOrder::LittleEndian,
1091            description: None,
1092            semantic_version: None,
1093            header_type: "messageHeader".to_string(),
1094            tokens: vec![
1095                Token {
1096                    id: None,
1097                    name: "A".to_string(),
1098                    signal: Signal::BeginMessage,
1099                    encoding: crate::ir::Encoding::default(),
1100                    span: None,
1101                },
1102                Token {
1103                    id: None,
1104                    name: "A".to_string(),
1105                    signal: Signal::EndMessage,
1106                    encoding: crate::ir::Encoding::default(),
1107                    span: None,
1108                },
1109            ],
1110        };
1111        // Should not panic or error — the message has no id to conflict
1112        assert!(resolve_schema(&mut ir, None).is_ok());
1113
1114        Ok(())
1115    }
1116
1117    #[test]
1118    fn find_matching_end_fallback_on_no_match() -> Result<(), Box<dyn std::error::Error>> {
1119        // Construct tokens with an unclosed BeginComposite to trigger the fallback.
1120        let tokens = vec![
1121            Token {
1122                id: None,
1123                name: "X".to_string(),
1124                signal: Signal::BeginComposite,
1125                encoding: crate::ir::Encoding::default(),
1126                span: None,
1127            },
1128            Token {
1129                id: None,
1130                name: "Y".to_string(),
1131                signal: Signal::BeginField,
1132                encoding: crate::ir::Encoding::default(),
1133                span: None,
1134            },
1135        ];
1136        // find_matching_end should return tokens.len() - 1 as fallback
1137        let end = find_matching_end(&tokens, 0, Signal::BeginComposite, Signal::EndComposite);
1138        assert_eq!(end, 1); // returns last index
1139
1140        Ok(())
1141    }
1142
1143    #[test]
1144    fn get_token_block_size_catch_all_signal() -> Result<(), Box<dyn std::error::Error>> {
1145        // An EndField or other non-Begin signal as a direct child hits the `_ =>` branch.
1146        let tokens = vec![Token {
1147            id: None,
1148            name: "X".to_string(),
1149            signal: Signal::EndField,
1150            encoding: crate::ir::Encoding::default(),
1151            span: None,
1152        }];
1153        let (size, next) = get_token_block_size(&tokens, 0);
1154        assert_eq!(size, 0);
1155        assert_eq!(next, 1);
1156
1157        Ok(())
1158    }
1159
1160    #[test]
1161    fn group_without_dimension_composite() -> Result<(), Box<dyn std::error::Error>> {
1162        // A group whose second token is NOT BeginComposite (missing dimensionType).
1163        let mut tokens = vec![
1164            Token {
1165                id: Some(1),
1166                name: "grp".to_string(),
1167                signal: Signal::BeginGroup,
1168                encoding: crate::ir::Encoding::default(),
1169                span: None,
1170            },
1171            // No BeginComposite — jump straight to a field
1172            Token {
1173                id: None,
1174                name: "field".to_string(),
1175                signal: Signal::BeginField,
1176                encoding: crate::ir::Encoding {
1177                    primitive_type: Some(PrimitiveType::UInt32),
1178                    ..crate::ir::Encoding::default()
1179                },
1180                span: None,
1181            },
1182            Token {
1183                id: None,
1184                name: "field".to_string(),
1185                signal: Signal::EndField,
1186                encoding: crate::ir::Encoding::default(),
1187                span: None,
1188            },
1189            Token {
1190                id: None,
1191                name: "grp".to_string(),
1192                signal: Signal::EndGroup,
1193                encoding: crate::ir::Encoding::default(),
1194                span: None,
1195            },
1196        ];
1197        let src: Option<miette::NamedSource<String>> = None;
1198        let result = resolve_group_offsets(&mut tokens, &src);
1199        assert!(result.is_ok());
1200        assert_eq!(tokens[1].encoding.offset, Some(0));
1201
1202        Ok(())
1203    }
1204
1205    #[test]
1206    fn vardata_without_type_composite() -> Result<(), Box<dyn std::error::Error>> {
1207        // A var-data whose second token is NOT BeginComposite (missing type).
1208        let mut tokens = vec![
1209            Token {
1210                id: Some(1),
1211                name: "data".to_string(),
1212                signal: Signal::BeginVarData,
1213                encoding: crate::ir::Encoding::default(),
1214                span: None,
1215            },
1216            // No BeginComposite — just EndVarData
1217            Token {
1218                id: None,
1219                name: "data".to_string(),
1220                signal: Signal::EndVarData,
1221                encoding: crate::ir::Encoding::default(),
1222                span: None,
1223            },
1224        ];
1225        let src: Option<miette::NamedSource<String>> = None;
1226        let result = resolve_vardata_offsets(&mut tokens, &src);
1227        assert!(result.is_ok());
1228
1229        Ok(())
1230    }
1231}