From f5e377148a64d8e37766f8ebc94c60643a617086 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Tue, 23 Jun 2026 18:42:24 -0400 Subject: [PATCH 01/12] Initial implementation of multi-section bytecode support --- .../vihaco-derive/src/derive_instruction.rs | 7 +- crates/vihaco-derive/src/derive_machine.rs | 441 +++++++++++++-- crates/vihaco-derive/src/lib.rs | 7 +- crates/vihaco/src/binary/context.rs | 262 +++++++++ crates/vihaco/src/binary/file.rs | 93 ++++ crates/vihaco/src/binary/format.rs | 223 ++++++++ crates/vihaco/src/binary/mod.rs | 16 + crates/vihaco/src/binary/parser.rs | 415 +++++++++++++++ crates/vihaco/src/binary/section.rs | 257 +++++++++ crates/vihaco/src/binary/tests.rs | 501 ++++++++++++++++++ crates/vihaco/src/lib.rs | 24 +- crates/vihaco/src/loader.rs | 171 +++++- crates/vihaco/src/traits/machine.rs | 5 +- crates/vihaco/tests/multisection_bytecode.rs | 354 +++++++++++++ docs/src/pages/guide/composites.md | 108 ++++ 15 files changed, 2827 insertions(+), 57 deletions(-) create mode 100644 crates/vihaco/src/binary/context.rs create mode 100644 crates/vihaco/src/binary/file.rs create mode 100644 crates/vihaco/src/binary/format.rs create mode 100644 crates/vihaco/src/binary/mod.rs create mode 100644 crates/vihaco/src/binary/parser.rs create mode 100644 crates/vihaco/src/binary/section.rs create mode 100644 crates/vihaco/src/binary/tests.rs create mode 100644 crates/vihaco/tests/multisection_bytecode.rs diff --git a/crates/vihaco-derive/src/derive_instruction.rs b/crates/vihaco-derive/src/derive_instruction.rs index 62877e5..db1ed88 100644 --- a/crates/vihaco-derive/src/derive_instruction.rs +++ b/crates/vihaco-derive/src/derive_instruction.rs @@ -16,6 +16,7 @@ pub fn expand(input: TokenStream) -> TokenStream { } let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let width_override = match instruction_attrs(&input) { Ok(width) => width, Err(err) => return err.into_compile_error().into(), @@ -115,7 +116,7 @@ pub fn expand(input: TokenStream) -> TokenStream { }; quote! { - impl ::vihaco::instruction::OpCode for #ident { + impl #impl_generics ::vihaco::instruction::OpCode for #ident #ty_generics #where_clause { fn width() -> u32 { #width_impl } @@ -127,7 +128,7 @@ pub fn expand(input: TokenStream) -> TokenStream { } } - impl ::vihaco::instruction::FromBytesWithOpcode for #ident { + impl #impl_generics ::vihaco::instruction::FromBytesWithOpcode for #ident #ty_generics #where_clause { fn from_bytes_with_opcode( bytes: &mut R, opcode: u8, @@ -142,7 +143,7 @@ pub fn expand(input: TokenStream) -> TokenStream { } } - impl ::vihaco::instruction::WriteBytes for #ident { + impl #impl_generics ::vihaco::instruction::WriteBytes for #ident #ty_generics #where_clause { fn write_bytes(&self, io: &mut W) -> ::eyre::Result<()> { let mut payload = Vec::new(); match self { diff --git a/crates/vihaco-derive/src/derive_machine.rs b/crates/vihaco-derive/src/derive_machine.rs index f4eed8b..0aaca06 100644 --- a/crates/vihaco-derive/src/derive_machine.rs +++ b/crates/vihaco-derive/src/derive_machine.rs @@ -2,16 +2,43 @@ // SPDX-License-Identifier: MIT use proc_macro::TokenStream; -use quote::{format_ident, quote}; +use proc_macro2::TokenStream as TokenStream2; +use quote::{ToTokens, format_ident, quote}; use std::collections::{BTreeMap, BTreeSet}; use syn::parse::{Parse, ParseStream}; -use syn::{Data, DeriveInput, Fields, Token}; +use syn::spanned::Spanned; +use syn::{Data, DeriveInput, Fields, GenericParam, Lifetime, LitStr, Token}; struct DeviceArgs { code: u8, aliases: Vec, } +struct SectionLoadArgs { + section_name: Option, +} + +impl Parse for SectionLoadArgs { + fn parse(input: ParseStream) -> syn::Result { + let section_name = if input.is_empty() { + None + } else { + let section_name = input.parse::()?; + if !input.is_empty() { + return Err(input.error("unexpected tokens in loadable attribute")); + } + Some(section_name) + }; + Ok(SectionLoadArgs { section_name }) + } +} + +struct SectionLoadField { + field: syn::Ident, + ty: syn::Type, + section_name: String, +} + impl Parse for DeviceArgs { fn parse(input: ParseStream<'_>) -> syn::Result { let code_lit: syn::LitInt = input.parse()?; @@ -55,67 +82,249 @@ fn pascal_case(ident: &syn::Ident) -> syn::Ident { format_ident!("{}", out) } +fn validate_loadable_name(name: &str, span: proc_macro2::Span) -> syn::Result<()> { + if name.is_empty() { + return Err(syn::Error::new( + span, + "loadable section name cannot be empty", + )); + } + if name.contains('/') { + return Err(syn::Error::new( + span, + "loadable section name cannot contain `/`", + )); + } + Ok(()) +} + +fn method_where_clause(predicates: &[TokenStream2]) -> TokenStream2 { + if predicates.is_empty() { + quote! {} + } else { + quote! { + where + #( #predicates ),* + } + } +} + +fn stream_contains_ident(stream: TokenStream2, ident: &syn::Ident) -> bool { + stream.into_iter().any(|tree| match tree { + proc_macro2::TokenTree::Ident(found) => found == *ident, + proc_macro2::TokenTree::Group(group) => stream_contains_ident(group.stream(), ident), + _ => false, + }) +} + +fn stream_contains_lifetime(stream: &TokenStream2, lifetime: &Lifetime) -> bool { + stream.to_string().contains(&lifetime.to_string()) +} + +fn generic_param_used(param: &GenericParam, streams: &[TokenStream2]) -> bool { + match param { + GenericParam::Lifetime(param) => streams + .iter() + .any(|stream| stream_contains_lifetime(stream, ¶m.lifetime)), + GenericParam::Type(param) => streams + .iter() + .any(|stream| stream_contains_ident(stream.clone(), ¶m.ident)), + GenericParam::Const(param) => streams + .iter() + .any(|stream| stream_contains_ident(stream.clone(), ¶m.ident)), + } +} + +fn stream_mentions_any_generic(stream: TokenStream2, params: &[GenericParam]) -> bool { + params.iter().any(|param| match param { + GenericParam::Lifetime(param) => stream_contains_lifetime(&stream, ¶m.lifetime), + GenericParam::Type(param) => stream_contains_ident(stream.clone(), ¶m.ident), + GenericParam::Const(param) => stream_contains_ident(stream.clone(), ¶m.ident), + }) +} + +fn enum_generics_for_device_fields( + generics: &syn::Generics, + devices: &[(syn::Ident, syn::Type, DeviceArgs)], +) -> syn::Generics { + let device_streams: Vec<_> = devices + .iter() + .map(|(_, ty, _)| ty.to_token_stream()) + .collect(); + let retained_params: Vec = generics + .params + .iter() + .filter(|param| generic_param_used(param, &device_streams)) + .cloned() + .collect(); + + let mut enum_generics = generics.clone(); + enum_generics.params = retained_params.iter().cloned().collect(); + + if let Some(where_clause) = &mut enum_generics.where_clause { + where_clause.predicates = where_clause + .predicates + .iter() + .filter(|predicate| { + stream_mentions_any_generic(predicate.to_token_stream(), &retained_params) + }) + .cloned() + .collect(); + if where_clause.predicates.is_empty() { + enum_generics.where_clause = None; + } + } + + enum_generics +} + pub fn expand(input: TokenStream) -> TokenStream { let input = syn::parse_macro_input!(input as DeriveInput); + match try_expand(input) { + Ok(tokens) => tokens.into(), + Err(err) => err.into_compile_error().into(), + } +} + +fn try_expand(input: DeriveInput) -> syn::Result { let ident = input.ident; + let generics = input.generics; let data = match input.data { Data::Struct(data) => data, _ => { - return syn::Error::new( + return Err(syn::Error::new( proc_macro2::Span::call_site(), "composite wiring can only be generated for structs", - ) - .into_compile_error() - .into(); + )); } }; let fields = match data.fields { Fields::Named(fields) => fields.named, _ => { - return syn::Error::new( + return Err(syn::Error::new( proc_macro2::Span::call_site(), "composite wiring requires a struct with named fields", - ) - .into_compile_error() - .into(); + )); } }; let mut devices = Vec::new(); let mut program_field: Option<(syn::Ident, syn::Type)> = None; + let mut header_field: Option<(syn::Ident, syn::Type)> = None; + let mut loadables = Vec::::new(); for field in fields { let field_ident = field.ident.expect("named field"); let field_ty = field.ty; + let mut is_device = false; + let mut is_program = false; + let mut is_header = false; + let mut loadable_args = None; for attr in &field.attrs { - if attr.path().is_ident("program") { + let path = attr.path(); + if path.is_ident("program") { + if let Some((existing, _)) = &program_field { + return Err(syn::Error::new( + field_ident.span(), + format!( + "duplicate program field `{}`; `{}` is already marked #[program]", + field_ident, existing + ), + )); + } + is_program = true; program_field = Some((field_ident.clone(), field_ty.clone())); + } else if path.is_ident("header") { + if !matches!(&attr.meta, syn::Meta::Path(_)) { + return Err(syn::Error::new( + attr.span(), + "header attribute does not take arguments", + )); + } + if let Some((existing, _)) = &header_field { + return Err(syn::Error::new( + field_ident.span(), + format!( + "duplicate header field `{}`; `{}` is already marked #[header]", + field_ident, existing + ), + )); + } + is_header = true; + header_field = Some((field_ident.clone(), field_ty.clone())); + } else if path.is_ident("device") { + is_device = true; + let args = attr.parse_args::()?; + devices.push((field_ident.clone(), field_ty.clone(), args)); + } else if path.is_ident("loadable") { + if loadable_args.is_some() { + return Err(syn::Error::new( + attr.span(), + format!("duplicate loadable attribute on field `{}`", field_ident), + )); + } + loadable_args = Some(if matches!(&attr.meta, syn::Meta::Path(_)) { + SectionLoadArgs { section_name: None } + } else { + attr.parse_args::()? + }); } } - - for attr in field.attrs { - if attr.path().is_ident("device") { - let args = match attr.parse_args::() { - Ok(args) => args, - Err(err) => return err.into_compile_error().into(), - }; - devices.push((field_ident.clone(), field_ty.clone(), args)); + if is_header && (is_program || is_device || loadable_args.is_some()) { + return Err(syn::Error::new( + field_ident.span(), + format!( + "field `{}` marked #[header] cannot also be marked #[program], #[device(...)] or #[loadable]", + field_ident + ), + )); + } + if let Some(args) = loadable_args { + if !is_device { + return Err(syn::Error::new( + field_ident.span(), + format!( + "field `{}` marked #[loadable] must also be marked #[device(...)]", + field_ident + ), + )); + } + if is_program { + return Err(syn::Error::new( + field_ident.span(), + format!( + "field `{}` cannot be both #[program] and #[loadable]", + field_ident + ), + )); } + let section_name = if let Some(lit) = args.section_name { + let value = lit.value(); + validate_loadable_name(&value, lit.span())?; + value + } else { + let value = field_ident.to_string(); + validate_loadable_name(&value, field_ident.span())?; + value + }; + loadables.push(SectionLoadField { + field: field_ident.clone(), + ty: field_ty.clone(), + section_name, + }); } } let mut seen_device_codes = BTreeMap::::new(); for (field, _, args) in &devices { if let Some(existing) = seen_device_codes.insert(args.code, field.clone()) { - return syn::Error::new( + return Err(syn::Error::new( field.span(), format!( "duplicate device code 0x{:02X} for fields `{}` and `{}`", args.code, existing, field ), - ) - .into_compile_error() - .into(); + )); } } @@ -123,43 +332,55 @@ pub fn expand(input: TokenStream) -> TokenStream { for (field, _, args) in &devices { let field_name = field.to_string(); if let Some(existing) = seen_source_symbols.insert(field_name.clone(), field.clone()) { - return syn::Error::new( + return Err(syn::Error::new( field.span(), format!( "duplicate source symbol `{}` for `{}` and `{}`", field_name, existing, field ), - ) - .into_compile_error() - .into(); + )); } let mut local_aliases = BTreeSet::new(); for alias in &args.aliases { let alias_name = alias.value(); if !local_aliases.insert(alias_name.clone()) { - return syn::Error::new( + return Err(syn::Error::new( alias.span(), format!("duplicate alias `{}` on field `{}`", alias_name, field), - ) - .into_compile_error() - .into(); + )); } if let Some(existing) = seen_source_symbols.insert(alias_name.clone(), field.clone()) { - return syn::Error::new( + return Err(syn::Error::new( alias.span(), format!( "duplicate source symbol `{}` for `{}` and `{}`", alias_name, existing, field ), - ) - .into_compile_error() - .into(); + )); } } } + let mut seen_loadable_names = BTreeMap::::new(); + for loadable in &loadables { + if let Some(existing) = + seen_loadable_names.insert(loadable.section_name.clone(), loadable.field.clone()) + { + return Err(syn::Error::new( + loadable.field.span(), + format!( + "duplicate loadable section name `{}` for fields `{}` and `{}`", + loadable.section_name, existing, loadable.field + ), + )); + } + } + let machine_instruction_ident = format_ident!("{}Instruction", ident); + let (impl_generics, ty_generics, where_clause) = generics.split_for_impl(); + let enum_generics = enum_generics_for_device_fields(&generics, &devices); + let (_, enum_ty_generics, _) = enum_generics.split_for_impl(); let machine_instruction_variants: Vec<_> = devices .iter() @@ -196,7 +417,7 @@ pub fn expand(input: TokenStream) -> TokenStream { let program_impl = if let Some((ref field_name, ref field_ty)) = program_field { quote! { - impl ::vihaco::traits::ProgramCounter for #ident { + impl #impl_generics ::vihaco::traits::ProgramCounter for #ident #ty_generics #where_clause { type Instruction = <#field_ty as ::vihaco::traits::ProgramCounter>::Instruction; fn pc(&self) -> u32 { @@ -216,14 +437,150 @@ pub fn expand(input: TokenStream) -> TokenStream { quote! {} }; - quote! { + let bc_lifetime = Lifetime::new("'__vihaco_bc", proc_macro2::Span::call_site()); + let loadable_context_param = format_ident!("__VihacoContext"); + let mut loadable_predicates = Vec::::new(); + loadable_predicates.push(quote! { #loadable_context_param: ::vihaco::binary::BytecodeContext }); + if let Some((_, header_ty)) = &header_field { + loadable_predicates.push(quote! { #header_ty: ::vihaco::binary::CompositeHeader }); + } + if let Some((_, program_ty)) = &program_field { + loadable_predicates + .push(quote! { #program_ty: ::vihaco::loader::LoadSection<#loadable_context_param> }); + } + for loadable in &loadables { + let ty = &loadable.ty; + loadable_predicates + .push(quote! { #ty: ::vihaco::loader::LoadSection<#loadable_context_param> }); + } + let loadable_method_where = method_where_clause(&loadable_predicates); + + let mut loadable_impl_generics = generics.clone(); + loadable_impl_generics + .params + .push(syn::parse_quote!(#loadable_context_param)); + if !loadable_predicates.is_empty() { + let where_clause = loadable_impl_generics.make_where_clause(); + for predicate in &loadable_predicates { + where_clause + .predicates + .push(syn::parse2(predicate.clone())?); + } + } + let (loadable_impl_generics, _, loadable_where_clause) = + loadable_impl_generics.split_for_impl(); + + let program_load = if let Some((field_name, field_ty)) = &program_field { + quote! { + <#field_ty as ::vihaco::loader::LoadSection<#loadable_context_param>>::load_section( + &mut self.#field_name, + input.clone(), + )?; + } + } else { + quote! { + if !input.section.bytecode().is_empty() { + return Err(::eyre::eyre!( + "section `{}` has bytecode but `{}` has no #[program] field", + input.section.display_path(), + stringify!(#ident), + )); + } + } + }; + + let header_load = if let Some((field_name, field_ty)) = &header_field { + quote! { + self.#field_name = input.section.parse_header::<#field_ty>()?; + } + } else { + quote! {} + }; + + let loadable_names: Vec<_> = loadables + .iter() + .map(|loadable| loadable.section_name.clone()) + .collect(); + let child_loads: Vec<_> = loadables + .iter() + .map(|loadable| { + let field = &loadable.field; + let ty = &loadable.ty; + let name = &loadable.section_name; + quote! { + let __vihaco_child = input.section.child(#name).ok_or_else(|| { + ::eyre::eyre!( + "section `{}` is missing required child section `{}`", + input.section.display_path(), + #name, + ) + })?; + <#ty as ::vihaco::loader::LoadSection<#loadable_context_param>>::load_section( + &mut self.#field, + ::vihaco::loader::LoadInput::from(__vihaco_child) + )?; + } + }) + .collect(); + + let loadable_impl = quote! { + impl #impl_generics #ident #ty_generics #where_clause { + pub fn load_generated_sections<#bc_lifetime, #loadable_context_param>( + &mut self, + input: ::vihaco::loader::LoadInput<#bc_lifetime, #loadable_context_param>, + ) -> ::eyre::Result<()> + #loadable_method_where + { + #header_load + #program_load + + let __vihaco_expected_children: &[&str] = &[#(#loadable_names),*]; + + for __vihaco_child in input.section.children() { + let __vihaco_child_name = __vihaco_child.local_name().ok_or_else(|| { + ::eyre::eyre!( + "section `{}` yielded a root section as a child", + input.section.display_path(), + ) + })?; + if !__vihaco_expected_children + .iter() + .any(|__vihaco_expected| *__vihaco_expected == __vihaco_child_name) + { + return Err(::eyre::eyre!( + "section `{}` has unexpected child section `{}`", + input.section.display_path(), + __vihaco_child.display_path(), + )); + } + } + + #( #child_loads )* + Ok(()) + } + } + + impl #loadable_impl_generics ::vihaco::loader::LoadSection<#loadable_context_param> + for #ident #ty_generics + #loadable_where_clause + { + fn load_section<#bc_lifetime>( + &mut self, + input: ::vihaco::loader::LoadInput<#bc_lifetime, #loadable_context_param>, + ) -> ::eyre::Result<()> { + self.load_generated_sections(input) + } + } + }; + + Ok(quote! { #[derive(Debug, Clone, ::vihaco::Instruction)] - pub enum #machine_instruction_ident { + pub enum #machine_instruction_ident #enum_generics { #( #machine_instruction_variants ),* } - impl ::vihaco::__private::GeneratedMachine for #ident { - type Instruction = #machine_instruction_ident; + impl #impl_generics ::vihaco::__private::GeneratedMachine for #ident #ty_generics #where_clause { + type Instruction = #machine_instruction_ident #enum_ty_generics; fn metadata(&self) -> ::vihaco::CompositeMetadata { static DEVICES: &[::vihaco::metadata::DeviceMetadata] = &[ @@ -240,6 +597,6 @@ pub fn expand(input: TokenStream) -> TokenStream { } #program_impl - } - .into() + #loadable_impl + }) } diff --git a/crates/vihaco-derive/src/lib.rs b/crates/vihaco-derive/src/lib.rs index 59e7359..dc4627e 100644 --- a/crates/vihaco-derive/src/lib.rs +++ b/crates/vihaco-derive/src/lib.rs @@ -22,7 +22,7 @@ pub fn derive_message(input: TokenStream) -> TokenStream { derive_message::expand(input) } -#[proc_macro_derive(Machine, attributes(device, program))] +#[proc_macro_derive(Machine, attributes(device, program, loadable, header))] pub fn derive_machine(input: TokenStream) -> TokenStream { derive_machine::expand(input) } @@ -42,7 +42,10 @@ pub fn composite(_attr: TokenStream, item: TokenStream) -> TokenStream { for field in &mut fields.named { field.attrs.retain(|attr| { let path = attr.path(); - !(path.is_ident("device") || path.is_ident("program")) + !(path.is_ident("device") + || path.is_ident("program") + || path.is_ident("loadable") + || path.is_ident("header")) }); } } diff --git a/crates/vihaco/src/binary/context.rs b/crates/vihaco/src/binary/context.rs new file mode 100644 index 0000000..d27d67a --- /dev/null +++ b/crates/vihaco/src/binary/context.rs @@ -0,0 +1,262 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::{ + io::{Cursor, Read}, + sync::Arc, +}; + +use byteorder::{LittleEndian, ReadBytesExt}; + +use crate::{ + module::{FunctionInfo, LabelInfo, Parameter, Signature, SourceSymbolInfo}, + traits::FromBytes, + value::{Type, Value}, +}; + +use super::format::ConstantId; + +/// The global context for a given program. +/// +/// This should include all context needed for an entire section tree. +/// Anything that should be shared across machines should be in a +/// [`BytecodeContext`]. +pub trait BytecodeContext: Sized { + fn from_bytes(bytes: &[u8]) -> eyre::Result; + + fn section_name(&self, index: u32) -> Option<&str>; +} + +pub trait ProgramGlobals { + type Type; + type Value; + + fn get_function(&self, index: usize) -> eyre::Result>; + fn get_string(&self, index: usize) -> eyre::Result<&String>; + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value>; +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ProgramContext { + pub constants: Vec, + pub strings: Vec, + pub functions: Vec>, + pub labels: Vec, + pub main_function: Option, + pub file: u32, + pub source_symbols: Vec, +} + +impl ProgramContext +where + V: FromBytes, + Ty: FromBytes, +{ + pub fn from_bytes(bytes: &[u8]) -> eyre::Result { + let mut cursor = Cursor::new(bytes); + let context = Self::read_from(&mut cursor)?; + if cursor.position() as usize != bytes.len() { + return Err(eyre::eyre!( + "program context has {} trailing bytes", + bytes.len() - cursor.position() as usize + )); + } + Ok(context) + } + + pub fn read_from(reader: &mut R) -> eyre::Result { + let constants = read_vec(reader, "constant", V::from_bytes)?; + let strings = read_vec(reader, "string", read_string)?; + let functions = read_vec(reader, "function", read_function_info)?; + let labels = read_vec(reader, "label", read_label_info)?; + let main_function = read_optional_u32(reader)?; + let file = reader.read_u32::()?; + let source_symbols = read_vec(reader, "source symbol", read_source_symbol_info)?; + + Ok(Self { + constants, + strings, + functions, + labels, + main_function, + file, + source_symbols, + }) + } +} + +impl BytecodeContext for ProgramContext +where + V: FromBytes, + Ty: FromBytes, +{ + fn from_bytes(bytes: &[u8]) -> eyre::Result { + ProgramContext::from_bytes(bytes) + } + + fn section_name(&self, index: u32) -> Option<&str> { + self.strings.get(index as usize).map(String::as_str) + } +} + +impl ProgramGlobals for ProgramContext +where + Ty: Clone, +{ + type Type = Ty; + type Value = V; + + fn get_function(&self, index: usize) -> eyre::Result> { + self.functions.get(index).cloned().ok_or_else(|| { + eyre::eyre!(format!( + "function index out of bounds: {} (max {})", + index, + self.functions.len() + )) + }) + } + + fn get_string(&self, index: usize) -> eyre::Result<&String> { + self.strings.get(index).ok_or_else(|| { + eyre::eyre!(format!( + "string index out of bounds: {} (max {})", + index, + self.strings.len() + )) + }) + } + + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value> { + self.constants.get(id.0 as usize).ok_or_else(|| { + eyre::eyre!(format!( + "constant index out of bounds: {} (max {})", + id.0, + self.constants.len() + )) + }) + } +} + +/// The public handle for a bytecode context. +/// +/// To avoid needing explicit lifetimes permeating throughout +/// machine definitions, we wrap the context in an [`Arc`] to drop it +/// automatically. +#[derive(Debug)] +pub struct BytecodeContextHandle(Arc); + +impl Clone for BytecodeContextHandle { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl BytecodeContextHandle { + pub fn new(context: C) -> Self { + Self(Arc::new(context)) + } + + pub fn get(&self) -> &C { + &self.0 + } + + pub fn ptr_eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl std::ops::Deref for BytecodeContextHandle { + type Target = C; + + fn deref(&self) -> &Self::Target { + self.get() + } +} + +fn read_vec(reader: &mut R, label: &str, mut read_item: F) -> eyre::Result> +where + R: Read, + F: FnMut(&mut R) -> eyre::Result, +{ + let count = reader.read_u32::()? as usize; + let mut values = Vec::with_capacity(count); + for index in 0..count { + values.push( + read_item(reader) + .map_err(|err| eyre::eyre!("failed to read {label} table entry {index}: {err}"))?, + ); + } + Ok(values) +} + +fn read_string(reader: &mut R) -> eyre::Result { + let len = reader.read_u32::()? as usize; + let mut bytes = vec![0; len]; + reader.read_exact(&mut bytes)?; + Ok(String::from_utf8(bytes)?) +} + +fn read_function_info(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let name = reader.read_u32::()?; + let signature = read_signature(reader)?; + let local_count = reader.read_u32::()?; + let start_address = reader.read_u32::()?; + let end_address = reader.read_u32::()?; + let file = reader.read_u32::()?; + + Ok(FunctionInfo { + name, + signature, + local_count, + start_address, + end_address, + file, + }) +} + +fn read_signature(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let params = read_vec(reader, "parameter", read_parameter)?; + let ret = read_vec(reader, "return type", Ty::from_bytes)?; + Ok(Signature { params, ret }) +} + +fn read_parameter(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let name = reader.read_u32::()?; + let ty = Ty::from_bytes(reader)?; + Ok(Parameter { name, ty }) +} + +fn read_label_info(reader: &mut R) -> eyre::Result { + let address = reader.read_u32::()?; + let name = reader.read_u32::()?; + Ok(LabelInfo { address, name }) +} + +fn read_source_symbol_info(reader: &mut R) -> eyre::Result { + let index = reader.read_u32::()?; + let name = read_string(reader)?; + Ok(SourceSymbolInfo { index, name }) +} + +fn read_optional_u32(reader: &mut R) -> eyre::Result> { + match reader.read_u8()? { + 0 => Ok(None), + 1 => Ok(Some(reader.read_u32::()?)), + other => Err(eyre::eyre!( + "invalid optional u32 discriminant {} in program context", + other + )), + } +} diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs new file mode 100644 index 0000000..62cd352 --- /dev/null +++ b/crates/vihaco/src/binary/file.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::io::Cursor; + +use super::{ + context::{BytecodeContext, BytecodeContextHandle, ProgramContext}, + format::BytecodeHeader, + parser::{SectionParseInfo, checked_add, parse_section}, + section::{SectionNode, SectionPath, SectionView, SectionWalk}, +}; + +/// A parsed bytecode file. +/// +/// This connects the raw bytes of the file with the parsed global context +/// and the tree of section nodes. +#[derive(Debug, Clone)] +pub struct BytecodeFile { + bytes: Vec, + context: BytecodeContextHandle, + root: SectionNode, +} + +impl BytecodeFile { + /// The public entry point for the parsing of a bytecode file. + /// + /// This will automatically split a multi-section file into + /// each individual section node and send it to its corresponding + /// composite loader marked with `#[program]`. + pub fn from_bytes(bytes: Vec) -> eyre::Result + where + C: BytecodeContext, + { + let mut cursor = Cursor::new(bytes.as_slice()); + let header = BytecodeHeader::read_from(&mut cursor)?; + + let context_start = BytecodeHeader::ENCODED_LEN; + let context_end = checked_add(context_start, header.context_len, "program context end")?; + let context = C::from_bytes( + bytes + .get(context_start..context_end) + .ok_or_else(|| eyre::eyre!("program context is out of bounds"))?, + )?; + let root = parse_section( + &bytes, + &context, + SectionParseInfo { + start: context_end, + path: SectionPath::root(), + }, + )?; + if root.section.end != bytes.len() { + return Err(eyre::eyre!( + "bytecode length mismatch: root section describes {} bytes, file has {} bytes", + root.section.end, + bytes.len() + )); + } + + Ok(Self { + bytes, + context: BytecodeContextHandle::new(context), + root, + }) + } + + pub fn context(&self) -> &C { + self.context.get() + } + + pub fn context_handle(&self) -> BytecodeContextHandle { + self.context.clone() + } + + /// The view of the root of the section tree. + pub fn root(&self) -> SectionView<'_, C> { + SectionView { + bytes: &self.bytes, + context: self.context.clone(), + node: &self.root, + } + } + + /// Walk every section in this file in depth-first order. + /// + /// The first yielded section is always the root section. + pub fn sections(&self) -> SectionWalk<'_, C> + where + C: BytecodeContext, + { + self.root().walk() + } +} diff --git a/crates/vihaco/src/binary/format.rs b/crates/vihaco/src/binary/format.rs new file mode 100644 index 0000000..b7ef5dc --- /dev/null +++ b/crates/vihaco/src/binary/format.rs @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::io::{Cursor, Read}; + +use byteorder::{LittleEndian, ReadBytesExt}; + +use crate::traits::{FromBytes, Instruction, OpCode, WriteBytes}; + +pub trait CompositeHeader: Sized + FromBytes + WriteBytes {} + +impl CompositeHeader for T where T: Sized + FromBytes + WriteBytes {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ConstantId(pub u32); + +impl From for ConstantId { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: ConstantId) -> Self { + value.0 + } +} + +impl FromBytes for ConstantId { + fn from_bytes(bytes: &mut R) -> eyre::Result { + Ok(Self(bytes.read_u32::()?)) + } +} + +impl WriteBytes for ConstantId { + fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { + use byteorder::WriteBytesExt; + + io.write_u32::(self.0)?; + Ok(()) + } +} + +impl OpCode for ConstantId { + fn width() -> u32 { + 4 + } + + fn opcode(&self) -> u8 { + 0 + } +} + +impl std::fmt::Display for ConstantId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "#{}", self.0) + } +} + +/// The vihaco bytecode file header. +/// +/// The file header only frames the shared [`ProgramContext`], with the root section +/// starting immediately after the context. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BytecodeHeader { + pub(super) context_len: usize, +} + +impl BytecodeHeader { + const MAGIC: &'static [u8; 4] = b"VHBC"; + const VERSION: u16 = 1; + + /// Currently unused in version 1, and we will reject any binary + /// that has flags set + const FLAGS: u16 = 0; + + const CONTEXT_LEN_SIZE: usize = 8; + + pub(super) const ENCODED_LEN: usize = 4 + 2 + 2 + Self::CONTEXT_LEN_SIZE; + + pub(super) fn read_from(reader: &mut R) -> eyre::Result { + let mut magic = [0; 4]; + reader.read_exact(&mut magic)?; + if &magic != Self::MAGIC { + return Err(eyre::eyre!("invalid bytecode magic")); + } + + let version = reader.read_u16::()?; + if version != Self::VERSION { + return Err(eyre::eyre!( + "unsupported bytecode version {} (expected {})", + version, + VERSION + )); + } + + let flags = reader.read_u16::()?; + if flags != Self::FLAGS { + return Err(eyre::eyre!("unsupported bytecode flags 0x{flags:04X}")); + } + + Ok(Self { + context_len: read_usize_u64(reader, "program context length")?, + }) + } +} + +pub const MAGIC: &[u8; 4] = BytecodeHeader::MAGIC; +pub const VERSION: u16 = BytecodeHeader::VERSION; +pub const FLAGS: u16 = BytecodeHeader::FLAGS; + +/// The frame data for a section in a bytecode file. +/// +/// The section length describes the length of the entire section, starting +/// from the beginning of the section frame to the end of the last child +/// section. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SectionFrame { + pub(super) section_len: usize, + pub(super) composite_header_len: usize, +} + +impl SectionFrame { + pub(super) const SECTION_LEN_SIZE: usize = 8; + pub(super) const HEADER_LEN_SIZE: usize = 8; + + pub(super) const ENCODED_LEN: usize = Self::SECTION_LEN_SIZE + Self::HEADER_LEN_SIZE; + + pub(super) fn read_from(reader: &mut R) -> eyre::Result { + Ok(Self { + section_len: read_usize_u64(reader, "section length")?, + composite_header_len: read_usize_u64(reader, "composite header length")?, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct SectionBytecodeHeader { + pub(super) bytecode_len: usize, +} + +impl SectionBytecodeHeader { + pub(super) const BYTECODE_LEN_SIZE: usize = 8; + + pub(super) const ENCODED_LEN: usize = Self::BYTECODE_LEN_SIZE; + + pub(super) fn read_from(reader: &mut R) -> eyre::Result { + Ok(Self { + bytecode_len: read_usize_u64(reader, "bytecode length")?, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ChildSectionTableHeader { + pub(super) child_count: usize, +} + +impl ChildSectionTableHeader { + // Note: this is stored as a `usize` within the `ChildSectionTableHeader` + // struct but will be 4 bytes (a `u32`) in the actual binary. + pub(super) const CHILD_COUNT_SIZE: usize = 4; + + pub(super) const ENCODED_LEN: usize = Self::CHILD_COUNT_SIZE; + + pub(super) fn read_from(reader: &mut R) -> eyre::Result { + Ok(Self { + child_count: reader.read_u32::()? as usize, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ChildSectionTableEntry { + pub(super) local_name_string: u32, + pub(super) section_offset: usize, +} + +impl ChildSectionTableEntry { + pub(super) const LOCAL_NAME_STRING_SIZE: usize = 4; + pub(super) const SECTION_OFFSET_SIZE: usize = 8; + + pub(super) const ENCODED_LEN: usize = Self::LOCAL_NAME_STRING_SIZE + Self::SECTION_OFFSET_SIZE; + + pub(super) fn read_from(reader: &mut R) -> eyre::Result { + Ok(Self { + local_name_string: reader.read_u32::()?, + section_offset: read_usize_u64(reader, "child section offset")?, + }) + } +} + +pub fn decode_instruction_stream(bytes: &[u8]) -> eyre::Result> { + let width = I::width() as usize; + if width == 0 { + if bytes.is_empty() { + return Ok(Vec::new()); + } + return Err(eyre::eyre!( + "cannot decode {} byte(s) into zero-width instructions", + bytes.len() + )); + } + if bytes.len() % width != 0 { + return Err(eyre::eyre!( + "bytecode length {} is not a multiple of instruction width {}", + bytes.len(), + width + )); + } + + let mut cursor = Cursor::new(bytes); + let mut code = Vec::with_capacity(bytes.len() / width); + while cursor.position() as usize != bytes.len() { + code.push(I::from_bytes(&mut cursor)?); + } + Ok(code) +} + +fn read_usize_u64(reader: &mut R, label: &str) -> eyre::Result { + usize::try_from(reader.read_u64::()?) + .map_err(|_| eyre::eyre!("{label} does not fit in usize")) +} diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs new file mode 100644 index 0000000..af57b04 --- /dev/null +++ b/crates/vihaco/src/binary/mod.rs @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +mod context; +mod file; +mod format; +mod parser; +mod section; + +#[cfg(test)] +mod tests; + +pub use context::{BytecodeContext, BytecodeContextHandle, ProgramContext, ProgramGlobals}; +pub use file::BytecodeFile; +pub use format::{CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION, decode_instruction_stream}; +pub use section::{SectionPath, SectionPathDisplay, SectionView, SectionWalk}; diff --git a/crates/vihaco/src/binary/parser.rs b/crates/vihaco/src/binary/parser.rs new file mode 100644 index 0000000..35ad10f --- /dev/null +++ b/crates/vihaco/src/binary/parser.rs @@ -0,0 +1,415 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::{ + collections::BTreeSet, + io::{Cursor, Read}, + ops::Range, +}; + +use super::{ + context::BytecodeContext, + format::{ + ChildSectionTableEntry, ChildSectionTableHeader, SectionBytecodeHeader, SectionFrame, + }, + section::{SectionNode, SectionPath}, +}; + +impl SectionFrame { + fn read_at( + bytes: &[u8], + section_start: usize, + path: &SectionPath, + context: &C, + ) -> eyre::Result + where + C: BytecodeContext, + { + let frame_end = checked_add(section_start, Self::ENCODED_LEN, "section frame end")?; + let frame_bytes = bytes.get(section_start..frame_end).ok_or_else(|| { + eyre::eyre!( + "section `{}` does not contain a complete section frame", + path.display(context) + ) + })?; + let mut cursor = Cursor::new(frame_bytes); + Self::read_from(&mut cursor) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SectionParseInfo { + pub(super) start: usize, + pub(super) path: SectionPath, +} + +/// Parse a section of a bytecode file. +/// +/// The vihaco bytecode binary is organized so the structure of +/// the section reads easily from this function. +/// +/// All logic related to the parsing of the current section should +/// come _before_ the parsing logic of its children. +/// +/// This function is called recursively, albeit with some intermediate stops along the +/// way related to the validation of child sections within its parent section's bounds; +/// this follows naturally from the tree structure of the sections. +pub(super) fn parse_section( + bytes: &[u8], + context: &C, + info: SectionParseInfo, +) -> eyre::Result +where + C: BytecodeContext, +{ + let SectionParseInfo { + start: section_start, + path, + } = info; + + let frame = SectionFrame::read_at(bytes, section_start, &path, context)?; + let section_end = checked_add(section_start, frame.section_len, "section end")?; + if section_end > bytes.len() { + return Err(eyre::eyre!( + "section `{}` extends past end of bytecode", + path.display(context) + )); + } + if frame.composite_header_len > frame.section_len { + return Err(eyre::eyre!( + "section `{}` composite header length {} exceeds section length {}", + path.display(context), + frame.composite_header_len, + frame.section_len + )); + } + + let composite_header_start = checked_add( + section_start, + SectionFrame::ENCODED_LEN, + "composite header start", + )?; + let composite_header_end = checked_add( + composite_header_start, + frame.composite_header_len, + "composite header end", + )?; + + // we generate a call to SectionView::parse_header + // if the composite contains a `#[header]` + let composite_header = composite_header_start..composite_header_end; + + let bytecode_header_start = composite_header.end; + if checked_add( + bytecode_header_start, + SectionBytecodeHeader::ENCODED_LEN, + "section bytecode header end", + )? > section_end + { + return Err(eyre::eyre!( + "section `{}` does not contain a complete bytecode header", + path.display(context) + )); + } + + let mut bytecode_header_cursor = Cursor::new(&bytes[bytecode_header_start..section_end]); + let bytecode_header = SectionBytecodeHeader::read_from(&mut bytecode_header_cursor)?; + let bytecode_start = checked_add( + bytecode_header_start, + SectionBytecodeHeader::ENCODED_LEN, + "bytecode start", + )?; + let bytecode_end = checked_add(bytecode_start, bytecode_header.bytecode_len, "bytecode end")?; + if bytecode_end > section_end { + return Err(eyre::eyre!( + "section `{}` bytecode extends past section end", + path.display(context) + )); + } + + let child_table_start = bytecode_end; + if checked_add( + child_table_start, + ChildSectionTableHeader::ENCODED_LEN, + "child section table header end", + )? > section_end + { + return Err(eyre::eyre!( + "section `{}` does not contain a complete child section table header", + path.display(context) + )); + } + + let mut child_table_cursor = Cursor::new(&bytes[child_table_start..section_end]); + let child_table_header = ChildSectionTableHeader::read_from(&mut child_table_cursor)?; + let total_len_of_child_section_entries = child_table_header + .child_count + .checked_mul(ChildSectionTableEntry::ENCODED_LEN) + .ok_or_else(|| { + eyre::eyre!( + "section `{}` child table length overflows usize", + path.display(context) + ) + })?; + let expected_child_table_len = checked_add( + ChildSectionTableHeader::ENCODED_LEN, + total_len_of_child_section_entries, + "child section table length", + )?; + if checked_add( + child_table_start, + expected_child_table_len, + "child section table end", + )? > section_end + { + return Err(eyre::eyre!( + "section `{}` does not contain a complete child section table", + path.display(context) + )); + } + + let child_section_entries = read_child_section_table( + &mut child_table_cursor, + child_table_header.child_count, + &path, + context, + )?; + + let child_table_len = child_table_cursor.position() as usize; + let child_table_end = checked_add( + child_table_start, + child_table_len, + "child section table end", + )?; + + let mut claimed_ranges = ClaimedSectionRanges::new(bytecode_start..child_table_end); + let mut children = Vec::with_capacity(child_section_entries.len()); + for child in child_section_entries { + children.push(parse_child_section( + bytes, + context, + ChildSectionParseInfo { + parent_path: &path, + parent_start: section_start, + parent_end: section_end, + parent_child_table_end: child_table_end, + claimed_ranges: &mut claimed_ranges, + child, + }, + )?); + } + + Ok(SectionNode { + path, + section: section_start..section_end, + header: composite_header, + bytecode: bytecode_start..bytecode_end, + children, + }) +} + +/// Represents a [`ChildSectionTableEntry`] with its name; +/// we use this for error reporting. +struct ResolvedChildSectionTableEntry { + name: String, + entry: ChildSectionTableEntry, +} + +fn read_child_section_table( + reader: &mut R, + child_count: usize, + parent_path: &SectionPath, + context: &C, +) -> eyre::Result> +where + R: Read, + C: BytecodeContext, +{ + let mut children = Vec::with_capacity(child_count); + let mut names = BTreeSet::new(); + for _ in 0..child_count { + let entry = ChildSectionTableEntry::read_from(reader)?; + let child_name = context + .section_name(entry.local_name_string) + .ok_or_else(|| { + eyre::eyre!( + "section `{}` references missing section name string {}", + parent_path.display(context), + entry.local_name_string + ) + })? + .to_string(); + validate_local_section_name(parent_path, context, &child_name)?; + if !names.insert(child_name.clone()) { + return Err(eyre::eyre!( + "section `{}` declares duplicate child `{}`", + parent_path.display(context), + child_name + )); + } + + children.push(ResolvedChildSectionTableEntry { + name: child_name, + entry, + }); + } + Ok(children) +} + +/// The claimed ranges within a section. +/// +/// This helper struct encapsulates the constraint that no ranges in a section's +/// data should overlap. For example, child data cannot overlap with other child data +/// or its parent's bytecode. +struct ClaimedSectionRanges { + ranges: Vec<(String, Range)>, +} + +impl ClaimedSectionRanges { + fn new(parent_data: Range) -> Self { + Self { + ranges: vec![("parent data".to_string(), parent_data)], + } + } + + fn claim_child( + &mut self, + parent_path: &SectionPath, + context: &C, + child_name: String, + range: Range, + ) -> eyre::Result<()> + where + C: BytecodeContext, + { + for (existing_name, existing) in &self.ranges { + if ranges_overlap(existing, &range) { + return Err(eyre::eyre!( + "section `{}` child `{}` overlaps `{}`", + parent_path.display(context), + child_name, + existing_name + )); + } + } + self.ranges.push((child_name, range)); + Ok(()) + } +} + +struct ChildSectionParseInfo<'a> { + parent_path: &'a SectionPath, + parent_start: usize, + parent_end: usize, + parent_child_table_end: usize, + claimed_ranges: &'a mut ClaimedSectionRanges, + child: ResolvedChildSectionTableEntry, +} + +/// This performs all the logic related to validating a child's bounds against +/// its parent's bounds, then recursively parses the child. +fn parse_child_section( + bytes: &[u8], + context: &C, + info: ChildSectionParseInfo<'_>, +) -> eyre::Result +where + C: BytecodeContext, +{ + let ChildSectionParseInfo { + parent_path, + parent_start, + parent_end, + parent_child_table_end, + claimed_ranges, + child, + } = info; + let ResolvedChildSectionTableEntry { + name: child_name, + entry, + } = child; + + let child_start = checked_add(parent_start, entry.section_offset, "child section start")?; + let child_path = parent_path.child(entry.local_name_string); + if child_start < parent_child_table_end { + return Err(eyre::eyre!( + "section `{}` child `{}` begins before parent child table ends", + parent_path.display(context), + child_name + )); + } + if child_start >= parent_end { + return Err(eyre::eyre!( + "section `{}` child `{}` extends past section end", + parent_path.display(context), + child_name + )); + } + if checked_add( + child_start, + SectionFrame::ENCODED_LEN, + "child section frame end", + )? > parent_end + { + return Err(eyre::eyre!( + "section `{}` child `{}` extends past section end", + parent_path.display(context), + child_name + )); + } + + let child_frame = SectionFrame::read_at(bytes, child_start, &child_path, context)?; + let child_end = checked_add(child_start, child_frame.section_len, "child section end")?; + if child_end > parent_end { + return Err(eyre::eyre!( + "section `{}` child `{}` extends past section end", + parent_path.display(context), + child_name + )); + } + + let range = child_start..child_end; + claimed_ranges.claim_child(parent_path, context, child_name, range)?; + + parse_section( + bytes, + context, + SectionParseInfo { + start: child_start, + path: child_path, + }, + ) +} + +fn validate_local_section_name( + parent: &SectionPath, + context: &C, + child: &str, +) -> eyre::Result<()> +where + C: BytecodeContext, +{ + if child.is_empty() { + return Err(eyre::eyre!( + "section `{}` has an empty child name", + parent.display(context) + )); + } + if child.contains('/') { + return Err(eyre::eyre!( + "section `{}` child name `{}` must be a local name", + parent.display(context), + child + )); + } + Ok(()) +} + +pub(super) fn checked_add(lhs: usize, rhs: usize, label: &str) -> eyre::Result { + lhs.checked_add(rhs) + .ok_or_else(|| eyre::eyre!("{label} overflows usize")) +} + +fn ranges_overlap(lhs: &Range, rhs: &Range) -> bool { + lhs.start < rhs.end && rhs.start < lhs.end +} diff --git a/crates/vihaco/src/binary/section.rs b/crates/vihaco/src/binary/section.rs new file mode 100644 index 0000000..31335fa --- /dev/null +++ b/crates/vihaco/src/binary/section.rs @@ -0,0 +1,257 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::{io::Cursor, ops::Range}; + +use super::{ + context::{BytecodeContext, BytecodeContextHandle, ProgramContext}, + format::CompositeHeader, +}; + +/// The fully resolved path for a given section. +/// +/// The root section will be empty. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SectionPath { + components: Vec, +} + +impl SectionPath { + pub fn root() -> Self { + Self { + components: Vec::new(), + } + } + + pub fn is_root(&self) -> bool { + self.components.is_empty() + } + + pub fn components(&self) -> &[u32] { + &self.components + } + + pub fn local_name(&self) -> Option { + self.components.last().copied() + } + + pub fn child(&self, local_name: u32) -> Self { + let mut components = self.components.clone(); + components.push(local_name); + Self { components } + } + + pub fn display<'a, C>(&'a self, context: &'a C) -> SectionPathDisplay<'a, C> { + SectionPathDisplay { + path: self, + context, + } + } +} + +impl Default for SectionPath { + fn default() -> Self { + Self::root() + } +} + +pub struct SectionPathDisplay<'a, C = ProgramContext> { + path: &'a SectionPath, + context: &'a C, +} + +impl std::fmt::Display for SectionPathDisplay<'_, C> +where + C: BytecodeContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.path.is_root() { + return f.write_str(""); + } + + for (index, component) in self.path.components.iter().enumerate() { + if index != 0 { + f.write_str("/")?; + } + match self.context.section_name(*component) { + Some(name) => f.write_str(name)?, + None => write!(f, "", component)?, + } + } + Ok(()) + } +} + +impl std::fmt::Debug for SectionPathDisplay<'_, C> +where + C: BytecodeContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(self, f) + } +} + +/// The internal parser representation of a section. +/// +/// For the public handle of a section, see [`SectionView`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct SectionNode { + pub path: SectionPath, + pub section: Range, + pub header: Range, + pub bytecode: Range, + pub children: Vec, +} + +/// The public handle of a bytecode section. +/// +/// This is a lightweight view into information owned by [`BytecodeFile`]. +pub struct SectionView<'bc, C = ProgramContext> { + pub(super) bytes: &'bc [u8], + pub(super) context: BytecodeContextHandle, + pub(super) node: &'bc SectionNode, +} + +impl<'bc, C> Clone for SectionView<'bc, C> { + fn clone(&self) -> Self { + Self { + bytes: self.bytes, + context: self.context.clone(), + node: self.node, + } + } +} + +impl<'bc, C> SectionView<'bc, C> +where + C: BytecodeContext, +{ + pub fn path(&self) -> &'bc SectionPath { + &self.node.path + } + + pub fn display_path(&self) -> SectionPathDisplay<'_, C> { + self.node.path.display(self.context.get()) + } + + pub fn context(&self) -> &C { + self.context.get() + } + + pub fn context_handle(&self) -> BytecodeContextHandle { + self.context.clone() + } + + pub fn header_bytes(&self) -> &'bc [u8] { + &self.bytes[self.node.header.clone()] + } + + pub fn bytecode(&self) -> &'bc [u8] { + &self.bytes[self.node.bytecode.clone()] + } + + pub fn children(&self) -> impl Iterator> + '_ { + self.node.children.iter().map(|node| SectionView { + bytes: self.bytes, + context: self.context.clone(), + node, + }) + } + + /// Walk this section and all of its descendants in depth-first order. + /// + /// The first yielded section is always `self`. + pub fn walk(&self) -> SectionWalk<'bc, C> { + SectionWalk { + bytes: self.bytes, + context: self.context.clone(), + stack: vec![self.node], + } + } + + /// Walk all descendants of this section in depth-first order. + /// + /// Unlike [`walk`](Self::walk), this does not yield `self`. + pub fn descendants(&self) -> SectionWalk<'bc, C> { + SectionWalk { + bytes: self.bytes, + context: self.context.clone(), + stack: self.node.children.iter().rev().collect(), + } + } + + pub fn child(&self, local_name: &str) -> Option> { + self.node + .children + .iter() + .find(|child| { + child + .path + .local_name() + .and_then(|name| self.context.section_name(name)) + .is_some_and(|name| name == local_name) + }) + .map(|node| SectionView { + bytes: self.bytes, + context: self.context.clone(), + node, + }) + } + + pub fn local_name(&self) -> Option<&str> { + self.node + .path + .local_name() + .and_then(|name| self.context.section_name(name)) + } + + /// Parse the specified composite header from the raw header bytes. + pub fn parse_header(&self) -> eyre::Result { + let bytes = self.header_bytes(); + let mut cursor = Cursor::new(bytes); + let header = H::from_bytes(&mut cursor)?; + if cursor.position() as usize != bytes.len() { + return Err(eyre::eyre!( + "section `{}` header has {} trailing bytes", + self.display_path(), + bytes.len() - cursor.position() as usize + )); + } + Ok(header) + } +} + +/// A depth-first iterator over a section subtree. +pub struct SectionWalk<'bc, C = ProgramContext> { + bytes: &'bc [u8], + context: BytecodeContextHandle, + stack: Vec<&'bc SectionNode>, +} + +impl<'bc, C> Iterator for SectionWalk<'bc, C> { + type Item = SectionView<'bc, C>; + + fn next(&mut self) -> Option { + let node = self.stack.pop()?; + self.stack.extend(node.children.iter().rev()); + Some(SectionView { + bytes: self.bytes, + context: self.context.clone(), + node, + }) + } +} + +impl std::fmt::Debug for SectionView<'_, C> +where + C: BytecodeContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SectionView") + .field("path", &self.display_path().to_string()) + .field("header_len", &self.header_bytes().len()) + .field("bytecode_len", &self.bytecode().len()) + .field("child_count", &self.node.children.len()) + .finish() + } +} diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs new file mode 100644 index 0000000..1e02554 --- /dev/null +++ b/crates/vihaco/src/binary/tests.rs @@ -0,0 +1,501 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use super::format::{ + ChildSectionTableEntry, ChildSectionTableHeader, SectionBytecodeHeader, SectionFrame, +}; +use super::*; +use crate::{ + traits::{FromBytes, WriteBytes}, + value::{Type, Value}, +}; +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use std::io::Read; + +#[derive(Debug, Clone, PartialEq, crate::Instruction)] +enum TestInst { + Nop, + Load(ConstantId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CustomValue(u8); + +impl FromBytes for CustomValue { + fn from_bytes(bytes: &mut R) -> eyre::Result { + Ok(Self(bytes.read_u8()?)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CustomType(u8); + +impl FromBytes for CustomType { + fn from_bytes(bytes: &mut R) -> eyre::Result { + Ok(Self(bytes.read_u8()?)) + } +} + +#[derive(Debug, Clone, PartialEq)] +struct WrappedContext { + inner: ProgramContext, +} + +impl BytecodeContext for WrappedContext { + fn from_bytes(bytes: &[u8]) -> eyre::Result { + Ok(Self { + inner: ProgramContext::from_bytes(bytes)?, + }) + } + + fn section_name(&self, index: u32) -> Option<&str> { + self.inner.section_name(index) + } +} + +#[test] +fn parses_context_and_nested_sections() { + const CPU_NAME: u32 = 1; + const ALU_NAME: u32 = 2; + + let context = context_bytes(); + let alu_header = b"alu header"; + let cpu_header = b"cpu header"; + let root_header = b"root header"; + let alu = section_bytes(alu_header, &[TestInst::Nop], vec![]); + let cpu = section_bytes( + cpu_header, + &[TestInst::Load(ConstantId(0))], + vec![(ALU_NAME, alu)], + ); + let root = section_bytes(root_header, &[], vec![(CPU_NAME, cpu)]); + let file = file_bytes(context, root); + + let parsed: BytecodeFile = BytecodeFile::from_bytes(file).unwrap(); + + assert_eq!(parsed.context().constants, vec![Value::I64(42)]); + assert_eq!( + parsed.context().strings, + vec!["main".to_string(), "cpu".to_string(), "alu".to_string()] + ); + assert_eq!(parsed.context().main_function, Some(0)); + assert_eq!(parsed.context().file, 7); + + let root = parsed.root(); + assert!(root.path().is_root()); + assert_eq!(root.path().components(), &[]); + assert_eq!(root.local_name(), None); + assert_eq!(root.display_path().to_string(), ""); + assert_eq!(root.header_bytes(), b"root header"); + + let cpu = root.child("cpu").unwrap(); + assert_eq!(cpu.path().components(), &[CPU_NAME]); + assert_eq!(cpu.local_name(), Some("cpu")); + assert_eq!(cpu.display_path().to_string(), "cpu"); + assert_eq!(cpu.header_bytes(), b"cpu header"); + assert_eq!( + decode_instruction_stream::(cpu.bytecode()).unwrap(), + vec![TestInst::Load(ConstantId(0))] + ); + + let alu = cpu.child("alu").unwrap(); + assert_eq!(alu.path().components(), &[CPU_NAME, ALU_NAME]); + assert_eq!(alu.local_name(), Some("alu")); + assert_eq!(alu.display_path().to_string(), "cpu/alu"); + assert_eq!(alu.header_bytes(), b"alu header"); + assert_eq!( + decode_instruction_stream::(alu.bytecode()).unwrap(), + vec![TestInst::Nop] + ); +} + +#[test] +fn parses_file_with_custom_context_representation() { + const CPU_NAME: u32 = 1; + + let cpu = section_bytes(b"", &[TestInst::Nop], vec![]); + let root = section_bytes(b"", &[], vec![(CPU_NAME, cpu)]); + let parsed: BytecodeFile = + BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + + assert_eq!(parsed.context().inner.constants, vec![Value::I64(42)]); + assert_eq!( + parsed.root().child("cpu").unwrap().local_name(), + Some("cpu") + ); +} + +#[test] +fn walks_section_tree_depth_first() { + const CPU_NAME: u32 = 0; + const ALU_NAME: u32 = 1; + const GPU_NAME: u32 = 2; + + let alu = section_bytes(b"", &[TestInst::Load(ConstantId(0))], vec![]); + let cpu = section_bytes(b"", &[TestInst::Nop], vec![(ALU_NAME, alu)]); + let gpu = section_bytes(b"", &[], vec![]); + let root = section_bytes(b"", &[], vec![(CPU_NAME, cpu), (GPU_NAME, gpu)]); + let parsed: BytecodeFile = BytecodeFile::from_bytes(file_bytes( + context_with_strings(&["cpu", "alu", "gpu"]), + root, + )) + .unwrap(); + + let paths = parsed + .sections() + .map(|section| section.display_path().to_string()) + .collect::>(); + + assert_eq!(paths, vec!["", "cpu", "cpu/alu", "gpu"]); + + let descendant_paths = parsed + .root() + .descendants() + .map(|section| section.display_path().to_string()) + .collect::>(); + + assert_eq!(descendant_paths, vec!["cpu", "cpu/alu", "gpu"]); +} + +#[test] +fn parses_context_with_custom_value_and_type_tables() { + let context = custom_context_bytes(); + let root = section_bytes(b"", &[], vec![]); + let parsed = BytecodeFile::>::from_bytes(file_bytes( + context, root, + )) + .unwrap(); + + assert_eq!(parsed.context().constants, vec![CustomValue(7)]); + assert_eq!(parsed.context().functions.len(), 1); + assert_eq!( + parsed.context().functions[0].signature.params[0].ty, + CustomType(3) + ); + assert_eq!( + parsed.context().functions[0].signature.ret, + vec![CustomType(4)] + ); +} + +#[test] +fn parse_header_consumes_the_whole_header() { + #[derive(Debug, PartialEq)] + struct Header(u32); + + impl FromBytes for Header { + fn from_bytes(bytes: &mut R) -> eyre::Result { + Ok(Header(bytes.read_u32::()?)) + } + } + + impl WriteBytes for Header { + fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { + io.write_u32::(self.0)?; + Ok(()) + } + } + + let mut header = Vec::new(); + header.write_u32::(99).unwrap(); + let root = section_bytes(&header, &[], vec![]); + let parsed: BytecodeFile = + BytecodeFile::from_bytes(file_bytes(empty_context_bytes(), root)).unwrap(); + + assert_eq!(parsed.root().parse_header::
().unwrap(), Header(99)); +} + +#[test] +fn rejects_bad_magic() { + let mut bytes = file_bytes(empty_context_bytes(), section_bytes(b"", &[], vec![])); + bytes[0] = b'X'; + + let err = BytecodeFile::>::from_bytes(bytes).unwrap_err(); + assert!(err.to_string().contains("invalid bytecode magic")); +} + +#[test] +fn rejects_missing_section_name_string() { + let root = raw_section(b"", b"", vec![(0, b"")]); + let err = BytecodeFile::>::from_bytes(file_bytes( + empty_context_bytes(), + root, + )) + .unwrap_err(); + + assert!(err.to_string().contains("missing section name string")); +} + +#[test] +fn rejects_duplicate_child_names() { + let child_a = section_bytes(b"", &[], vec![]); + let child_b = section_bytes(b"", &[], vec![]); + let root = raw_section(b"", b"", vec![(0, &child_a), (0, &child_b)]); + + let err = BytecodeFile::>::from_bytes(file_bytes( + context_with_strings(&["cpu"]), + root, + )) + .unwrap_err(); + + assert!(err.to_string().contains("duplicate child")); +} + +#[test] +fn rejects_out_of_bounds_child_section() { + let root = raw_section_with_entry_offsets(b"", b"", vec![(0, 999, b"")]); + + let err = BytecodeFile::>::from_bytes(file_bytes( + context_with_strings(&["cpu"]), + root, + )) + .unwrap_err(); + + assert!(err.to_string().contains("extends past section end")); +} + +#[test] +fn rejects_overlapping_child_sections() { + let child = section_bytes(b"", &[], vec![]); + let child_offset = (SectionFrame::ENCODED_LEN + + SectionBytecodeHeader::ENCODED_LEN + + ChildSectionTableHeader::ENCODED_LEN + + (2 * ChildSectionTableEntry::ENCODED_LEN)) as u64; + let root = raw_section_with_entry_offsets( + b"", + b"", + vec![(0, child_offset, &child), (1, child_offset, &[])], + ); + + let err = BytecodeFile::>::from_bytes(file_bytes( + context_with_strings(&["cpu", "gpu"]), + root, + )) + .unwrap_err(); + + assert!(err.to_string().contains("overlaps")); +} + +#[test] +fn rejects_bytecode_that_extends_past_section_end() { + let mut root = Vec::new(); + root.write_u64::( + (SectionFrame::ENCODED_LEN + SectionBytecodeHeader::ENCODED_LEN) as u64, + ) + .unwrap(); + root.write_u64::(0).unwrap(); + root.write_u64::(1).unwrap(); + + let err = BytecodeFile::>::from_bytes(file_bytes( + empty_context_bytes(), + root, + )) + .unwrap_err(); + + assert!( + err.to_string() + .contains("bytecode extends past section end") + ); +} + +#[test] +fn rejects_non_multiple_instruction_stream() { + let err = decode_instruction_stream::(&[0, 1, 2]).unwrap_err(); + + assert!(err.to_string().contains("not a multiple")); +} + +fn file_bytes(context: Vec, root: Vec) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(MAGIC); + bytes.write_u16::(VERSION).unwrap(); + bytes.write_u16::(FLAGS).unwrap(); + bytes + .write_u64::(context.len() as u64) + .unwrap(); + bytes.extend_from_slice(&context); + bytes.extend_from_slice(&root); + bytes +} + +fn context_bytes() -> Vec { + let mut bytes = Vec::new(); + + bytes.write_u32::(1).unwrap(); + Value::I64(42).write_bytes(&mut bytes).unwrap(); + + bytes.write_u32::(3).unwrap(); + write_string(&mut bytes, "main"); + write_string(&mut bytes, "cpu"); + write_string(&mut bytes, "alu"); + + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(1).unwrap(); + Type::I64.write_bytes(&mut bytes).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(7).unwrap(); + + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + + bytes.write_u8(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(7).unwrap(); + + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(0).unwrap(); + write_string(&mut bytes, "cpu"); + + bytes +} + +fn custom_context_bytes() -> Vec { + let mut bytes = Vec::new(); + + bytes.write_u32::(1).unwrap(); + bytes.write_u8(7).unwrap(); + + bytes.write_u32::(1).unwrap(); + write_string(&mut bytes, "main"); + + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u8(3).unwrap(); + bytes.write_u32::(1).unwrap(); + bytes.write_u8(4).unwrap(); + bytes.write_u32::(2).unwrap(); + bytes.write_u32::(11).unwrap(); + bytes.write_u32::(22).unwrap(); + bytes.write_u32::(5).unwrap(); + + bytes.write_u32::(0).unwrap(); + bytes.write_u8(1).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(5).unwrap(); + bytes.write_u32::(0).unwrap(); + + bytes +} + +fn empty_context_bytes() -> Vec { + context_with_strings(&[]) +} + +fn context_with_strings(strings: &[&str]) -> Vec { + let mut bytes = Vec::new(); + bytes.write_u32::(0).unwrap(); + bytes + .write_u32::(strings.len() as u32) + .unwrap(); + for string in strings { + write_string(&mut bytes, string); + } + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u8(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes +} + +fn section_bytes( + header: &[u8], + instructions: &[TestInst], + children: Vec<(u32, Vec)>, +) -> Vec { + let mut bytecode = Vec::new(); + for inst in instructions { + inst.write_bytes(&mut bytecode).unwrap(); + } + let children = children + .iter() + .map(|(name_index, bytes)| (*name_index, bytes.as_slice())) + .collect(); + raw_section(header, &bytecode, children) +} + +fn raw_section(header: &[u8], bytecode: &[u8], children: Vec<(u32, &[u8])>) -> Vec { + let child_table_len = + ChildSectionTableHeader::ENCODED_LEN + children.len() * ChildSectionTableEntry::ENCODED_LEN; + let bytecode_start = + SectionFrame::ENCODED_LEN + header.len() + SectionBytecodeHeader::ENCODED_LEN; + let mut child_offset = bytecode_start + bytecode.len() + child_table_len; + let section_len = child_offset + children.iter().map(|(_, child)| child.len()).sum::(); + + let mut bytes = Vec::new(); + bytes.write_u64::(section_len as u64).unwrap(); + bytes + .write_u64::(header.len() as u64) + .unwrap(); + bytes.extend_from_slice(header); + bytes + .write_u64::(bytecode.len() as u64) + .unwrap(); + bytes.extend_from_slice(bytecode); + bytes + .write_u32::(children.len() as u32) + .unwrap(); + for (name_index, child) in &children { + bytes.write_u32::(*name_index).unwrap(); + bytes + .write_u64::(child_offset as u64) + .unwrap(); + child_offset += child.len(); + } + for (_, child) in children { + bytes.extend_from_slice(child); + } + bytes +} + +fn raw_section_with_entry_offsets( + header: &[u8], + bytecode: &[u8], + children: Vec<(u32, u64, &[u8])>, +) -> Vec { + let child_table_len = + ChildSectionTableHeader::ENCODED_LEN + children.len() * ChildSectionTableEntry::ENCODED_LEN; + let bytecode_start = + SectionFrame::ENCODED_LEN + header.len() + SectionBytecodeHeader::ENCODED_LEN; + let section_len = bytecode_start + + bytecode.len() + + child_table_len + + children + .iter() + .map(|(_, _, child)| child.len()) + .sum::(); + + let mut bytes = Vec::new(); + bytes.write_u64::(section_len as u64).unwrap(); + bytes + .write_u64::(header.len() as u64) + .unwrap(); + bytes.extend_from_slice(header); + bytes + .write_u64::(bytecode.len() as u64) + .unwrap(); + bytes.extend_from_slice(bytecode); + bytes + .write_u32::(children.len() as u32) + .unwrap(); + for (name_index, offset, _) in &children { + bytes.write_u32::(*name_index).unwrap(); + bytes.write_u64::(*offset).unwrap(); + } + for (_, _, child) in children { + bytes.extend_from_slice(child); + } + bytes +} + +fn write_string(bytes: &mut Vec, value: &str) { + bytes.write_u32::(value.len() as u32).unwrap(); + bytes.extend_from_slice(value.as_bytes()); +} diff --git a/crates/vihaco/src/lib.rs b/crates/vihaco/src/lib.rs index e1aad9f..af4142c 100644 --- a/crates/vihaco/src/lib.rs +++ b/crates/vihaco/src/lib.rs @@ -5,6 +5,7 @@ extern crate self as vihaco; #[doc(hidden)] pub mod __private; +pub mod binary; pub mod color; pub mod effect; pub mod frame; @@ -22,25 +23,32 @@ pub mod syntax; pub mod traits; pub mod value; +pub use binary::{ + BytecodeContext, BytecodeContextHandle, BytecodeFile, CompositeHeader, ConstantId, + ProgramContext, ProgramGlobals, SectionPath, SectionView, SectionWalk, +}; pub use effect::Effects; pub use instruction_syntax::{ CanonicalInstructionSyntax, CanonicalInstructionVariantSyntax, InstructionSugarSyntax, InstructionSugarVariantSyntax, OperandKind, SugarOperandKind, }; -pub use loader::ProgramLoader; +pub use loader::{LoadInput, LoadSection, ModuleProgramLoader, ProgramLoader}; pub use macros::{Instruction, Message, component, composite, observe}; pub use runtime::{ CompositeMetadata, EffectSink, GeneratedComponent, Message as MessageMarker, Observe, expect_exactly_one_effect, }; -pub use traits::Reset; +pub use traits::{GetProgramGlobal, Reset}; pub use value::{Type, Value}; #[cfg(test)] mod public_api_tests { use crate::{ - EffectSink, Effects, GeneratedComponent, Reset, + BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, + Reset, SectionWalk, + binary::ConstantId, instruction::{FromBytes, OpCode, WriteBytes}, + module::FunctionInfo, observer::stdio::StdoutEffect, }; @@ -55,12 +63,22 @@ mod public_api_tests { fn require_effect_sink>() {} fn require_reset() {} fn require_instruction() {} + fn require_bytecode_context() {} + fn require_program_globals() {} + fn require_load_section() {} + fn require_section_walk(_walk: Option>) {} fn require_stdout_effect(_effect: StdoutEffect) {} fn require_metadata(_metadata: crate::CompositeMetadata) {} require_effect_sink::>(); require_reset::(); require_instruction::(); + require_bytecode_context::(); + require_program_globals::(); + require_load_section::>(); + require_section_walk(None); + let _constant = ConstantId(0); + let _function: Option> = None; require_stdout_effect(StdoutEffect(String::new())); require_metadata(crate::CompositeMetadata { devices: &[], diff --git a/crates/vihaco/src/loader.rs b/crates/vihaco/src/loader.rs index 4ab1416..91a4238 100644 --- a/crates/vihaco/src/loader.rs +++ b/crates/vihaco/src/loader.rs @@ -2,18 +2,64 @@ // SPDX-License-Identifier: MIT use crate::{ + BytecodeFile, + binary::{ + BytecodeContextHandle, ConstantId, ProgramContext, ProgramGlobals, SectionView, + decode_instruction_stream, + }, module::{Module, NoInfo}, traits::{self, GetProgramGlobal, ProgramCounter}, value::{Type, Value}, }; +pub struct LoadInput<'bc, C = ProgramContext> { + pub section: SectionView<'bc, C>, +} + +impl<'bc, C> Clone for LoadInput<'bc, C> { + fn clone(&self) -> Self { + Self { + section: self.section.clone(), + } + } +} + +impl<'bc, C> From<&'bc BytecodeFile> for LoadInput<'bc, C> { + fn from(file: &'bc BytecodeFile) -> Self { + Self { + section: file.root(), + } + } +} + +impl<'bc, C> From> for LoadInput<'bc, C> { + fn from(section: SectionView<'bc, C>) -> Self { + Self { section } + } +} + +impl std::fmt::Debug for LoadInput<'_, C> +where + C: crate::binary::BytecodeContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadInput") + .field("section", &self.section) + .finish_non_exhaustive() + } +} + +pub trait LoadSection { + fn load_section<'bc>(&mut self, input: LoadInput<'bc, C>) -> eyre::Result<()>; +} + #[derive(Debug, Clone)] -pub struct ProgramLoader { - pub module: Module, +pub struct ModuleProgramLoader { + pub module: Module, pub pc: u32, } -impl Default for ProgramLoader { +impl Default for ModuleProgramLoader { fn default() -> Self { Self { module: Module::default(), @@ -22,7 +68,7 @@ impl Default for ProgramLoader { } } -impl ProgramCounter for ProgramLoader { +impl ProgramCounter for ModuleProgramLoader { type Instruction = I; fn pc(&self) -> u32 { @@ -44,8 +90,12 @@ impl ProgramCounter for ProgramLoader { } } -impl GetProgramGlobal for ProgramLoader { - type Type = Type; +impl GetProgramGlobal for ModuleProgramLoader +where + Ty: Clone, +{ + type Type = Ty; + type Value = V; fn get_function(&self, index: usize) -> eyre::Result> { self.module.functions.get(index).cloned().ok_or_else(|| { @@ -66,4 +116,113 @@ impl GetProgramGlobal for ProgramLoader { )) }) } + + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value> { + self.module.constants.get(id.0 as usize).ok_or_else(|| { + eyre::eyre!(format!( + "constant index out of bounds: {} (max {})", + id.0, + self.module.constants.len() + )) + }) + } +} + +#[derive(Debug, Clone)] +pub struct ProgramLoader { + pub code: Vec, + pub context: Option>, + pub pc: u32, + pub extra: Info, +} + +impl Default for ProgramLoader { + fn default() -> Self { + Self { + code: Vec::new(), + context: None, + pc: 0, + extra: Info::default(), + } + } +} + +impl ProgramLoader { + pub fn new() -> Self + where + Info: Default, + { + Self::default() + } + + pub fn with_extra(extra: Info) -> Self { + Self { + code: Vec::new(), + context: None, + pc: 0, + extra, + } + } + + pub fn context(&self) -> eyre::Result<&C> { + self.context + .as_ref() + .map(BytecodeContextHandle::get) + .ok_or_else(|| eyre::eyre!("bytecode program loader has not been loaded")) + } +} + +impl LoadSection for ProgramLoader +where + I: traits::Instruction, + C: crate::binary::BytecodeContext, +{ + fn load_section<'bc>(&mut self, input: LoadInput<'bc, C>) -> eyre::Result<()> { + self.code = decode_instruction_stream(input.section.bytecode())?; + self.context = Some(input.section.context_handle()); + self.pc = 0; + Ok(()) + } +} + +impl ProgramCounter for ProgramLoader { + type Instruction = I; + + fn pc(&self) -> u32 { + self.pc + } + + fn pc_mut(&mut self) -> &mut u32 { + &mut self.pc + } + + fn get_instruction(&self, pc: u32) -> eyre::Result<&Self::Instruction> { + self.code.get(pc as usize).ok_or_else(|| { + eyre::eyre!(format!( + "program counter out of bounds: {} (max {})", + pc, + self.code.len() + )) + }) + } +} + +impl GetProgramGlobal for ProgramLoader +where + C: ProgramGlobals, +{ + type Type = C::Type; + type Value = C::Value; + + fn get_function(&self, index: usize) -> eyre::Result> { + self.context()?.get_function(index) + } + + fn get_string(&self, index: usize) -> eyre::Result<&String> { + self.context()?.get_string(index) + } + + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value> { + self.context()?.get_constant(id) + } } diff --git a/crates/vihaco/src/traits/machine.rs b/crates/vihaco/src/traits/machine.rs index 88b206a..95c8807 100644 --- a/crates/vihaco/src/traits/machine.rs +++ b/crates/vihaco/src/traits/machine.rs @@ -5,7 +5,7 @@ use eyre::Result; use super::Instruction; -use crate::{frame::Frame, module::FunctionInfo}; +use crate::{binary::ConstantId, frame::Frame, module::FunctionInfo}; pub trait ProgramCounter { type Instruction: Instruction; @@ -89,8 +89,11 @@ pub trait FrameMemory: StackFrame + StackMemory { pub trait GetProgramGlobal { type Type; + type Value; + fn get_function(&self, index: usize) -> Result>; fn get_string(&self, index: usize) -> Result<&String>; + fn get_constant(&self, id: ConstantId) -> Result<&Self::Value>; } pub trait Stdout { diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs new file mode 100644 index 0000000..1946760 --- /dev/null +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::io::Read; + +use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use vihaco::{ + BytecodeFile, ConstantId, Effects, GeneratedComponent, GetProgramGlobal, Instruction, + LoadInput, LoadSection, ProgramLoader, Value, + binary::{FLAGS, MAGIC, VERSION}, + traits::{FromBytes, WriteBytes}, +}; + +const CHILD_NAME: u32 = 0; +const DEFAULT_CHILD_NAME: u32 = 1; +const EXTRA_NAME: u32 = 2; +const MIDDLE_NAME: u32 = 3; +const LEAF_NAME: u32 = 4; +const SECTION_FRAME_LEN: usize = 8 + 8; +const SECTION_BYTECODE_HEADER_LEN: usize = 8; +const CHILD_SECTION_TABLE_HEADER_LEN: usize = 4; +const CHILD_SECTION_TABLE_ENTRY_LEN: usize = 4 + 8; + +#[derive(Debug, Clone, PartialEq, Instruction)] +enum TestInst { + Nop, + Load(ConstantId), +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TestHeader { + cores: u32, +} + +impl FromBytes for TestHeader { + fn from_bytes(bytes: &mut R) -> eyre::Result { + Ok(Self { + cores: bytes.read_u32::()?, + }) + } +} + +impl WriteBytes for TestHeader { + fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { + io.write_u32::(self.cores)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Default)] +struct LoadedDevice { + program: ProgramLoader, +} + +impl GeneratedComponent for LoadedDevice { + type Instruction = TestInst; + type Message = (); + type Effect = (); + + fn execute_generated( + &mut self, + _inst: Self::Instruction, + _msg: Self::Message, + ) -> eyre::Result> { + Ok(Effects::none()) + } +} + +impl LoadSection for LoadedDevice { + fn load_section<'bc>(&mut self, input: LoadInput<'bc>) -> eyre::Result<()> { + self.program.load_section(input) + } +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct Machine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("child")] + child: LoadedDevice, + + #[device(0x02)] + #[loadable] + default_child: LoadedDevice, +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct NestedMachine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("leaf")] + leaf: LoadedDevice, +} + +impl GeneratedComponent for NestedMachine { + type Instruction = NestedMachineInstruction; + type Message = (); + type Effect = (); + + fn execute_generated( + &mut self, + _inst: Self::Instruction, + _msg: Self::Message, + ) -> eyre::Result> { + Ok(Effects::none()) + } +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct HostMachine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("middle")] + middle: NestedMachine, +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct HeaderMachine { + #[header] + info: TestHeader, + + #[program] + program: ProgramLoader, + + #[device(0x01)] + device: LoadedDevice, +} + +#[test] +fn generated_loadable_routes_program_and_child_sections() { + let child = section_bytes(b"", &[TestInst::Load(ConstantId(0))], vec![]); + let default_child = section_bytes(b"", &[TestInst::Nop], vec![]); + let root = section_bytes( + b"", + &[TestInst::Nop], + vec![(CHILD_NAME, child), (DEFAULT_CHILD_NAME, default_child)], + ); + let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + + let mut machine = Machine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.program.code, vec![TestInst::Nop]); + assert_eq!( + machine.child.program.code, + vec![TestInst::Load(ConstantId(0))] + ); + assert_eq!(machine.default_child.program.code, vec![TestInst::Nop]); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .child + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert_eq!( + machine.program.get_constant(ConstantId(0)).unwrap(), + &Value::I64(9) + ); +} + +#[test] +fn generated_loadable_parses_marked_header() { + let mut header = Vec::new(); + TestHeader { cores: 8 }.write_bytes(&mut header).unwrap(); + let root = section_bytes(&header, &[TestInst::Nop], vec![]); + let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + + let mut machine = HeaderMachine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.info, TestHeader { cores: 8 }); + assert_eq!(machine.program.code, vec![TestInst::Nop]); +} + +#[test] +fn generated_loadable_routes_three_level_section_tree() { + let leaf = section_bytes(b"", &[TestInst::Nop, TestInst::Load(ConstantId(0))], vec![]); + let middle = section_bytes( + b"", + &[TestInst::Load(ConstantId(0))], + vec![(LEAF_NAME, leaf)], + ); + let root = section_bytes(b"", &[TestInst::Nop], vec![(MIDDLE_NAME, middle)]); + let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + + let mut machine = HostMachine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.program.code, vec![TestInst::Nop]); + assert_eq!( + machine.middle.program.code, + vec![TestInst::Load(ConstantId(0))] + ); + assert_eq!( + machine.middle.leaf.program.code, + vec![TestInst::Nop, TestInst::Load(ConstantId(0))] + ); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .leaf + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); +} + +#[test] +fn generated_loadable_requires_marked_children() { + let root = section_bytes(b"", &[TestInst::Nop], vec![]); + let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let mut machine = Machine::default(); + + let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + + assert!(err.to_string().contains("missing required child section")); +} + +#[test] +fn generated_loadable_rejects_unexpected_direct_children() { + let extra = section_bytes(b"", &[], vec![]); + let root = section_bytes(b"", &[TestInst::Nop], vec![(EXTRA_NAME, extra)]); + let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let mut machine = Machine::default(); + + let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + + assert!(err.to_string().contains("unexpected child section")); +} + +fn file_bytes(context: Vec, root: Vec) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(MAGIC); + bytes.write_u16::(VERSION).unwrap(); + bytes.write_u16::(FLAGS).unwrap(); + bytes + .write_u64::(context.len() as u64) + .unwrap(); + bytes.extend_from_slice(&context); + bytes.extend_from_slice(&root); + bytes +} + +fn context_bytes() -> Vec { + let mut bytes = Vec::new(); + + bytes.write_u32::(1).unwrap(); + Value::I64(9).write_bytes(&mut bytes).unwrap(); + + bytes.write_u32::(5).unwrap(); + write_string(&mut bytes, "child"); + write_string(&mut bytes, "default_child"); + write_string(&mut bytes, "extra"); + write_string(&mut bytes, "middle"); + write_string(&mut bytes, "leaf"); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u8(0).unwrap(); + bytes.write_u32::(0).unwrap(); + bytes.write_u32::(0).unwrap(); + + bytes +} + +fn section_bytes( + header: &[u8], + instructions: &[TestInst], + children: Vec<(u32, Vec)>, +) -> Vec { + let mut bytecode = Vec::new(); + for inst in instructions { + inst.write_bytes(&mut bytecode).unwrap(); + } + + let child_table_len = + CHILD_SECTION_TABLE_HEADER_LEN + children.len() * CHILD_SECTION_TABLE_ENTRY_LEN; + let bytecode_start = SECTION_FRAME_LEN + header.len() + SECTION_BYTECODE_HEADER_LEN; + let mut child_offset = bytecode_start + bytecode.len() + child_table_len; + let section_len = child_offset + children.iter().map(|(_, child)| child.len()).sum::(); + + let mut bytes = Vec::new(); + bytes.write_u64::(section_len as u64).unwrap(); + bytes + .write_u64::(header.len() as u64) + .unwrap(); + bytes.extend_from_slice(header); + bytes + .write_u64::(bytecode.len() as u64) + .unwrap(); + bytes.extend_from_slice(&bytecode); + bytes + .write_u32::(children.len() as u32) + .unwrap(); + for (name_index, child) in &children { + bytes.write_u32::(*name_index).unwrap(); + bytes + .write_u64::(child_offset as u64) + .unwrap(); + child_offset += child.len(); + } + for (_, child) in children { + bytes.extend_from_slice(&child); + } + bytes +} + +fn write_string(bytes: &mut Vec, value: &str) { + bytes.write_u32::(value.len() as u32).unwrap(); + bytes.extend_from_slice(value.as_bytes()); +} diff --git a/docs/src/pages/guide/composites.md b/docs/src/pages/guide/composites.md index 0470c51..9e7b0e8 100644 --- a/docs/src/pages/guide/composites.md +++ b/docs/src/pages/guide/composites.md @@ -138,6 +138,114 @@ pub struct Machine { Here `Machine` drives its instruction pointer through the `cpu` field. +### `#[header]` + +Marks the field that should receive the section's typed composite header during generated bytecode loading. + +```rust ignore +#[derive(Default)] +pub struct CpuHeader { + cores: u32, +} + +#[composite] +#[derive(Default)] +pub struct CpuMachine { + #[header] + info: CpuHeader, + + #[program] + program: vihaco::ProgramLoader, +} +``` + +The field type must implement `CompositeHeader`, which has a blanket impl for types implementing `FromBytes + WriteBytes`. Generated loading parses `input.section.header_bytes()` with `SectionView::parse_header::()` and assigns the result to the field before loading the program or child sections. Composites without a `#[header]` field generate no header parsing code, no header trait bound, and no runtime header check. + +### `#[loadable]` + +Marks a device field that should receive its own direct child section when loading v1 multi-section bytecode. The device owns its loader internally; the generated composite loader only routes the section to the marked device's `LoadSection` impl. + +```rust ignore +#[composite] +#[derive(Default)] +pub struct Machine { + #[program] + program: vihaco::ProgramLoader, + + #[device(0x01)] + #[loadable("signal")] + signal: SignalMachine, +} +``` + +`#[loadable]` must be used on a `#[device(...)]` field whose type implements `LoadSection` as well as `GeneratedComponent`. It uses the field name as the local section name. `#[loadable("name")]` overrides it. Names must be non-empty direct child names, so they cannot contain `/`. + +Section identity is represented as a `SectionPath`, which is a vector of string-table indices for local path components. The root section is `SectionPath::root()` with zero components. A root child named `cpu` has the path `[cpu]`; a child named `alu` inside that section has `[cpu, alu]`. Generated loading asks the current section for direct children by local name, so the same composite can be loaded at the root or under another parent section. + +Manual loaders can inspect a whole section subtree with `SectionView::walk()`, which yields the current section followed by its descendants in depth-first order. `SectionView::descendants()` uses the same order but skips the current section. To walk an entire file, use `BytecodeFile::sections()`. + +The generated loader is strict: + +- every `#[loadable]` device field must have exactly one matching direct child section +- every direct child section must correspond to a `#[loadable]` device field +- if the composite has no `#[program]` field, its own section bytecode must be empty +- composite headers are parsed by the macro only when the composite has a `#[header]` field; manual loaders can still inspect raw headers through `SectionView::header_bytes()` or `SectionView::parse_header::()` + +## Multi-Section Bytecode + +The read-side bytecode API lives in `vihaco::binary` and `vihaco::loader`. + +```rust ignore +fn load_machine<'bc>(file: &'bc vihaco::BytecodeFile) -> eyre::Result { + let mut machine = Machine::default(); + machine.load_section(vihaco::LoadInput::from(file))?; + Ok(machine) +} + +let file: vihaco::BytecodeFile = vihaco::BytecodeFile::from_bytes(bytes)?; +let machine = load_machine(&file)?; +``` + +The v1 file layout is: + +```text +VHBC magic +u16 version = 1 +u16 flags = 0 +u64 program_context_len +program context bytes +root section bytes +``` + +The program context contains the shared `Module` tables except `code` and `extra`: constants, strings, functions, labels, `main_function`, `file`, and source symbols. `ProgramContext` is the default context representation and is generic over the VM's constant value and type encodings. A bytecode file can also use a custom context type by implementing `BytecodeContext`; generated composite loading is generic over that context type, so Rust infers it from `BytecodeFile` / `LoadInput`. Section bytecode can refer to shared constants with `vihaco::ConstantId`, a `u32` newtype that implements the bytecode field traits. + +Each section is: + +```text +section frame: +u64 section_len +u64 header_len +composite header bytes +section bytecode header: +u64 bytecode_len +bytecode bytes +child section table header: +u32 child_count +child table entries +child section bytes +``` + +Each child table entry stores: + +```text +u32 local_name_string +u64 section_offset +``` + +The section frame is part of every section, including the root section. The bytecode header starts after the composite header, and the section's bytecode immediately follows that length. Child-related metadata comes after the parent bytecode. `local_name_string` is resolved through `BytecodeContext::section_name` and represents the child's local section name. The parser builds each child's `SectionPath` by appending that component to the parent path. Child section offsets are relative to the start of the containing section. + +`ProgramLoader` is the standard loader for fixed-width instruction streams. It decodes `section.bytecode()` with `decode_instruction_stream::()`, stores a cloned `BytecodeContextHandle`, implements `ProgramCounter`, and exposes functions, strings, and constants through `GetProgramGlobal` when `C: ProgramGlobals`. + ## Effect Continuation Is Hand-Written `#[composite]` generates the instruction enum and metadata, but it does **not** auto-deliver effects to observers. Continuing effects is something the runtime does explicitly: execute a component, then hand each returned effect to the types that observe it by calling their `Observe` impls. From 222fa1ff1a701423e5ce638147f7689be6a3fc9a Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Tue, 23 Jun 2026 18:43:04 -0400 Subject: [PATCH 02/12] Add compile fail tests related to multi-section bytecode; cleanup the test file names --- CONTRIBUTING.md | 3 ++- .../composite_machine/duplicate-device-code.rs} | 0 .../composite_machine/duplicate-device-code.stderr} | 2 +- .../composite_machine/duplicate-header.rs | 13 +++++++++++++ .../composite_machine/duplicate-header.stderr | 5 +++++ .../composite_machine/duplicate-loadable-name.rs | 13 +++++++++++++ .../duplicate-loadable-name.stderr | 5 +++++ .../composite_machine/header-with-device.rs | 10 ++++++++++ .../composite_machine/header-with-device.stderr | 5 +++++ .../composite_machine/invalid-loadable-name.rs | 10 ++++++++++ .../composite_machine/invalid-loadable-name.stderr | 5 +++++ .../composite_machine/loadable-without-device.rs | 9 +++++++++ .../loadable-without-device.stderr | 5 +++++ .../vihaco/tests/composite_machine_compile_fail.rs | 13 +++++++++++++ crates/vihaco/tests/rfc0007_machine_compile_fail.rs | 8 -------- licenserc.toml | 12 ++++++------ 16 files changed, 102 insertions(+), 16 deletions(-) rename crates/vihaco/tests/{ui/rfc0007-machine-duplicate-device-code.rs => compile_fail/composite_machine/duplicate-device-code.rs} (100%) rename crates/vihaco/tests/{ui/rfc0007-machine-duplicate-device-code.stderr => compile_fail/composite_machine/duplicate-device-code.stderr} (57%) create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.rs create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.stderr create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.rs create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.stderr create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/header-with-device.rs create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/header-with-device.stderr create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.rs create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.stderr create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.rs create mode 100644 crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.stderr create mode 100644 crates/vihaco/tests/composite_machine_compile_fail.rs delete mode 100644 crates/vihaco/tests/rfc0007_machine_compile_fail.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4dfbec3..5b6a07f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,7 +52,8 @@ If you do not agree to those terms, please do not open a pull request. - **License headers.** Every source file under `crates/**` carries a two-line SPDX header. Add one to a new file with `mise run license-fix` (or `hawkeye format`); CI enforces it. Line-sensitive trybuild fixtures under - `tests/ui/` and `tests/compile_errors/` are intentionally excluded. + `tests/compile_fail/` and `tests/compile_errors/` are intentionally + excluded. ## Workflow diff --git a/crates/vihaco/tests/ui/rfc0007-machine-duplicate-device-code.rs b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-device-code.rs similarity index 100% rename from crates/vihaco/tests/ui/rfc0007-machine-duplicate-device-code.rs rename to crates/vihaco/tests/compile_fail/composite_machine/duplicate-device-code.rs diff --git a/crates/vihaco/tests/ui/rfc0007-machine-duplicate-device-code.stderr b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-device-code.stderr similarity index 57% rename from crates/vihaco/tests/ui/rfc0007-machine-duplicate-device-code.stderr rename to crates/vihaco/tests/compile_fail/composite_machine/duplicate-device-code.stderr index 2de85ec..f7c47c5 100644 --- a/crates/vihaco/tests/ui/rfc0007-machine-duplicate-device-code.stderr +++ b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-device-code.stderr @@ -1,5 +1,5 @@ error: duplicate device code 0x01 for fields `a` and `b` - --> tests/ui/rfc0007-machine-duplicate-device-code.rs:26:5 + --> tests/compile_fail/composite_machine/duplicate-device-code.rs:26:5 | 26 | b: DemoDevice, | ^ diff --git a/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.rs b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.rs new file mode 100644 index 0000000..8965f16 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.rs @@ -0,0 +1,13 @@ +#[derive(Default)] +struct Header; + +#[vihaco::composite] +struct BadMachine { + #[header] + first: Header, + + #[header] + second: Header, +} + +fn main() {} diff --git a/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.stderr b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.stderr new file mode 100644 index 0000000..599c98a --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-header.stderr @@ -0,0 +1,5 @@ +error: duplicate header field `second`; `first` is already marked #[header] + --> tests/compile_fail/composite_machine/duplicate-header.rs:10:5 + | +10 | second: Header, + | ^^^^^^ diff --git a/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.rs b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.rs new file mode 100644 index 0000000..636daea --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.rs @@ -0,0 +1,13 @@ +struct Child; + +#[vihaco::composite] +struct BadMachine { + #[device(0x01)] + #[loadable("child")] + a: Child, + #[device(0x02)] + #[loadable("child")] + b: Child, +} + +fn main() {} diff --git a/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.stderr b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.stderr new file mode 100644 index 0000000..a2f2962 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/duplicate-loadable-name.stderr @@ -0,0 +1,5 @@ +error: duplicate loadable section name `child` for fields `a` and `b` + --> tests/compile_fail/composite_machine/duplicate-loadable-name.rs:10:5 + | +10 | b: Child, + | ^ diff --git a/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.rs b/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.rs new file mode 100644 index 0000000..90a60a3 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.rs @@ -0,0 +1,10 @@ +struct HeaderDevice; + +#[vihaco::composite] +struct BadMachine { + #[header] + #[device(0x01)] + header: HeaderDevice, +} + +fn main() {} diff --git a/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.stderr b/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.stderr new file mode 100644 index 0000000..269f8da --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/header-with-device.stderr @@ -0,0 +1,5 @@ +error: field `header` marked #[header] cannot also be marked #[program], #[device(...)] or #[loadable] + --> tests/compile_fail/composite_machine/header-with-device.rs:7:5 + | +7 | header: HeaderDevice, + | ^^^^^^ diff --git a/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.rs b/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.rs new file mode 100644 index 0000000..997dbdf --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.rs @@ -0,0 +1,10 @@ +struct Child; + +#[vihaco::composite] +struct BadMachine { + #[device(0x01)] + #[loadable("child/nested")] + child: Child, +} + +fn main() {} diff --git a/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.stderr b/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.stderr new file mode 100644 index 0000000..4b6a865 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/invalid-loadable-name.stderr @@ -0,0 +1,5 @@ +error: loadable section name cannot contain `/` + --> tests/compile_fail/composite_machine/invalid-loadable-name.rs:6:16 + | +6 | #[loadable("child/nested")] + | ^^^^^^^^^^^^^^ diff --git a/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.rs b/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.rs new file mode 100644 index 0000000..50e4a13 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.rs @@ -0,0 +1,9 @@ +struct Child; + +#[vihaco::composite] +struct BadMachine { + #[loadable("child")] + child: Child, +} + +fn main() {} diff --git a/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.stderr b/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.stderr new file mode 100644 index 0000000..bfaefc6 --- /dev/null +++ b/crates/vihaco/tests/compile_fail/composite_machine/loadable-without-device.stderr @@ -0,0 +1,5 @@ +error: field `child` marked #[loadable] must also be marked #[device(...)] + --> tests/compile_fail/composite_machine/loadable-without-device.rs:6:5 + | +6 | child: Child, + | ^^^^^ diff --git a/crates/vihaco/tests/composite_machine_compile_fail.rs b/crates/vihaco/tests/composite_machine_compile_fail.rs new file mode 100644 index 0000000..a777d63 --- /dev/null +++ b/crates/vihaco/tests/composite_machine_compile_fail.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +#[test] +fn composite_machine_rejects_ambiguous_wiring() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/composite_machine/duplicate-device-code.rs"); + t.compile_fail("tests/compile_fail/composite_machine/duplicate-header.rs"); + t.compile_fail("tests/compile_fail/composite_machine/duplicate-loadable-name.rs"); + t.compile_fail("tests/compile_fail/composite_machine/header-with-device.rs"); + t.compile_fail("tests/compile_fail/composite_machine/invalid-loadable-name.rs"); + t.compile_fail("tests/compile_fail/composite_machine/loadable-without-device.rs"); +} diff --git a/crates/vihaco/tests/rfc0007_machine_compile_fail.rs b/crates/vihaco/tests/rfc0007_machine_compile_fail.rs deleted file mode 100644 index 9f9ebda..0000000 --- a/crates/vihaco/tests/rfc0007_machine_compile_fail.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: 2026 The vihaco Authors -// SPDX-License-Identifier: MIT - -#[test] -fn machine_derive_rejects_ambiguous_wiring() { - let t = trybuild::TestCases::new(); - t.compile_fail("tests/ui/rfc0007-machine-duplicate-device-code.rs"); -} diff --git a/licenserc.toml b/licenserc.toml index 9bccd14..6077540 100644 --- a/licenserc.toml +++ b/licenserc.toml @@ -15,12 +15,12 @@ excludes = [ "**/*.lock", "docs/**", "examples/**", - # trybuild UI fixtures: their .stderr snapshots are line-number - # sensitive, so a header would shift the diagnostics and break the - # tests. The trybuild *runner* files (tests/compile_errors.rs, - # tests/rfc0007_machine_compile_fail.rs) are not under these dirs and - # are still stamped. - "**/tests/ui/**", + # trybuild compile-fail fixtures: their .stderr snapshots are + # line-number sensitive, so a header would shift the diagnostics and + # break the tests. The trybuild *runner* files + # (tests/compile_errors.rs, tests/composite_machine_compile_fail.rs) + # are not under these dirs and are still stamped. + "**/tests/compile_fail/**", "**/tests/compile_errors/**", ] From e79ff7d0a123d76b553c1819ef7c284eb63cca05 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Wed, 24 Jun 2026 20:51:39 -0400 Subject: [PATCH 03/12] Support for text-based bytecode files to be parsed; refactored existing section scaffolding to accommodate text in addition to raw bytes --- crates/vihaco-derive/src/derive_machine.rs | 162 ++++++- crates/vihaco/src/binary/common.rs | 28 ++ crates/vihaco/src/binary/context.rs | 8 +- crates/vihaco/src/binary/file.rs | 162 +++++-- crates/vihaco/src/binary/mod.rs | 11 +- crates/vihaco/src/binary/parser.rs | 30 +- crates/vihaco/src/binary/section.rs | 122 +++-- crates/vihaco/src/binary/tests.rs | 317 +++++++++---- crates/vihaco/src/binary/text.rs | 473 +++++++++++++++++++ crates/vihaco/src/lib.rs | 8 +- crates/vihaco/src/loader.rs | 76 ++- crates/vihaco/tests/multisection_bytecode.rs | 439 ++++++++++++++--- 12 files changed, 1516 insertions(+), 320 deletions(-) create mode 100644 crates/vihaco/src/binary/common.rs create mode 100644 crates/vihaco/src/binary/text.rs diff --git a/crates/vihaco-derive/src/derive_machine.rs b/crates/vihaco-derive/src/derive_machine.rs index 0aaca06..4691911 100644 --- a/crates/vihaco-derive/src/derive_machine.rs +++ b/crates/vihaco-derive/src/derive_machine.rs @@ -3,7 +3,7 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use quote::{ToTokens, format_ident, quote}; +use quote::{format_ident, quote, ToTokens}; use std::collections::{BTreeMap, BTreeSet}; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; @@ -446,12 +446,12 @@ fn try_expand(input: DeriveInput) -> syn::Result { } if let Some((_, program_ty)) = &program_field { loadable_predicates - .push(quote! { #program_ty: ::vihaco::loader::LoadSection<#loadable_context_param> }); + .push(quote! { #program_ty: ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param> }); } for loadable in &loadables { let ty = &loadable.ty; loadable_predicates - .push(quote! { #ty: ::vihaco::loader::LoadSection<#loadable_context_param> }); + .push(quote! { #ty: ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param> }); } let loadable_method_where = method_where_clause(&loadable_predicates); @@ -472,7 +472,7 @@ fn try_expand(input: DeriveInput) -> syn::Result { let program_load = if let Some((field_name, field_ty)) = &program_field { quote! { - <#field_ty as ::vihaco::loader::LoadSection<#loadable_context_param>>::load_section( + <#field_ty as ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param>>::load_section( &mut self.#field_name, input.clone(), )?; @@ -515,7 +515,7 @@ fn try_expand(input: DeriveInput) -> syn::Result { #name, ) })?; - <#ty as ::vihaco::loader::LoadSection<#loadable_context_param>>::load_section( + <#ty as ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param>>::load_section( &mut self.#field, ::vihaco::loader::LoadInput::from(__vihaco_child) )?; @@ -527,7 +527,7 @@ fn try_expand(input: DeriveInput) -> syn::Result { impl #impl_generics #ident #ty_generics #where_clause { pub fn load_generated_sections<#bc_lifetime, #loadable_context_param>( &mut self, - input: ::vihaco::loader::LoadInput<#bc_lifetime, #loadable_context_param>, + input: ::vihaco::loader::LoadInput<#bc_lifetime, ::std::vec::Vec, #loadable_context_param>, ) -> ::eyre::Result<()> #loadable_method_where { @@ -560,19 +560,164 @@ fn try_expand(input: DeriveInput) -> syn::Result { } } - impl #loadable_impl_generics ::vihaco::loader::LoadSection<#loadable_context_param> + impl #loadable_impl_generics ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param> for #ident #ty_generics #loadable_where_clause { fn load_section<#bc_lifetime>( &mut self, - input: ::vihaco::loader::LoadInput<#bc_lifetime, #loadable_context_param>, + input: ::vihaco::loader::LoadInput<#bc_lifetime, ::std::vec::Vec, #loadable_context_param>, ) -> ::eyre::Result<()> { self.load_generated_sections(input) } } }; + let mut text_loadable_predicates = Vec::::new(); + text_loadable_predicates + .push(quote! { #loadable_context_param: ::vihaco::binary::BytecodeContext }); + if let Some((_, header_ty)) = &header_field { + text_loadable_predicates.push(quote! { #header_ty: ::std::str::FromStr }); + text_loadable_predicates + .push(quote! { <#header_ty as ::std::str::FromStr>::Err: ::std::fmt::Display }); + } + if let Some((_, program_ty)) = &program_field { + text_loadable_predicates + .push(quote! { #program_ty: ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param> }); + } + for loadable in &loadables { + let ty = &loadable.ty; + text_loadable_predicates + .push(quote! { #ty: ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param> }); + } + let text_loadable_method_where = method_where_clause(&text_loadable_predicates); + + let mut text_loadable_impl_generics = generics.clone(); + text_loadable_impl_generics + .params + .push(syn::parse_quote!(#loadable_context_param)); + if !text_loadable_predicates.is_empty() { + let where_clause = text_loadable_impl_generics.make_where_clause(); + for predicate in &text_loadable_predicates { + where_clause + .predicates + .push(syn::parse2(predicate.clone())?); + } + } + let (text_loadable_impl_generics, _, text_loadable_where_clause) = + text_loadable_impl_generics.split_for_impl(); + + let text_program_load = if let Some((field_name, field_ty)) = &program_field { + quote! { + <#field_ty as ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param>>::load_section( + &mut self.#field_name, + input.clone(), + )?; + } + } else { + quote! { + if !input.section.text().trim().is_empty() { + return Err(::eyre::eyre!( + "section `{}` has bytecode but `{}` has no #[program] field", + input.section.display_path(), + stringify!(#ident), + )); + } + } + }; + + let text_header_load = if let Some((field_name, field_ty)) = &header_field { + quote! { + self.#field_name = input + .section + .header_text() + .trim() + .parse::<#field_ty>() + .map_err(|__vihaco_err| { + ::eyre::eyre!( + "failed to parse section `{}` header for `{}`: {}", + input.section.display_path(), + stringify!(#field_ty), + __vihaco_err, + ) + })?; + } + } else { + quote! {} + }; + + let text_child_loads: Vec<_> = loadables + .iter() + .map(|loadable| { + let field = &loadable.field; + let ty = &loadable.ty; + let name = &loadable.section_name; + quote! { + let __vihaco_child = input.section.child(#name).ok_or_else(|| { + ::eyre::eyre!( + "section `{}` is missing required child section `{}`", + input.section.display_path(), + #name, + ) + })?; + <#ty as ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param>>::load_section( + &mut self.#field, + ::vihaco::loader::LoadInput::from(__vihaco_child) + )?; + } + }) + .collect(); + + let text_loadable_impl = quote! { + impl #impl_generics #ident #ty_generics #where_clause { + pub fn load_generated_text_sections<#bc_lifetime, #loadable_context_param>( + &mut self, + input: ::vihaco::loader::LoadInput<#bc_lifetime, ::std::string::String, #loadable_context_param>, + ) -> ::eyre::Result<()> + #text_loadable_method_where + { + #text_header_load + #text_program_load + + let __vihaco_expected_children: &[&str] = &[#(#loadable_names),*]; + + for __vihaco_child in input.section.children() { + let __vihaco_child_name = __vihaco_child.local_name().ok_or_else(|| { + ::eyre::eyre!( + "section `{}` yielded a root section as a child", + input.section.display_path(), + ) + })?; + if !__vihaco_expected_children + .iter() + .any(|__vihaco_expected| *__vihaco_expected == __vihaco_child_name) + { + return Err(::eyre::eyre!( + "section `{}` has unexpected child section `{}`", + input.section.display_path(), + __vihaco_child.display_path(), + )); + } + } + + #( #text_child_loads )* + Ok(()) + } + } + + impl #text_loadable_impl_generics ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param> + for #ident #ty_generics + #text_loadable_where_clause + { + fn load_section<#bc_lifetime>( + &mut self, + input: ::vihaco::loader::LoadInput<#bc_lifetime, ::std::string::String, #loadable_context_param>, + ) -> ::eyre::Result<()> { + self.load_generated_text_sections(input) + } + } + }; + Ok(quote! { #[derive(Debug, Clone, ::vihaco::Instruction)] pub enum #machine_instruction_ident #enum_generics { @@ -598,5 +743,6 @@ fn try_expand(input: DeriveInput) -> syn::Result { #program_impl #loadable_impl + #text_loadable_impl }) } diff --git a/crates/vihaco/src/binary/common.rs b/crates/vihaco/src/binary/common.rs new file mode 100644 index 0000000..1b5b7c7 --- /dev/null +++ b/crates/vihaco/src/binary/common.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use crate::{BytecodeContext, SectionPath}; + +pub(super) fn validate_local_section_name( + parent: &SectionPath, + context: &C, + child: &str, +) -> eyre::Result<()> +where + C: BytecodeContext, +{ + if child.is_empty() { + return Err(eyre::eyre!( + "section `{}` has an empty child name", + parent.display(context) + )); + } + if child.contains('/') { + return Err(eyre::eyre!( + "section `{}` child name `{}` must be a local name", + parent.display(context), + child + )); + } + Ok(()) +} diff --git a/crates/vihaco/src/binary/context.rs b/crates/vihaco/src/binary/context.rs index d27d67a..05e3933 100644 --- a/crates/vihaco/src/binary/context.rs +++ b/crates/vihaco/src/binary/context.rs @@ -143,15 +143,15 @@ where /// machine definitions, we wrap the context in an [`Arc`] to drop it /// automatically. #[derive(Debug)] -pub struct BytecodeContextHandle(Arc); +pub struct ContextHandle(Arc); -impl Clone for BytecodeContextHandle { +impl Clone for ContextHandle { fn clone(&self) -> Self { Self(self.0.clone()) } } -impl BytecodeContextHandle { +impl ContextHandle { pub fn new(context: C) -> Self { Self(Arc::new(context)) } @@ -165,7 +165,7 @@ impl BytecodeContextHandle { } } -impl std::ops::Deref for BytecodeContextHandle { +impl std::ops::Deref for ContextHandle { type Target = C; fn deref(&self) -> &Self::Target { diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index 62cd352..c84b7a5 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -3,34 +3,89 @@ use std::io::Cursor; +use crate::binary::text::{ + consume_context, lex_lines, line_error as text_line_error, parse_section as parse_text_section, + verify_version, LineCursor, LineKind, TextSectionParseInfo, +}; + use super::{ - context::{BytecodeContext, BytecodeContextHandle, ProgramContext}, + context::{BytecodeContext, ContextHandle, ProgramContext}, format::BytecodeHeader, - parser::{SectionParseInfo, checked_add, parse_section}, - section::{SectionNode, SectionPath, SectionView, SectionWalk}, + parser::{checked_add, parse_section, SectionParseInfo}, + section::{SectionNode, SectionPath, SectionView}, }; +/// We're sealing [`FileContents`] for two reasons: +/// +/// 1. we don't want downstream authors to implement their own file content +/// support (yet? maybe in the future we can expose an API), and +/// 2. we don't want to use an enum for [`BytecodeFile`] when we know the +/// type of our file's contents statically; when dealing with a +/// [`BytecodeFile`] we'd have to constantly destruct and unreachable!() +/// +/// https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed +mod private { + pub trait Sealed {} + + impl Sealed for Vec {} + impl Sealed for String {} +} + +pub trait FileContents: private::Sealed {} + +impl FileContents for Vec {} +impl FileContents for String {} + /// A parsed bytecode file. /// /// This connects the raw bytes of the file with the parsed global context /// and the tree of section nodes. #[derive(Debug, Clone)] -pub struct BytecodeFile { - bytes: Vec, - context: BytecodeContextHandle, +pub struct BytecodeFile, C = ProgramContext> +where + F: FileContents, + C: BytecodeContext, +{ + contents: F, + context: ContextHandle, root: SectionNode, } -impl BytecodeFile { +impl BytecodeFile +where + F: FileContents, + C: BytecodeContext, +{ + pub fn context(&self) -> &C { + self.context.get() + } + + pub fn context_handle(&self) -> ContextHandle { + self.context.clone() + } + + /// The view of the root of the section tree. + pub fn root(&self) -> SectionView<'_, F, C> { + SectionView { + contents: &self.contents, + context: self.context.clone(), + node: &self.root, + } + } +} + +pub type BinaryBytecodeFile = BytecodeFile, C>; + +impl BinaryBytecodeFile +where + C: BytecodeContext, +{ /// The public entry point for the parsing of a bytecode file. /// /// This will automatically split a multi-section file into /// each individual section node and send it to its corresponding /// composite loader marked with `#[program]`. - pub fn from_bytes(bytes: Vec) -> eyre::Result - where - C: BytecodeContext, - { + pub fn from_bytes(bytes: Vec) -> eyre::Result> { let mut cursor = Cursor::new(bytes.as_slice()); let header = BytecodeHeader::read_from(&mut cursor)?; @@ -57,37 +112,74 @@ impl BytecodeFile { )); } - Ok(Self { - bytes, - context: BytecodeContextHandle::new(context), + Ok(BytecodeFile { + contents: bytes, + context: ContextHandle::new(context), root, }) } +} - pub fn context(&self) -> &C { - self.context.get() - } +pub type TextBytecodeFile = BytecodeFile; - pub fn context_handle(&self) -> BytecodeContextHandle { - self.context.clone() - } +impl TextBytecodeFile +where + C: BytecodeContext, +{ + pub fn from_text(text: &str) -> eyre::Result> { + let lines = lex_lines(text)?; + let mut cursor = LineCursor::new(&lines); - /// The view of the root of the section tree. - pub fn root(&self) -> SectionView<'_, C> { - SectionView { - bytes: &self.bytes, - context: self.context.clone(), - node: &self.root, + let Some(version) = cursor.next_significant() else { + return Err(eyre::eyre!( + "expected `vihaco version {}`", + super::format::VERSION + )); + }; + match &version.kind { + LineKind::Version(version) => verify_version(*version)?, + _ => { + return Err(text_line_error( + version, + format!("expected `vihaco version {}`", super::format::VERSION), + )) + } } - } - /// Walk every section in this file in depth-first order. - /// - /// The first yielded section is always the root section. - pub fn sections(&self) -> SectionWalk<'_, C> - where - C: BytecodeContext, - { - self.root().walk() + let Some(context_begin) = cursor.next_significant() else { + return Err(eyre::eyre!("expected `begin context:`")); + }; + if context_begin.kind != LineKind::BeginContext { + return Err(text_line_error(context_begin, "expected `begin context:`")); + } + + let context_start = context_begin.full.end; + let context_end = consume_context(&mut cursor)?; + let context = C::from_bytes(text[context_start..context_end].as_bytes())?; + + let Some(section_begin) = cursor.peek_significant() else { + return Err(eyre::eyre!("expected root section")); + }; + let root = parse_text_section( + &mut cursor, + &context, + TextSectionParseInfo { + parent: None, + begin: section_begin, + }, + )?; + + if let Some(extra) = cursor.next_significant() { + return Err(text_line_error( + extra, + "unexpected content after root section", + )); + } + + Ok(BytecodeFile { + contents: text.to_string(), + context: ContextHandle::new(context), + root, + }) } } diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs index af57b04..89f1037 100644 --- a/crates/vihaco/src/binary/mod.rs +++ b/crates/vihaco/src/binary/mod.rs @@ -1,16 +1,19 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT +mod common; mod context; mod file; mod format; mod parser; mod section; +mod text; #[cfg(test)] mod tests; -pub use context::{BytecodeContext, BytecodeContextHandle, ProgramContext, ProgramGlobals}; -pub use file::BytecodeFile; -pub use format::{CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION, decode_instruction_stream}; -pub use section::{SectionPath, SectionPathDisplay, SectionView, SectionWalk}; +pub use context::{BytecodeContext, ContextHandle, ProgramContext, ProgramGlobals}; +pub use file::{BytecodeFile, FileContents}; +pub use format::{decode_instruction_stream, CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION}; +pub use section::{SectionPath, SectionPathDisplay, SectionView}; +pub use text::parse_instruction_stream; diff --git a/crates/vihaco/src/binary/parser.rs b/crates/vihaco/src/binary/parser.rs index 35ad10f..81d5b7a 100644 --- a/crates/vihaco/src/binary/parser.rs +++ b/crates/vihaco/src/binary/parser.rs @@ -7,6 +7,8 @@ use std::{ ops::Range, }; +use crate::binary::common::validate_local_section_name; + use super::{ context::BytecodeContext, format::{ @@ -50,10 +52,6 @@ pub(super) struct SectionParseInfo { /// /// All logic related to the parsing of the current section should /// come _before_ the parsing logic of its children. -/// -/// This function is called recursively, albeit with some intermediate stops along the -/// way related to the validation of child sections within its parent section's bounds; -/// this follows naturally from the tree structure of the sections. pub(super) fn parse_section( bytes: &[u8], context: &C, @@ -381,30 +379,6 @@ where ) } -fn validate_local_section_name( - parent: &SectionPath, - context: &C, - child: &str, -) -> eyre::Result<()> -where - C: BytecodeContext, -{ - if child.is_empty() { - return Err(eyre::eyre!( - "section `{}` has an empty child name", - parent.display(context) - )); - } - if child.contains('/') { - return Err(eyre::eyre!( - "section `{}` child name `{}` must be a local name", - parent.display(context), - child - )); - } - Ok(()) -} - pub(super) fn checked_add(lhs: usize, rhs: usize, label: &str) -> eyre::Result { lhs.checked_add(rhs) .ok_or_else(|| eyre::eyre!("{label} overflows usize")) diff --git a/crates/vihaco/src/binary/section.rs b/crates/vihaco/src/binary/section.rs index 31335fa..db1083a 100644 --- a/crates/vihaco/src/binary/section.rs +++ b/crates/vihaco/src/binary/section.rs @@ -3,8 +3,10 @@ use std::{io::Cursor, ops::Range}; +use crate::binary::file::FileContents; + use super::{ - context::{BytecodeContext, BytecodeContextHandle, ProgramContext}, + context::{BytecodeContext, ContextHandle, ProgramContext}, format::CompositeHeader, }; @@ -106,24 +108,33 @@ pub(super) struct SectionNode { /// The public handle of a bytecode section. /// /// This is a lightweight view into information owned by [`BytecodeFile`]. -pub struct SectionView<'bc, C = ProgramContext> { - pub(super) bytes: &'bc [u8], - pub(super) context: BytecodeContextHandle, +pub struct SectionView<'bc, F = Vec, C = ProgramContext> +where + F: FileContents, + C: BytecodeContext, +{ + pub(super) contents: &'bc F, + pub(super) context: ContextHandle, pub(super) node: &'bc SectionNode, } -impl<'bc, C> Clone for SectionView<'bc, C> { - fn clone(&self) -> Self { - Self { - bytes: self.bytes, +impl<'bc, F, C> Clone for SectionView<'bc, F, C> +where + F: FileContents, + C: BytecodeContext, +{ + fn clone(&self) -> SectionView<'bc, F, C> { + SectionView { + contents: self.contents, context: self.context.clone(), node: self.node, } } } -impl<'bc, C> SectionView<'bc, C> +impl<'bc, F, C> SectionView<'bc, F, C> where + F: FileContents, C: BytecodeContext, { pub fn path(&self) -> &'bc SectionPath { @@ -138,49 +149,19 @@ where self.context.get() } - pub fn context_handle(&self) -> BytecodeContextHandle { + pub fn context_handle(&self) -> ContextHandle { self.context.clone() } - pub fn header_bytes(&self) -> &'bc [u8] { - &self.bytes[self.node.header.clone()] - } - - pub fn bytecode(&self) -> &'bc [u8] { - &self.bytes[self.node.bytecode.clone()] - } - - pub fn children(&self) -> impl Iterator> + '_ { + pub fn children(&self) -> impl Iterator> + '_ { self.node.children.iter().map(|node| SectionView { - bytes: self.bytes, + contents: self.contents, context: self.context.clone(), node, }) } - /// Walk this section and all of its descendants in depth-first order. - /// - /// The first yielded section is always `self`. - pub fn walk(&self) -> SectionWalk<'bc, C> { - SectionWalk { - bytes: self.bytes, - context: self.context.clone(), - stack: vec![self.node], - } - } - - /// Walk all descendants of this section in depth-first order. - /// - /// Unlike [`walk`](Self::walk), this does not yield `self`. - pub fn descendants(&self) -> SectionWalk<'bc, C> { - SectionWalk { - bytes: self.bytes, - context: self.context.clone(), - stack: self.node.children.iter().rev().collect(), - } - } - - pub fn child(&self, local_name: &str) -> Option> { + pub fn child(&self, local_name: &str) -> Option> { self.node .children .iter() @@ -192,7 +173,7 @@ where .is_some_and(|name| name == local_name) }) .map(|node| SectionView { - bytes: self.bytes, + contents: self.contents, context: self.context.clone(), node, }) @@ -204,6 +185,21 @@ where .local_name() .and_then(|name| self.context.section_name(name)) } +} + +pub type BinarySectionView<'bc, C> = SectionView<'bc, Vec, C>; + +impl<'bc, C> BinarySectionView<'bc, C> +where + C: BytecodeContext, +{ + pub fn header_bytes(&self) -> &'bc [u8] { + &self.contents[self.node.header.clone()] + } + + pub fn bytecode(&self) -> &'bc [u8] { + &self.contents[self.node.bytecode.clone()] + } /// Parse the specified composite header from the raw header bytes. pub fn parse_header(&self) -> eyre::Result { @@ -221,28 +217,7 @@ where } } -/// A depth-first iterator over a section subtree. -pub struct SectionWalk<'bc, C = ProgramContext> { - bytes: &'bc [u8], - context: BytecodeContextHandle, - stack: Vec<&'bc SectionNode>, -} - -impl<'bc, C> Iterator for SectionWalk<'bc, C> { - type Item = SectionView<'bc, C>; - - fn next(&mut self) -> Option { - let node = self.stack.pop()?; - self.stack.extend(node.children.iter().rev()); - Some(SectionView { - bytes: self.bytes, - context: self.context.clone(), - node, - }) - } -} - -impl std::fmt::Debug for SectionView<'_, C> +impl std::fmt::Debug for BinarySectionView<'_, C> where C: BytecodeContext, { @@ -255,3 +230,18 @@ where .finish() } } + +pub type TextSectionView<'bc, C> = SectionView<'bc, String, C>; + +impl<'bc, C> TextSectionView<'bc, C> +where + C: BytecodeContext, +{ + pub fn header_text(&self) -> &'bc str { + &self.contents[self.node.header.clone()] + } + + pub fn text(&self) -> &'bc str { + &self.contents[self.node.bytecode.clone()] + } +} diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index 1e02554..e4fcc4b 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -5,6 +5,7 @@ use super::format::{ ChildSectionTableEntry, ChildSectionTableHeader, SectionBytecodeHeader, SectionFrame, }; use super::*; +use crate::binary::file::{BinaryBytecodeFile, TextBytecodeFile}; use crate::{ traits::{FromBytes, WriteBytes}, value::{Type, Value}, @@ -41,6 +42,12 @@ struct WrappedContext { inner: ProgramContext, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct TextContext { + raw: String, + section_names: Vec, +} + impl BytecodeContext for WrappedContext { fn from_bytes(bytes: &[u8]) -> eyre::Result { Ok(Self { @@ -53,8 +60,25 @@ impl BytecodeContext for WrappedContext { } } +impl BytecodeContext for TextContext { + fn from_bytes(bytes: &[u8]) -> eyre::Result { + let raw = std::str::from_utf8(bytes)?.to_string(); + let section_names = raw + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(); + Ok(Self { raw, section_names }) + } + + fn section_name(&self, index: u32) -> Option<&str> { + self.section_names.get(index as usize).map(String::as_str) + } +} + #[test] -fn parses_context_and_nested_sections() { +fn parses_binary_context_and_nested_sections() { const CPU_NAME: u32 = 1; const ALU_NAME: u32 = 2; @@ -62,16 +86,16 @@ fn parses_context_and_nested_sections() { let alu_header = b"alu header"; let cpu_header = b"cpu header"; let root_header = b"root header"; - let alu = section_bytes(alu_header, &[TestInst::Nop], vec![]); - let cpu = section_bytes( + let alu = binary_section_bytes(alu_header, &[TestInst::Nop], vec![]); + let cpu = binary_section_bytes( cpu_header, &[TestInst::Load(ConstantId(0))], vec![(ALU_NAME, alu)], ); - let root = section_bytes(root_header, &[], vec![(CPU_NAME, cpu)]); - let file = file_bytes(context, root); + let root = binary_section_bytes(root_header, &[], vec![(CPU_NAME, cpu)]); + let file = binary_file_bytes(context, root); - let parsed: BytecodeFile = BytecodeFile::from_bytes(file).unwrap(); + let parsed: BinaryBytecodeFile = BinaryBytecodeFile::from_bytes(file).unwrap(); assert_eq!(parsed.context().constants, vec![Value::I64(42)]); assert_eq!( @@ -83,7 +107,7 @@ fn parses_context_and_nested_sections() { let root = parsed.root(); assert!(root.path().is_root()); - assert_eq!(root.path().components(), &[]); + assert_eq!(root.path().components(), &[] as &[u32]); assert_eq!(root.local_name(), None); assert_eq!(root.display_path().to_string(), ""); assert_eq!(root.header_bytes(), b"root header"); @@ -110,13 +134,13 @@ fn parses_context_and_nested_sections() { } #[test] -fn parses_file_with_custom_context_representation() { +fn parses_binary_file_with_custom_context_representation() { const CPU_NAME: u32 = 1; - let cpu = section_bytes(b"", &[TestInst::Nop], vec![]); - let root = section_bytes(b"", &[], vec![(CPU_NAME, cpu)]); - let parsed: BytecodeFile = - BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let cpu = binary_section_bytes(b"", &[TestInst::Nop], vec![]); + let root = binary_section_bytes(b"", &[], vec![(CPU_NAME, cpu)]); + let parsed: BinaryBytecodeFile = + BinaryBytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); assert_eq!(parsed.context().inner.constants, vec![Value::I64(42)]); assert_eq!( @@ -126,44 +150,12 @@ fn parses_file_with_custom_context_representation() { } #[test] -fn walks_section_tree_depth_first() { - const CPU_NAME: u32 = 0; - const ALU_NAME: u32 = 1; - const GPU_NAME: u32 = 2; - - let alu = section_bytes(b"", &[TestInst::Load(ConstantId(0))], vec![]); - let cpu = section_bytes(b"", &[TestInst::Nop], vec![(ALU_NAME, alu)]); - let gpu = section_bytes(b"", &[], vec![]); - let root = section_bytes(b"", &[], vec![(CPU_NAME, cpu), (GPU_NAME, gpu)]); - let parsed: BytecodeFile = BytecodeFile::from_bytes(file_bytes( - context_with_strings(&["cpu", "alu", "gpu"]), - root, - )) - .unwrap(); - - let paths = parsed - .sections() - .map(|section| section.display_path().to_string()) - .collect::>(); - - assert_eq!(paths, vec!["", "cpu", "cpu/alu", "gpu"]); - - let descendant_paths = parsed - .root() - .descendants() - .map(|section| section.display_path().to_string()) - .collect::>(); - - assert_eq!(descendant_paths, vec!["cpu", "cpu/alu", "gpu"]); -} - -#[test] -fn parses_context_with_custom_value_and_type_tables() { +fn parses_binary_context_with_custom_value_and_type_tables() { let context = custom_context_bytes(); - let root = section_bytes(b"", &[], vec![]); - let parsed = BytecodeFile::>::from_bytes(file_bytes( - context, root, - )) + let root = binary_section_bytes(b"", &[], vec![]); + let parsed = BinaryBytecodeFile::>::from_bytes( + binary_file_bytes(context, root), + ) .unwrap(); assert_eq!(parsed.context().constants, vec![CustomValue(7)]); @@ -179,7 +171,7 @@ fn parses_context_with_custom_value_and_type_tables() { } #[test] -fn parse_header_consumes_the_whole_header() { +fn binary_parse_header_consumes_the_whole_header() { #[derive(Debug, PartialEq)] struct Header(u32); @@ -198,26 +190,29 @@ fn parse_header_consumes_the_whole_header() { let mut header = Vec::new(); header.write_u32::(99).unwrap(); - let root = section_bytes(&header, &[], vec![]); - let parsed: BytecodeFile = - BytecodeFile::from_bytes(file_bytes(empty_context_bytes(), root)).unwrap(); + let root = binary_section_bytes(&header, &[], vec![]); + let parsed: BinaryBytecodeFile = + BinaryBytecodeFile::from_bytes(binary_file_bytes(empty_context_bytes(), root)).unwrap(); assert_eq!(parsed.root().parse_header::
().unwrap(), Header(99)); } #[test] -fn rejects_bad_magic() { - let mut bytes = file_bytes(empty_context_bytes(), section_bytes(b"", &[], vec![])); +fn rejects_bad_binary_magic() { + let mut bytes = binary_file_bytes( + empty_context_bytes(), + binary_section_bytes(b"", &[], vec![]), + ); bytes[0] = b'X'; - let err = BytecodeFile::>::from_bytes(bytes).unwrap_err(); + let err = BinaryBytecodeFile::>::from_bytes(bytes).unwrap_err(); assert!(err.to_string().contains("invalid bytecode magic")); } #[test] -fn rejects_missing_section_name_string() { - let root = raw_section(b"", b"", vec![(0, b"")]); - let err = BytecodeFile::>::from_bytes(file_bytes( +fn rejects_binary_missing_section_name_string() { + let root = raw_binary_section(b"", b"", vec![(0, b"")]); + let err = BinaryBytecodeFile::>::from_bytes(binary_file_bytes( empty_context_bytes(), root, )) @@ -227,12 +222,12 @@ fn rejects_missing_section_name_string() { } #[test] -fn rejects_duplicate_child_names() { - let child_a = section_bytes(b"", &[], vec![]); - let child_b = section_bytes(b"", &[], vec![]); - let root = raw_section(b"", b"", vec![(0, &child_a), (0, &child_b)]); +fn rejects_binary_duplicate_child_names() { + let child_a = binary_section_bytes(b"", &[], vec![]); + let child_b = binary_section_bytes(b"", &[], vec![]); + let root = raw_binary_section(b"", b"", vec![(0, &child_a), (0, &child_b)]); - let err = BytecodeFile::>::from_bytes(file_bytes( + let err = BinaryBytecodeFile::>::from_bytes(binary_file_bytes( context_with_strings(&["cpu"]), root, )) @@ -242,10 +237,10 @@ fn rejects_duplicate_child_names() { } #[test] -fn rejects_out_of_bounds_child_section() { - let root = raw_section_with_entry_offsets(b"", b"", vec![(0, 999, b"")]); +fn rejects_binary_out_of_bounds_child_section() { + let root = raw_binary_section_with_entry_offsets(b"", b"", vec![(0, 999, b"")]); - let err = BytecodeFile::>::from_bytes(file_bytes( + let err = BinaryBytecodeFile::>::from_bytes(binary_file_bytes( context_with_strings(&["cpu"]), root, )) @@ -255,19 +250,19 @@ fn rejects_out_of_bounds_child_section() { } #[test] -fn rejects_overlapping_child_sections() { - let child = section_bytes(b"", &[], vec![]); +fn rejects_binary_overlapping_child_sections() { + let child = binary_section_bytes(b"", &[], vec![]); let child_offset = (SectionFrame::ENCODED_LEN + SectionBytecodeHeader::ENCODED_LEN + ChildSectionTableHeader::ENCODED_LEN + (2 * ChildSectionTableEntry::ENCODED_LEN)) as u64; - let root = raw_section_with_entry_offsets( + let root = raw_binary_section_with_entry_offsets( b"", b"", vec![(0, child_offset, &child), (1, child_offset, &[])], ); - let err = BytecodeFile::>::from_bytes(file_bytes( + let err = BinaryBytecodeFile::>::from_bytes(binary_file_bytes( context_with_strings(&["cpu", "gpu"]), root, )) @@ -277,7 +272,7 @@ fn rejects_overlapping_child_sections() { } #[test] -fn rejects_bytecode_that_extends_past_section_end() { +fn rejects_binary_bytecode_that_extends_past_section_end() { let mut root = Vec::new(); root.write_u64::( (SectionFrame::ENCODED_LEN + SectionBytecodeHeader::ENCODED_LEN) as u64, @@ -286,26 +281,178 @@ fn rejects_bytecode_that_extends_past_section_end() { root.write_u64::(0).unwrap(); root.write_u64::(1).unwrap(); - let err = BytecodeFile::>::from_bytes(file_bytes( + let err = BinaryBytecodeFile::>::from_bytes(binary_file_bytes( empty_context_bytes(), root, )) .unwrap_err(); - assert!( - err.to_string() - .contains("bytecode extends past section end") - ); + assert!(err + .to_string() + .contains("bytecode extends past section end")); } #[test] -fn rejects_non_multiple_instruction_stream() { +fn rejects_binary_instruction_stream_with_non_multiple_width() { let err = decode_instruction_stream::(&[0, 1, 2]).unwrap_err(); assert!(err.to_string().contains("not a multiple")); } -fn file_bytes(context: Vec, root: Vec) -> Vec { +#[test] +fn parses_text_context_and_nested_sections() { + let parsed = TextBytecodeFile::::from_text(&text_file( + "cpu\nalu\n", + r#"begin section root: +begin header root: +root header +end header root +begin bytecode root: +root bytecode +end bytecode root +begin section cpu: +begin header cpu: +cpu header +end header cpu +begin bytecode cpu: +cpu bytecode +end bytecode cpu +begin section alu: +begin header alu: +alu header +end header alu +begin bytecode alu: +alu bytecode +end bytecode alu +end section alu +end section cpu +end section root +"#, + )) + .unwrap(); + + assert_eq!(parsed.context().section_names, vec!["cpu", "alu"]); + + let root = parsed.root(); + assert!(root.path().is_root()); + assert_eq!(root.local_name(), None); + assert_eq!(root.display_path().to_string(), ""); + assert_eq!(root.header_text(), "root header\n"); + assert_eq!(root.text(), "root bytecode\n"); + + let cpu = root.child("cpu").unwrap(); + assert_eq!(cpu.path().components(), &[0]); + assert_eq!(cpu.local_name(), Some("cpu")); + assert_eq!(cpu.display_path().to_string(), "cpu"); + assert_eq!(cpu.header_text(), "cpu header\n"); + assert_eq!(cpu.text(), "cpu bytecode\n"); + + let alu = cpu.child("alu").unwrap(); + assert_eq!(alu.path().components(), &[0, 1]); + assert_eq!(alu.local_name(), Some("alu")); + assert_eq!(alu.display_path().to_string(), "cpu/alu"); + assert_eq!(alu.header_text(), "alu header\n"); + assert_eq!(alu.text(), "alu bytecode\n"); +} + +#[test] +fn parses_text_section_without_header_or_bytecode_as_empty_ranges() { + let parsed = TextBytecodeFile::::from_text(&text_file( + "", + r#"begin section root: +end section root +"#, + )) + .unwrap(); + + let root = parsed.root(); + assert_eq!(root.header_text(), ""); + assert_eq!(root.text(), ""); +} + +#[test] +fn rejects_text_bad_version() { + let err = TextBytecodeFile::::from_text(&format!( + "vihaco version {}\nbegin context:\nend context\nbegin section root:\nend section root\n", + VERSION + 1 + )) + .unwrap_err(); + + assert!(err.to_string().contains("unsupported bytecode version")); +} + +#[test] +fn rejects_text_missing_context_end() { + let err = TextBytecodeFile::::from_text( + "vihaco version 1\nbegin context:\ncpu\nbegin section root:\nend section root\n", + ) + .unwrap_err(); + + assert!(err.to_string().contains("unterminated context")); +} + +#[test] +fn rejects_text_missing_section_name() { + let err = TextBytecodeFile::::from_text(&text_file( + "cpu\n", + r#"begin section root: +begin section gpu: +end section gpu +end section root +"#, + )) + .unwrap_err(); + + assert!(err.to_string().contains("missing section name `gpu`")); +} + +#[test] +fn rejects_text_duplicate_child_sections() { + let err = TextBytecodeFile::::from_text(&text_file( + "cpu\n", + r#"begin section root: +begin section cpu: +end section cpu +begin section cpu: +end section cpu +end section root +"#, + )) + .unwrap_err(); + + assert!(err.to_string().contains("duplicate child `cpu`")); +} + +#[test] +fn rejects_text_mismatched_section_end_marker() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + r#"begin section root: +end section other +"#, + )) + .unwrap_err(); + + assert!(err.to_string().contains("mismatched marker `other`")); +} + +#[test] +fn rejects_text_body_directly_inside_section() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + r#"begin section root: +this line is not in a header or bytecode block +end section root +"#, + )) + .unwrap_err(); + + assert!(err + .to_string() + .contains("unexpected content in section ``")); +} + +fn binary_file_bytes(context: Vec, root: Vec) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(MAGIC); bytes.write_u16::(VERSION).unwrap(); @@ -405,7 +552,7 @@ fn context_with_strings(strings: &[&str]) -> Vec { bytes } -fn section_bytes( +fn binary_section_bytes( header: &[u8], instructions: &[TestInst], children: Vec<(u32, Vec)>, @@ -418,10 +565,10 @@ fn section_bytes( .iter() .map(|(name_index, bytes)| (*name_index, bytes.as_slice())) .collect(); - raw_section(header, &bytecode, children) + raw_binary_section(header, &bytecode, children) } -fn raw_section(header: &[u8], bytecode: &[u8], children: Vec<(u32, &[u8])>) -> Vec { +fn raw_binary_section(header: &[u8], bytecode: &[u8], children: Vec<(u32, &[u8])>) -> Vec { let child_table_len = ChildSectionTableHeader::ENCODED_LEN + children.len() * ChildSectionTableEntry::ENCODED_LEN; let bytecode_start = @@ -455,7 +602,7 @@ fn raw_section(header: &[u8], bytecode: &[u8], children: Vec<(u32, &[u8])>) -> V bytes } -fn raw_section_with_entry_offsets( +fn raw_binary_section_with_entry_offsets( header: &[u8], bytecode: &[u8], children: Vec<(u32, u64, &[u8])>, @@ -495,6 +642,10 @@ fn raw_section_with_entry_offsets( bytes } +fn text_file(context: &str, sections: &str) -> String { + format!("vihaco version {VERSION}\nbegin context:\n{context}end context\n{sections}") +} + fn write_string(bytes: &mut Vec, value: &str) { bytes.write_u32::(value.len() as u32).unwrap(); bytes.extend_from_slice(value.as_bytes()); diff --git a/crates/vihaco/src/binary/text.rs b/crates/vihaco/src/binary/text.rs new file mode 100644 index 0000000..487c57b --- /dev/null +++ b/crates/vihaco/src/binary/text.rs @@ -0,0 +1,473 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::{collections::BTreeSet, ops::Range}; + +use chumsky::{error::Simple, extra, prelude::*}; +use eyre::Result; + +use crate::binary::common::validate_local_section_name; + +use super::{ + context::BytecodeContext, + format::VERSION, + section::{SectionNode, SectionPath}, +}; + +type ParseExtra<'src> = extra::Err>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum LineKind { + Version(u16), + BeginContext, + EndContext, + BeginSection(String), + EndSection(String), + BeginHeader(String), + EndHeader(String), + BeginBytecode(String), + EndBytecode(String), + Body, + Blank, +} + +#[derive(Debug, Clone)] +pub(super) struct SourceLine { + pub(super) kind: LineKind, + pub(super) full: Range, + pub(super) number: usize, +} + +pub(super) fn verify_version(version: u16) -> Result<()> { + if version != VERSION { + return Err(eyre::eyre!( + "unsupported bytecode version {} (expected {})", + version, + VERSION + )); + } + Ok(()) +} + +pub fn parse_instruction_stream<'src, I>(text: &'src str) -> Result> +where + I: crate::traits::Instruction + vihaco_parser_core::Parse<'src>, +{ + use chumsky::IterParser as _; + + if text.trim().is_empty() { + return Ok(Vec::new()); + } + + >::parser() + .padded() + .repeated() + .collect::>() + .then_ignore(end()) + .parse(text) + .into_result() + .map_err(|errors| eyre::eyre!("failed to parse text instruction stream: {:?}", errors)) +} + +fn parse_line(line: &str) -> Result { + line_parser() + .parse(line) + .into_result() + .map_err(format_parse_errors) +} + +fn line_parser<'src>() -> impl Parser<'src, &'src str, LineKind, ParseExtra<'src>> { + let hspace = one_of(" \t").repeated(); + let required_hspace = one_of(" \t").repeated().at_least(1); + let name = any() + .filter(|c: &char| !c.is_whitespace() && *c != ':') + .repeated() + .at_least(1) + .collect::(); + let colon = just(':'); + + let version = just("vihaco") + .ignore_then(required_hspace.clone()) + .ignore_then(just("version")) + .ignore_then(required_hspace.clone()) + .ignore_then(text::int(10).try_map(|version: &str, span| { + version.parse::().map_err(|_| Simple::new(None, span)) + })) + .map(LineKind::Version); + + let begin_context = just("begin") + .ignore_then(required_hspace.clone()) + .ignore_then(just("context")) + .then_ignore(colon) + .to(LineKind::BeginContext); + let end_context = just("end") + .ignore_then(required_hspace.clone()) + .ignore_then(just("context")) + .to(LineKind::EndContext); + + let begin_section = just("begin") + .ignore_then(required_hspace.clone()) + .ignore_then(just("section")) + .ignore_then(required_hspace.clone()) + .ignore_then(name.clone()) + .then_ignore(colon) + .map(LineKind::BeginSection); + let end_section = just("end") + .ignore_then(required_hspace.clone()) + .ignore_then(just("section")) + .ignore_then(required_hspace.clone()) + .ignore_then(name.clone()) + .map(LineKind::EndSection); + + let begin_header = just("begin") + .ignore_then(required_hspace.clone()) + .ignore_then(just("header")) + .ignore_then(required_hspace.clone()) + .ignore_then(name.clone()) + .then_ignore(colon) + .map(LineKind::BeginHeader); + let end_header = just("end") + .ignore_then(required_hspace.clone()) + .ignore_then(just("header")) + .ignore_then(required_hspace.clone()) + .ignore_then(name.clone()) + .map(LineKind::EndHeader); + + let begin_bytecode = just("begin") + .ignore_then(required_hspace.clone()) + .ignore_then(just("bytecode")) + .ignore_then(required_hspace.clone()) + .ignore_then(name.clone()) + .then_ignore(colon) + .map(LineKind::BeginBytecode); + let end_bytecode = just("end") + .ignore_then(required_hspace) + .ignore_then(just("bytecode")) + .ignore_then(one_of(" \t").repeated().at_least(1)) + .ignore_then(name) + .map(LineKind::EndBytecode); + + let blank = hspace.clone().to(LineKind::Blank); + let body = any().repeated().at_least(1).to(LineKind::Body); + + hspace + .ignore_then(choice(( + version, + begin_context, + end_context, + begin_section, + end_section, + begin_header, + end_header, + begin_bytecode, + end_bytecode, + body, + blank, + ))) + .then_ignore(end()) +} + +fn format_parse_errors(errors: Vec>) -> eyre::Report { + let error = errors + .into_iter() + .next() + .map(|error| format!("{error:?}")) + .unwrap_or_else(|| "unknown parse error".to_string()); + eyre::eyre!("{error}") +} + +pub(super) fn lex_lines(text: &str) -> Result> { + let mut lines = Vec::new(); + let mut start = 0; + for (index, line) in text.split_inclusive('\n').enumerate() { + let full_end = start + line.len(); + let mut content_end = full_end; + if line.ends_with('\n') { + content_end -= 1; + if text.as_bytes().get(content_end.wrapping_sub(1)) == Some(&b'\r') { + content_end -= 1; + } + } + + lines.push(SourceLine { + kind: parse_line(&text[start..content_end]) + .map_err(|err| eyre::eyre!("line {}: {err}", index + 1))?, + full: start..full_end, + number: index + 1, + }); + start = full_end; + } + + if start < text.len() { + let number = lines.len() + 1; + lines.push(SourceLine { + kind: parse_line(&text[start..]) + .map_err(|err| eyre::eyre!("line {}: {err}", number))?, + full: start..text.len(), + number, + }); + } + + Ok(lines) +} + +pub(super) struct LineCursor<'a> { + lines: &'a [SourceLine], + next: usize, +} + +impl<'a> LineCursor<'a> { + pub(super) fn new(lines: &'a [SourceLine]) -> Self { + Self { lines, next: 0 } + } + + pub(super) fn peek_significant(&self) -> Option<&'a SourceLine> { + self.lines[self.next..] + .iter() + .find(|line| line.kind != LineKind::Blank) + } + + pub(super) fn next_significant(&mut self) -> Option<&'a SourceLine> { + while let Some(line) = self.lines.get(self.next) { + self.next += 1; + if line.kind != LineKind::Blank { + return Some(line); + } + } + None + } +} + +pub(super) fn consume_context(cursor: &mut LineCursor<'_>) -> Result { + while let Some(line) = cursor.next_significant() { + if line.kind == LineKind::EndContext { + return Ok(line.full.start); + } + } + + Err(eyre::eyre!("unterminated context; expected `end context`")) +} + +pub(super) struct TextSectionParseInfo<'a> { + pub(super) parent: Option>, + pub(super) begin: &'a SourceLine, +} + +#[derive(Clone, Copy)] +pub(super) struct ParentSection<'a> { + pub(super) path: &'a SectionPath, +} + +pub(super) fn parse_section( + cursor: &mut LineCursor<'_>, + context: &C, + info: TextSectionParseInfo<'_>, +) -> Result +where + C: BytecodeContext, +{ + let TextSectionParseInfo { parent, begin } = info; + let section_name = match &begin.kind { + LineKind::BeginSection(name) => name.clone(), + _ => return Err(line_error(begin, "expected section")), + }; + + let consumed_begin = cursor + .next_significant() + .ok_or_else(|| eyre::eyre!("expected section"))?; + debug_assert_eq!(consumed_begin.full, begin.full); + + let path = match parent { + Some(parent) => { + validate_local_section_name(parent.path, context, §ion_name)?; + parent + .path + .child(find_section_name(context, parent.path, §ion_name)?) + } + None => SectionPath::root(), + }; + + let mut header = None; + let mut bytecode = None; + let mut children = Vec::new(); + let mut child_names = BTreeSet::new(); + + loop { + let Some(line) = cursor.peek_significant() else { + return Err(line_error(begin, "unterminated section")); + }; + + match &line.kind { + LineKind::EndSection(name) => { + if name != §ion_name { + return Err(line_error( + line, + format!( + "section `{}` ended with mismatched marker `{}`", + section_name, name + ), + )); + } + let end = cursor.next_significant().expect("peeked line must exist"); + let fallback = end.full.start..end.full.start; + return Ok(SectionNode { + path, + section: begin.full.start..end.full.end, + header: header.unwrap_or_else(|| fallback.clone()), + bytecode: bytecode.unwrap_or(fallback), + children, + }); + } + LineKind::BeginHeader(name) => { + if name != §ion_name { + return Err(line_error( + line, + format!( + "section `{}` has header marker for `{}`", + section_name, name + ), + )); + } + if header.is_some() { + return Err(line_error( + line, + format!( + "section `{}` declares duplicate header", + path.display(context) + ), + )); + } + header = Some(consume_named_block( + cursor, + §ion_name, + "header", + |kind| match kind { + LineKind::EndHeader(name) => Some(name.as_str()), + _ => None, + }, + )?); + } + LineKind::BeginBytecode(name) => { + if name != §ion_name { + return Err(line_error( + line, + format!( + "section `{}` has bytecode marker for `{}`", + section_name, name + ), + )); + } + if bytecode.is_some() { + return Err(line_error( + line, + format!( + "section `{}` declares duplicate bytecode", + path.display(context) + ), + )); + } + bytecode = Some(consume_named_block( + cursor, + §ion_name, + "bytecode", + |kind| match kind { + LineKind::EndBytecode(name) => Some(name.as_str()), + _ => None, + }, + )?); + } + LineKind::BeginSection(child_name) => { + validate_local_section_name(&path, context, child_name)?; + if !child_names.insert(child_name.clone()) { + return Err(line_error( + line, + format!( + "section `{}` declares duplicate child `{}`", + path.display(context), + child_name + ), + )); + } + let child = parse_section( + cursor, + context, + TextSectionParseInfo { + parent: Some(ParentSection { path: &path }), + begin: line, + }, + )?; + children.push(child); + } + LineKind::Blank => { + cursor.next_significant(); + } + _ => { + return Err(line_error( + line, + format!("unexpected content in section `{}`", path.display(context)), + )); + } + } + } +} + +fn consume_named_block( + cursor: &mut LineCursor<'_>, + section_name: &str, + label: &str, + end_name: impl Fn(&LineKind) -> Option<&str>, +) -> Result> { + let begin = cursor + .next_significant() + .ok_or_else(|| eyre::eyre!("expected `begin {label} {section_name}:`"))?; + let start = begin.full.end; + + while let Some(line) = cursor.next_significant() { + if let Some(name) = end_name(&line.kind) { + if name != section_name { + return Err(line_error( + line, + format!( + "{label} `{}` ended with mismatched marker `{}`", + section_name, name + ), + )); + } + return Ok(start..line.full.start); + } + } + + Err(line_error( + begin, + format!("unterminated {label}; expected `end {label} {section_name}`"), + )) +} + +fn find_section_name(context: &C, parent: &SectionPath, name: &str) -> Result +where + C: BytecodeContext, +{ + let mut index = 0; + while let Some(candidate) = context.section_name(index) { + if candidate == name { + return Ok(index); + } + index = index.checked_add(1).ok_or_else(|| { + eyre::eyre!( + "section `{}` name lookup overflowed while resolving `{}`", + parent.display(context), + name + ) + })?; + } + + Err(eyre::eyre!( + "section `{}` references missing section name `{}`", + parent.display(context), + name + )) +} + +pub(super) fn line_error(line: &SourceLine, message: impl std::fmt::Display) -> eyre::Report { + eyre::eyre!("line {}: {}", line.number, message) +} diff --git a/crates/vihaco/src/lib.rs b/crates/vihaco/src/lib.rs index af4142c..34b7df7 100644 --- a/crates/vihaco/src/lib.rs +++ b/crates/vihaco/src/lib.rs @@ -24,8 +24,8 @@ pub mod traits; pub mod value; pub use binary::{ - BytecodeContext, BytecodeContextHandle, BytecodeFile, CompositeHeader, ConstantId, - ProgramContext, ProgramGlobals, SectionPath, SectionView, SectionWalk, + BytecodeContext, BytecodeFile, CompositeHeader, ConstantId, ContextHandle, ProgramContext, + ProgramGlobals, SectionPath, SectionView, }; pub use effect::Effects; pub use instruction_syntax::{ @@ -45,7 +45,7 @@ pub use value::{Type, Value}; mod public_api_tests { use crate::{ BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, - Reset, SectionWalk, + Reset, binary::ConstantId, instruction::{FromBytes, OpCode, WriteBytes}, module::FunctionInfo, @@ -66,7 +66,6 @@ mod public_api_tests { fn require_bytecode_context() {} fn require_program_globals() {} fn require_load_section() {} - fn require_section_walk(_walk: Option>) {} fn require_stdout_effect(_effect: StdoutEffect) {} fn require_metadata(_metadata: crate::CompositeMetadata) {} @@ -76,7 +75,6 @@ mod public_api_tests { require_bytecode_context::(); require_program_globals::(); require_load_section::>(); - require_section_walk(None); let _constant = ConstantId(0); let _function: Option> = None; require_stdout_effect(StdoutEffect(String::new())); diff --git a/crates/vihaco/src/loader.rs b/crates/vihaco/src/loader.rs index 91a4238..1b46d4c 100644 --- a/crates/vihaco/src/loader.rs +++ b/crates/vihaco/src/loader.rs @@ -2,45 +2,63 @@ // SPDX-License-Identifier: MIT use crate::{ - BytecodeFile, binary::{ - BytecodeContextHandle, ConstantId, ProgramContext, ProgramGlobals, SectionView, - decode_instruction_stream, + decode_instruction_stream, parse_instruction_stream, ConstantId, ContextHandle, + FileContents, ProgramContext, ProgramGlobals, SectionView, }, module::{Module, NoInfo}, traits::{self, GetProgramGlobal, ProgramCounter}, value::{Type, Value}, + BytecodeContext, BytecodeFile, }; -pub struct LoadInput<'bc, C = ProgramContext> { - pub section: SectionView<'bc, C>, +pub struct LoadInput<'bc, F = Vec, C = ProgramContext> +where + F: FileContents, + C: BytecodeContext, +{ + pub section: SectionView<'bc, F, C>, } -impl<'bc, C> Clone for LoadInput<'bc, C> { +impl<'bc, F, C> Clone for LoadInput<'bc, F, C> +where + F: FileContents, + C: BytecodeContext, +{ fn clone(&self) -> Self { - Self { + LoadInput { section: self.section.clone(), } } } -impl<'bc, C> From<&'bc BytecodeFile> for LoadInput<'bc, C> { - fn from(file: &'bc BytecodeFile) -> Self { +impl<'bc, F, C> From<&'bc BytecodeFile> for LoadInput<'bc, F, C> +where + F: FileContents, + C: BytecodeContext, +{ + fn from(file: &'bc BytecodeFile) -> Self { Self { section: file.root(), } } } -impl<'bc, C> From> for LoadInput<'bc, C> { - fn from(section: SectionView<'bc, C>) -> Self { +impl<'bc, F, C> From> for LoadInput<'bc, F, C> +where + F: FileContents, + C: BytecodeContext, +{ + fn from(section: SectionView<'bc, F, C>) -> Self { Self { section } } } -impl std::fmt::Debug for LoadInput<'_, C> +impl<'bc, F, C> std::fmt::Debug for LoadInput<'bc, F, C> where - C: crate::binary::BytecodeContext, + F: FileContents, + C: BytecodeContext, + SectionView<'bc, F, C>: std::fmt::Debug, { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LoadInput") @@ -49,8 +67,12 @@ where } } -pub trait LoadSection { - fn load_section<'bc>(&mut self, input: LoadInput<'bc, C>) -> eyre::Result<()>; +pub trait LoadSection, C = ProgramContext> +where + F: FileContents, + C: BytecodeContext, +{ + fn load_section<'bc>(&mut self, input: LoadInput<'bc, F, C>) -> eyre::Result<()>; } #[derive(Debug, Clone)] @@ -131,7 +153,7 @@ where #[derive(Debug, Clone)] pub struct ProgramLoader { pub code: Vec, - pub context: Option>, + pub context: Option>, pub pc: u32, pub extra: Info, } @@ -167,17 +189,17 @@ impl ProgramLoader { pub fn context(&self) -> eyre::Result<&C> { self.context .as_ref() - .map(BytecodeContextHandle::get) + .map(ContextHandle::get) .ok_or_else(|| eyre::eyre!("bytecode program loader has not been loaded")) } } -impl LoadSection for ProgramLoader +impl LoadSection, C> for ProgramLoader where I: traits::Instruction, - C: crate::binary::BytecodeContext, + C: BytecodeContext, { - fn load_section<'bc>(&mut self, input: LoadInput<'bc, C>) -> eyre::Result<()> { + fn load_section<'bc>(&mut self, input: LoadInput<'bc, Vec, C>) -> eyre::Result<()> { self.code = decode_instruction_stream(input.section.bytecode())?; self.context = Some(input.section.context_handle()); self.pc = 0; @@ -185,6 +207,20 @@ where } } +impl LoadSection for ProgramLoader +where + I: traits::Instruction, + C: BytecodeContext, + for<'src> I: vihaco_parser_core::Parse<'src>, +{ + fn load_section<'bc>(&mut self, input: LoadInput<'bc, String, C>) -> eyre::Result<()> { + self.code = parse_instruction_stream(input.section.text())?; + self.context = Some(input.section.context_handle()); + self.pc = 0; + Ok(()) + } +} + impl ProgramCounter for ProgramLoader { type Instruction = I; diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index 1946760..08ef2d1 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -1,14 +1,14 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT -use std::io::Read; +use std::{io::Read, str::FromStr}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use vihaco::{ - BytecodeFile, ConstantId, Effects, GeneratedComponent, GetProgramGlobal, Instruction, - LoadInput, LoadSection, ProgramLoader, Value, binary::{FLAGS, MAGIC, VERSION}, traits::{FromBytes, WriteBytes}, + BytecodeContext, BytecodeFile, ConstantId, Effects, GeneratedComponent, GetProgramGlobal, + Instruction, LoadInput, LoadSection, ProgramLoader, Value, }; const CHILD_NAME: u32 = 0; @@ -27,6 +27,12 @@ enum TestInst { Load(ConstantId), } +#[derive(Debug, Clone, PartialEq, Instruction, vihaco_parser::Parse)] +enum TextInst { + Nop, + Alt, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct TestHeader { cores: u32, @@ -47,11 +53,49 @@ impl WriteBytes for TestHeader { } } +impl FromStr for TestHeader { + type Err = std::num::ParseIntError; + + fn from_str(text: &str) -> Result { + Ok(Self { + cores: text.parse()?, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TextContext { + section_names: Vec, +} + +impl BytecodeContext for TextContext { + fn from_bytes(bytes: &[u8]) -> eyre::Result { + let text = std::str::from_utf8(bytes)?; + Ok(Self { + section_names: text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(), + }) + } + + fn section_name(&self, index: u32) -> Option<&str> { + self.section_names.get(index as usize).map(String::as_str) + } +} + #[derive(Debug, Clone, Default)] struct LoadedDevice { program: ProgramLoader, } +#[derive(Debug, Clone, Default)] +struct TextLoadedDevice { + program: ProgramLoader, +} + impl GeneratedComponent for LoadedDevice { type Instruction = TestInst; type Message = (); @@ -66,12 +110,35 @@ impl GeneratedComponent for LoadedDevice { } } +impl GeneratedComponent for TextLoadedDevice { + type Instruction = TextInst; + type Message = (); + type Effect = (); + + fn execute_generated( + &mut self, + _inst: Self::Instruction, + _msg: Self::Message, + ) -> eyre::Result> { + Ok(Effects::none()) + } +} + impl LoadSection for LoadedDevice { fn load_section<'bc>(&mut self, input: LoadInput<'bc>) -> eyre::Result<()> { self.program.load_section(input) } } +impl LoadSection for TextLoadedDevice { + fn load_section<'bc>( + &mut self, + input: LoadInput<'bc, String, TextContext>, + ) -> eyre::Result<()> { + self.program.load_section(input) + } +} + #[vihaco::composite] #[derive(Debug, Default)] #[allow(dead_code)] @@ -140,16 +207,85 @@ struct HeaderMachine { device: LoadedDevice, } +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct TextMachine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("child")] + child: TextLoadedDevice, + + #[device(0x02)] + #[loadable] + default_child: TextLoadedDevice, +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct TextNestedMachine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("leaf")] + leaf: TextLoadedDevice, +} + +impl GeneratedComponent for TextNestedMachine { + type Instruction = TextNestedMachineInstruction; + type Message = (); + type Effect = (); + + fn execute_generated( + &mut self, + _inst: Self::Instruction, + _msg: Self::Message, + ) -> eyre::Result> { + Ok(Effects::none()) + } +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct TextHostMachine { + #[program] + program: ProgramLoader, + + #[device(0x01)] + #[loadable("middle")] + middle: TextNestedMachine, +} + +#[vihaco::composite] +#[derive(Debug, Default)] +#[allow(dead_code)] +struct TextHeaderMachine { + #[header] + info: TestHeader, + + #[program] + program: ProgramLoader, + + #[device(0x01)] + device: TextLoadedDevice, +} + #[test] -fn generated_loadable_routes_program_and_child_sections() { - let child = section_bytes(b"", &[TestInst::Load(ConstantId(0))], vec![]); - let default_child = section_bytes(b"", &[TestInst::Nop], vec![]); - let root = section_bytes( +fn binary_generated_loadable_routes_program_and_child_sections() { + let child = binary_section_bytes(b"", &[TestInst::Load(ConstantId(0))], vec![]); + let default_child = binary_section_bytes(b"", &[TestInst::Nop], vec![]); + let root = binary_section_bytes( b"", &[TestInst::Nop], vec![(CHILD_NAME, child), (DEFAULT_CHILD_NAME, default_child)], ); - let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let file: BytecodeFile> = + BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = Machine::default(); machine.load_section(LoadInput::from(&file)).unwrap(); @@ -160,23 +296,19 @@ fn generated_loadable_routes_program_and_child_sections() { vec![TestInst::Load(ConstantId(0))] ); assert_eq!(machine.default_child.program.code, vec![TestInst::Nop]); - assert!( - machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle()) - ); - assert!( - machine - .child - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle()) - ); + assert!(machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .child + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); assert_eq!( machine.program.get_constant(ConstantId(0)).unwrap(), &Value::I64(9) @@ -184,11 +316,55 @@ fn generated_loadable_routes_program_and_child_sections() { } #[test] -fn generated_loadable_parses_marked_header() { +fn text_generated_loadable_routes_program_and_child_sections() { + let file = text_file( + &["child", "default_child"], + r#"begin section root: +begin bytecode root: +nop +end bytecode root +begin section child: +begin bytecode child: +alt +end bytecode child +end section child +begin section default_child: +begin bytecode default_child: +nop +end bytecode default_child +end section default_child +end section root +"#, + ); + + let mut machine = TextMachine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.program.code, vec![TextInst::Nop]); + assert_eq!(machine.child.program.code, vec![TextInst::Alt]); + assert_eq!(machine.default_child.program.code, vec![TextInst::Nop]); + assert!(machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .child + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); +} + +#[test] +fn binary_generated_loadable_parses_marked_header() { let mut header = Vec::new(); TestHeader { cores: 8 }.write_bytes(&mut header).unwrap(); - let root = section_bytes(&header, &[TestInst::Nop], vec![]); - let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let root = binary_section_bytes(&header, &[TestInst::Nop], vec![]); + let file: BytecodeFile> = + BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = HeaderMachine::default(); machine.load_section(LoadInput::from(&file)).unwrap(); @@ -198,15 +374,38 @@ fn generated_loadable_parses_marked_header() { } #[test] -fn generated_loadable_routes_three_level_section_tree() { - let leaf = section_bytes(b"", &[TestInst::Nop, TestInst::Load(ConstantId(0))], vec![]); - let middle = section_bytes( +fn text_generated_loadable_parses_marked_header() { + let file = text_file( + &[], + r#"begin section root: +begin header root: +8 +end header root +begin bytecode root: +nop +end bytecode root +end section root +"#, + ); + + let mut machine = TextHeaderMachine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.info, TestHeader { cores: 8 }); + assert_eq!(machine.program.code, vec![TextInst::Nop]); +} + +#[test] +fn binary_generated_loadable_routes_three_level_section_tree() { + let leaf = binary_section_bytes(b"", &[TestInst::Nop, TestInst::Load(ConstantId(0))], vec![]); + let middle = binary_section_bytes( b"", &[TestInst::Load(ConstantId(0))], vec![(LEAF_NAME, leaf)], ); - let root = section_bytes(b"", &[TestInst::Nop], vec![(MIDDLE_NAME, middle)]); - let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); + let root = binary_section_bytes(b"", &[TestInst::Nop], vec![(MIDDLE_NAME, middle)]); + let file: BytecodeFile> = + BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = HostMachine::default(); machine.load_section(LoadInput::from(&file)).unwrap(); @@ -220,39 +419,89 @@ fn generated_loadable_routes_three_level_section_tree() { machine.middle.leaf.program.code, vec![TestInst::Nop, TestInst::Load(ConstantId(0))] ); - assert!( - machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle()) - ); - assert!( - machine - .middle - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle()) + assert!(machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .middle + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .middle + .leaf + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); +} + +#[test] +fn text_generated_loadable_routes_three_level_section_tree() { + let file = text_file( + &["middle", "leaf"], + r#"begin section root: +begin bytecode root: +nop +end bytecode root +begin section middle: +begin bytecode middle: +alt +end bytecode middle +begin section leaf: +begin bytecode leaf: +nop +alt +end bytecode leaf +end section leaf +end section middle +end section root +"#, ); - assert!( - machine - .middle - .leaf - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle()) + + let mut machine = TextHostMachine::default(); + machine.load_section(LoadInput::from(&file)).unwrap(); + + assert_eq!(machine.program.code, vec![TextInst::Nop]); + assert_eq!(machine.middle.program.code, vec![TextInst::Alt]); + assert_eq!( + machine.middle.leaf.program.code, + vec![TextInst::Nop, TextInst::Alt] ); + assert!(machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .middle + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); + assert!(machine + .middle + .leaf + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle())); } #[test] -fn generated_loadable_requires_marked_children() { - let root = section_bytes(b"", &[TestInst::Nop], vec![]); - let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); +fn binary_generated_loadable_requires_marked_children() { + let root = binary_section_bytes(b"", &[TestInst::Nop], vec![]); + let file: BytecodeFile> = + BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = Machine::default(); let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); @@ -261,10 +510,29 @@ fn generated_loadable_requires_marked_children() { } #[test] -fn generated_loadable_rejects_unexpected_direct_children() { - let extra = section_bytes(b"", &[], vec![]); - let root = section_bytes(b"", &[TestInst::Nop], vec![(EXTRA_NAME, extra)]); - let file: BytecodeFile = BytecodeFile::from_bytes(file_bytes(context_bytes(), root)).unwrap(); +fn text_generated_loadable_requires_marked_children() { + let file = text_file( + &["child", "default_child"], + r#"begin section root: +begin bytecode root: +nop +end bytecode root +end section root +"#, + ); + let mut machine = TextMachine::default(); + + let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + + assert!(err.to_string().contains("missing required child section")); +} + +#[test] +fn binary_generated_loadable_rejects_unexpected_direct_children() { + let extra = binary_section_bytes(b"", &[], vec![]); + let root = binary_section_bytes(b"", &[TestInst::Nop], vec![(EXTRA_NAME, extra)]); + let file: BytecodeFile> = + BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = Machine::default(); let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); @@ -272,7 +540,31 @@ fn generated_loadable_rejects_unexpected_direct_children() { assert!(err.to_string().contains("unexpected child section")); } -fn file_bytes(context: Vec, root: Vec) -> Vec { +#[test] +fn text_generated_loadable_rejects_unexpected_direct_children() { + let file = text_file( + &["child", "default_child", "extra"], + r#"begin section root: +begin bytecode root: +nop +end bytecode root +begin section child: +end section child +begin section default_child: +end section default_child +begin section extra: +end section extra +end section root +"#, + ); + let mut machine = TextMachine::default(); + + let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + + assert!(err.to_string().contains("unexpected child section")); +} + +fn binary_file_bytes(context: Vec, root: Vec) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(MAGIC); bytes.write_u16::(VERSION).unwrap(); @@ -285,6 +577,19 @@ fn file_bytes(context: Vec, root: Vec) -> Vec { bytes } +fn text_file(section_names: &[&str], sections: &str) -> BytecodeFile { + let context = section_names.join("\n"); + let context = if context.is_empty() { + String::new() + } else { + format!("{context}\n") + }; + BytecodeFile::::from_text(&format!( + "vihaco version {VERSION}\nbegin context:\n{context}end context\n{sections}" + )) + .unwrap() +} + fn context_bytes() -> Vec { let mut bytes = Vec::new(); @@ -306,7 +611,7 @@ fn context_bytes() -> Vec { bytes } -fn section_bytes( +fn binary_section_bytes( header: &[u8], instructions: &[TestInst], children: Vec<(u32, Vec)>, From 36b75449aefa8bc0cc9b3f4277816cca02b9082c Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Thu, 25 Jun 2026 13:08:08 -0400 Subject: [PATCH 04/12] Refactor text parser for new grammar; refactor SectionPath to use fully resolved strings on binary side, already existing strings in text side --- crates/vihaco/src/binary/common.rs | 18 +- crates/vihaco/src/binary/context.rs | 2 +- crates/vihaco/src/binary/file.rs | 25 +- crates/vihaco/src/binary/mod.rs | 2 +- crates/vihaco/src/binary/parser.rs | 63 ++--- crates/vihaco/src/binary/section.rs | 55 ++--- crates/vihaco/src/binary/tests.rs | 154 ++++++------ crates/vihaco/src/binary/text.rs | 232 ++++++++----------- crates/vihaco/tests/multisection_bytecode.rs | 117 +++++----- 9 files changed, 288 insertions(+), 380 deletions(-) diff --git a/crates/vihaco/src/binary/common.rs b/crates/vihaco/src/binary/common.rs index 1b5b7c7..15a9850 100644 --- a/crates/vihaco/src/binary/common.rs +++ b/crates/vihaco/src/binary/common.rs @@ -1,26 +1,16 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT -use crate::{BytecodeContext, SectionPath}; +use crate::SectionPath; -pub(super) fn validate_local_section_name( - parent: &SectionPath, - context: &C, - child: &str, -) -> eyre::Result<()> -where - C: BytecodeContext, -{ +pub(super) fn validate_local_section_name(parent: &SectionPath, child: &str) -> eyre::Result<()> { if child.is_empty() { - return Err(eyre::eyre!( - "section `{}` has an empty child name", - parent.display(context) - )); + return Err(eyre::eyre!("section `{}` has an empty child name", parent)); } if child.contains('/') { return Err(eyre::eyre!( "section `{}` child name `{}` must be a local name", - parent.display(context), + parent, child )); } diff --git a/crates/vihaco/src/binary/context.rs b/crates/vihaco/src/binary/context.rs index 05e3933..7d06903 100644 --- a/crates/vihaco/src/binary/context.rs +++ b/crates/vihaco/src/binary/context.rs @@ -143,7 +143,7 @@ where /// machine definitions, we wrap the context in an [`Arc`] to drop it /// automatically. #[derive(Debug)] -pub struct ContextHandle(Arc); +pub struct ContextHandle(Arc); impl Clone for ContextHandle { fn clone(&self) -> Self { diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index c84b7a5..6a1dcfd 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -21,7 +21,7 @@ use super::{ /// support (yet? maybe in the future we can expose an API), and /// 2. we don't want to use an enum for [`BytecodeFile`] when we know the /// type of our file's contents statically; when dealing with a -/// [`BytecodeFile`] we'd have to constantly destruct and unreachable!() +/// [`BytecodeFile`] we'd have to constantly destruct /// /// https://rust-lang.github.io/api-guidelines/future-proofing.html#sealed-traits-protect-against-downstream-implementations-c-sealed mod private { @@ -131,26 +131,35 @@ where let mut cursor = LineCursor::new(&lines); let Some(version) = cursor.next_significant() else { - return Err(eyre::eyre!( - "expected `vihaco version {}`", - super::format::VERSION - )); + return Err(eyre::eyre!("expected `vhbc{}`", super::format::VERSION)); }; + if version.indent != 0 { + return Err(text_line_error( + version, + "version marker must not be indented", + )); + } match &version.kind { LineKind::Version(version) => verify_version(*version)?, _ => { return Err(text_line_error( version, - format!("expected `vihaco version {}`", super::format::VERSION), + format!("expected `vhbc{}`", super::format::VERSION), )) } } let Some(context_begin) = cursor.next_significant() else { - return Err(eyre::eyre!("expected `begin context:`")); + return Err(eyre::eyre!("expected `@>`")); }; if context_begin.kind != LineKind::BeginContext { - return Err(text_line_error(context_begin, "expected `begin context:`")); + return Err(text_line_error(context_begin, "expected `@>`")); + } + if context_begin.indent != 0 { + return Err(text_line_error( + context_begin, + "context start must not be indented", + )); } let context_start = context_begin.full.end; diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs index 89f1037..d3e75f3 100644 --- a/crates/vihaco/src/binary/mod.rs +++ b/crates/vihaco/src/binary/mod.rs @@ -15,5 +15,5 @@ mod tests; pub use context::{BytecodeContext, ContextHandle, ProgramContext, ProgramGlobals}; pub use file::{BytecodeFile, FileContents}; pub use format::{decode_instruction_stream, CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION}; -pub use section::{SectionPath, SectionPathDisplay, SectionView}; +pub use section::{SectionPath, SectionView}; pub use text::parse_instruction_stream; diff --git a/crates/vihaco/src/binary/parser.rs b/crates/vihaco/src/binary/parser.rs index 81d5b7a..c7a7154 100644 --- a/crates/vihaco/src/binary/parser.rs +++ b/crates/vihaco/src/binary/parser.rs @@ -18,20 +18,12 @@ use super::{ }; impl SectionFrame { - fn read_at( - bytes: &[u8], - section_start: usize, - path: &SectionPath, - context: &C, - ) -> eyre::Result - where - C: BytecodeContext, - { + fn read_at(bytes: &[u8], section_start: usize, path: &SectionPath) -> eyre::Result { let frame_end = checked_add(section_start, Self::ENCODED_LEN, "section frame end")?; let frame_bytes = bytes.get(section_start..frame_end).ok_or_else(|| { eyre::eyre!( "section `{}` does not contain a complete section frame", - path.display(context) + path ) })?; let mut cursor = Cursor::new(frame_bytes); @@ -65,18 +57,18 @@ where path, } = info; - let frame = SectionFrame::read_at(bytes, section_start, &path, context)?; + let frame = SectionFrame::read_at(bytes, section_start, &path)?; let section_end = checked_add(section_start, frame.section_len, "section end")?; if section_end > bytes.len() { return Err(eyre::eyre!( "section `{}` extends past end of bytecode", - path.display(context) + path )); } if frame.composite_header_len > frame.section_len { return Err(eyre::eyre!( "section `{}` composite header length {} exceeds section length {}", - path.display(context), + path, frame.composite_header_len, frame.section_len )); @@ -106,7 +98,7 @@ where { return Err(eyre::eyre!( "section `{}` does not contain a complete bytecode header", - path.display(context) + path )); } @@ -121,7 +113,7 @@ where if bytecode_end > section_end { return Err(eyre::eyre!( "section `{}` bytecode extends past section end", - path.display(context) + path )); } @@ -134,7 +126,7 @@ where { return Err(eyre::eyre!( "section `{}` does not contain a complete child section table header", - path.display(context) + path )); } @@ -143,12 +135,7 @@ where let total_len_of_child_section_entries = child_table_header .child_count .checked_mul(ChildSectionTableEntry::ENCODED_LEN) - .ok_or_else(|| { - eyre::eyre!( - "section `{}` child table length overflows usize", - path.display(context) - ) - })?; + .ok_or_else(|| eyre::eyre!("section `{}` child table length overflows usize", path))?; let expected_child_table_len = checked_add( ChildSectionTableHeader::ENCODED_LEN, total_len_of_child_section_entries, @@ -162,7 +149,7 @@ where { return Err(eyre::eyre!( "section `{}` does not contain a complete child section table", - path.display(context) + path )); } @@ -232,16 +219,16 @@ where .ok_or_else(|| { eyre::eyre!( "section `{}` references missing section name string {}", - parent_path.display(context), + parent_path, entry.local_name_string ) })? .to_string(); - validate_local_section_name(parent_path, context, &child_name)?; + validate_local_section_name(parent_path, &child_name)?; if !names.insert(child_name.clone()) { return Err(eyre::eyre!( "section `{}` declares duplicate child `{}`", - parent_path.display(context), + parent_path, child_name )); } @@ -270,21 +257,17 @@ impl ClaimedSectionRanges { } } - fn claim_child( + fn claim_child( &mut self, parent_path: &SectionPath, - context: &C, child_name: String, range: Range, - ) -> eyre::Result<()> - where - C: BytecodeContext, - { + ) -> eyre::Result<()> { for (existing_name, existing) in &self.ranges { if ranges_overlap(existing, &range) { return Err(eyre::eyre!( "section `{}` child `{}` overlaps `{}`", - parent_path.display(context), + parent_path, child_name, existing_name )); @@ -328,18 +311,18 @@ where } = child; let child_start = checked_add(parent_start, entry.section_offset, "child section start")?; - let child_path = parent_path.child(entry.local_name_string); + let child_path = parent_path.child(child_name.clone()); if child_start < parent_child_table_end { return Err(eyre::eyre!( "section `{}` child `{}` begins before parent child table ends", - parent_path.display(context), + parent_path, child_name )); } if child_start >= parent_end { return Err(eyre::eyre!( "section `{}` child `{}` extends past section end", - parent_path.display(context), + parent_path, child_name )); } @@ -351,23 +334,23 @@ where { return Err(eyre::eyre!( "section `{}` child `{}` extends past section end", - parent_path.display(context), + parent_path, child_name )); } - let child_frame = SectionFrame::read_at(bytes, child_start, &child_path, context)?; + let child_frame = SectionFrame::read_at(bytes, child_start, &child_path)?; let child_end = checked_add(child_start, child_frame.section_len, "child section end")?; if child_end > parent_end { return Err(eyre::eyre!( "section `{}` child `{}` extends past section end", - parent_path.display(context), + parent_path, child_name )); } let range = child_start..child_end; - claimed_ranges.claim_child(parent_path, context, child_name, range)?; + claimed_ranges.claim_child(parent_path, child_name, range)?; parse_section( bytes, diff --git a/crates/vihaco/src/binary/section.rs b/crates/vihaco/src/binary/section.rs index db1083a..17763b9 100644 --- a/crates/vihaco/src/binary/section.rs +++ b/crates/vihaco/src/binary/section.rs @@ -13,9 +13,9 @@ use super::{ /// The fully resolved path for a given section. /// /// The root section will be empty. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct SectionPath { - components: Vec, + components: Vec, } impl SectionPath { @@ -29,26 +29,19 @@ impl SectionPath { self.components.is_empty() } - pub fn components(&self) -> &[u32] { + pub fn components(&self) -> &[String] { &self.components } - pub fn local_name(&self) -> Option { - self.components.last().copied() + pub fn local_name(&self) -> Option<&str> { + self.components.last().map(String::as_str) } - pub fn child(&self, local_name: u32) -> Self { + pub fn child(&self, local_name: impl Into) -> Self { let mut components = self.components.clone(); - components.push(local_name); + components.push(local_name.into()); Self { components } } - - pub fn display<'a, C>(&'a self, context: &'a C) -> SectionPathDisplay<'a, C> { - SectionPathDisplay { - path: self, - context, - } - } } impl Default for SectionPath { @@ -57,37 +50,23 @@ impl Default for SectionPath { } } -pub struct SectionPathDisplay<'a, C = ProgramContext> { - path: &'a SectionPath, - context: &'a C, -} - -impl std::fmt::Display for SectionPathDisplay<'_, C> -where - C: BytecodeContext, -{ +impl std::fmt::Display for SectionPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.path.is_root() { + if self.is_root() { return f.write_str(""); } - for (index, component) in self.path.components.iter().enumerate() { + for (index, component) in self.components.iter().enumerate() { if index != 0 { f.write_str("/")?; } - match self.context.section_name(*component) { - Some(name) => f.write_str(name)?, - None => write!(f, "", component)?, - } + f.write_str(component)?; } Ok(()) } } -impl std::fmt::Debug for SectionPathDisplay<'_, C> -where - C: BytecodeContext, -{ +impl std::fmt::Debug for SectionPath { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { std::fmt::Display::fmt(self, f) } @@ -141,8 +120,8 @@ where &self.node.path } - pub fn display_path(&self) -> SectionPathDisplay<'_, C> { - self.node.path.display(self.context.get()) + pub fn display_path(&self) -> &'bc SectionPath { + &self.node.path } pub fn context(&self) -> &C { @@ -169,7 +148,6 @@ where child .path .local_name() - .and_then(|name| self.context.section_name(name)) .is_some_and(|name| name == local_name) }) .map(|node| SectionView { @@ -180,10 +158,7 @@ where } pub fn local_name(&self) -> Option<&str> { - self.node - .path - .local_name() - .and_then(|name| self.context.section_name(name)) + self.node.path.local_name() } } diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index e4fcc4b..214cc64 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -107,13 +107,13 @@ fn parses_binary_context_and_nested_sections() { let root = parsed.root(); assert!(root.path().is_root()); - assert_eq!(root.path().components(), &[] as &[u32]); + assert!(root.path().components().is_empty()); assert_eq!(root.local_name(), None); assert_eq!(root.display_path().to_string(), ""); assert_eq!(root.header_bytes(), b"root header"); let cpu = root.child("cpu").unwrap(); - assert_eq!(cpu.path().components(), &[CPU_NAME]); + assert_eq!(path_components(cpu.path()), vec!["cpu"]); assert_eq!(cpu.local_name(), Some("cpu")); assert_eq!(cpu.display_path().to_string(), "cpu"); assert_eq!(cpu.header_bytes(), b"cpu header"); @@ -123,7 +123,7 @@ fn parses_binary_context_and_nested_sections() { ); let alu = cpu.child("alu").unwrap(); - assert_eq!(alu.path().components(), &[CPU_NAME, ALU_NAME]); + assert_eq!(path_components(alu.path()), vec!["cpu", "alu"]); assert_eq!(alu.local_name(), Some("alu")); assert_eq!(alu.display_path().to_string(), "cpu/alu"); assert_eq!(alu.header_bytes(), b"alu header"); @@ -302,68 +302,62 @@ fn rejects_binary_instruction_stream_with_non_multiple_width() { #[test] fn parses_text_context_and_nested_sections() { let parsed = TextBytecodeFile::::from_text(&text_file( - "cpu\nalu\n", - r#"begin section root: -begin header root: -root header -end header root -begin bytecode root: -root bytecode -end bytecode root -begin section cpu: -begin header cpu: -cpu header -end header cpu -begin bytecode cpu: -cpu bytecode -end bytecode cpu -begin section alu: -begin header alu: -alu header -end header alu -begin bytecode alu: -alu bytecode -end bytecode alu -end section alu -end section cpu -end section root -"#, + "", + "~> root:\n\ +\t!>\n\ +\t\troot header\n\ +\t\n\ +\t\troot bytecode\n\ +\t<^\n\ +\t~> cpu:\n\ +\t\t!>\n\ +\t\t\tcpu header\n\ +\t\t\n\ +\t\t\tcpu bytecode\n\ +\t\t<^\n\ +\t\t~> alu:\n\ +\t\t\t!>\n\ +\t\t\t\talu header\n\ +\t\t\t\n\ +\t\t\t\talu bytecode\n\ +\t\t\t<^\n\ +\t\t<~ alu.\n\ +\t<~ cpu.\n\ +<~ root.\n", )) .unwrap(); - assert_eq!(parsed.context().section_names, vec!["cpu", "alu"]); + assert!(parsed.context().section_names.is_empty()); let root = parsed.root(); assert!(root.path().is_root()); assert_eq!(root.local_name(), None); assert_eq!(root.display_path().to_string(), ""); - assert_eq!(root.header_text(), "root header\n"); - assert_eq!(root.text(), "root bytecode\n"); + assert_eq!(root.header_text(), "\t\troot header\n"); + assert_eq!(root.text(), "\t\troot bytecode\n"); let cpu = root.child("cpu").unwrap(); - assert_eq!(cpu.path().components(), &[0]); + assert_eq!(path_components(cpu.path()), vec!["cpu"]); assert_eq!(cpu.local_name(), Some("cpu")); assert_eq!(cpu.display_path().to_string(), "cpu"); - assert_eq!(cpu.header_text(), "cpu header\n"); - assert_eq!(cpu.text(), "cpu bytecode\n"); + assert_eq!(cpu.header_text(), "\t\t\tcpu header\n"); + assert_eq!(cpu.text(), "\t\t\tcpu bytecode\n"); let alu = cpu.child("alu").unwrap(); - assert_eq!(alu.path().components(), &[0, 1]); + assert_eq!(path_components(alu.path()), vec!["cpu", "alu"]); assert_eq!(alu.local_name(), Some("alu")); assert_eq!(alu.display_path().to_string(), "cpu/alu"); - assert_eq!(alu.header_text(), "alu header\n"); - assert_eq!(alu.text(), "alu bytecode\n"); + assert_eq!(alu.header_text(), "\t\t\t\talu header\n"); + assert_eq!(alu.text(), "\t\t\t\talu bytecode\n"); } #[test] fn parses_text_section_without_header_or_bytecode_as_empty_ranges() { - let parsed = TextBytecodeFile::::from_text(&text_file( - "", - r#"begin section root: -end section root -"#, - )) - .unwrap(); + let parsed = + TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ root.\n")).unwrap(); let root = parsed.root(); assert_eq!(root.header_text(), ""); @@ -373,7 +367,7 @@ end section root #[test] fn rejects_text_bad_version() { let err = TextBytecodeFile::::from_text(&format!( - "vihaco version {}\nbegin context:\nend context\nbegin section root:\nend section root\n", + "vhbc{}\n@>\n<@\n~> root:\n<~ root.\n", VERSION + 1 )) .unwrap_err(); @@ -383,40 +377,28 @@ fn rejects_text_bad_version() { #[test] fn rejects_text_missing_context_end() { - let err = TextBytecodeFile::::from_text( - "vihaco version 1\nbegin context:\ncpu\nbegin section root:\nend section root\n", - ) - .unwrap_err(); + let err = TextBytecodeFile::::from_text("vhbc1\n@>\ncpu\n~> root:\n<~ root.\n") + .unwrap_err(); assert!(err.to_string().contains("unterminated context")); } #[test] -fn rejects_text_missing_section_name() { +fn rejects_text_non_local_child_section_name() { let err = TextBytecodeFile::::from_text(&text_file( - "cpu\n", - r#"begin section root: -begin section gpu: -end section gpu -end section root -"#, + "", + "~> root:\n\t~> gpu/core:\n\t<~ gpu/core.\n<~ root.\n", )) .unwrap_err(); - assert!(err.to_string().contains("missing section name `gpu`")); + assert!(err.to_string().contains("must be a local name")); } #[test] fn rejects_text_duplicate_child_sections() { let err = TextBytecodeFile::::from_text(&text_file( "cpu\n", - r#"begin section root: -begin section cpu: -end section cpu -begin section cpu: -end section cpu -end section root -"#, + "~> root:\n\t~> cpu:\n\t<~ cpu.\n\t~> cpu:\n\t<~ cpu.\n<~ root.\n", )) .unwrap_err(); @@ -425,13 +407,8 @@ end section root #[test] fn rejects_text_mismatched_section_end_marker() { - let err = TextBytecodeFile::::from_text(&text_file( - "", - r#"begin section root: -end section other -"#, - )) - .unwrap_err(); + let err = TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ other.\n")) + .unwrap_err(); assert!(err.to_string().contains("mismatched marker `other`")); } @@ -440,10 +417,7 @@ end section other fn rejects_text_body_directly_inside_section() { let err = TextBytecodeFile::::from_text(&text_file( "", - r#"begin section root: -this line is not in a header or bytecode block -end section root -"#, + "~> root:\n\tthis line is not in a header or bytecode block\n<~ root.\n", )) .unwrap_err(); @@ -452,6 +426,28 @@ end section root .contains("unexpected content in section ``")); } +#[test] +fn rejects_text_child_section_indented_with_spaces() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + "~> root:\n ~> cpu:\n <~ cpu.\n<~ root.\n", + )) + .unwrap_err(); + + assert!(err.to_string().contains("child section must be indented")); +} + +#[test] +fn rejects_text_header_indented_with_spaces() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + "~> root:\n !>\n\t\troot header\n , root: Vec) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(MAGIC); @@ -642,8 +638,12 @@ fn raw_binary_section_with_entry_offsets( bytes } +fn path_components(path: &SectionPath) -> Vec<&str> { + path.components().iter().map(String::as_str).collect() +} + fn text_file(context: &str, sections: &str) -> String { - format!("vihaco version {VERSION}\nbegin context:\n{context}end context\n{sections}") + format!("vhbc{VERSION}\n\n@>\n{context}<@\n\n{sections}") } fn write_string(bytes: &mut Vec, value: &str) { diff --git a/crates/vihaco/src/binary/text.rs b/crates/vihaco/src/binary/text.rs index 487c57b..cd126f4 100644 --- a/crates/vihaco/src/binary/text.rs +++ b/crates/vihaco/src/binary/text.rs @@ -23,10 +23,10 @@ pub(super) enum LineKind { EndContext, BeginSection(String), EndSection(String), - BeginHeader(String), - EndHeader(String), - BeginBytecode(String), - EndBytecode(String), + BeginHeader, + EndHeader, + BeginBytecode, + EndBytecode, Body, Blank, } @@ -35,6 +35,7 @@ pub(super) enum LineKind { pub(super) struct SourceLine { pub(super) kind: LineKind, pub(super) full: Range, + pub(super) indent: usize, pub(super) number: usize, } @@ -77,80 +78,44 @@ fn parse_line(line: &str) -> Result { } fn line_parser<'src>() -> impl Parser<'src, &'src str, LineKind, ParseExtra<'src>> { - let hspace = one_of(" \t").repeated(); - let required_hspace = one_of(" \t").repeated().at_least(1); + let space = one_of(" \t").repeated().at_least(1); let name = any() - .filter(|c: &char| !c.is_whitespace() && *c != ':') + .filter(|c: &char| !c.is_whitespace() && !matches!(*c, ':' | '.')) .repeated() .at_least(1) .collect::(); - let colon = just(':'); - let version = just("vihaco") - .ignore_then(required_hspace.clone()) - .ignore_then(just("version")) - .ignore_then(required_hspace.clone()) + let version = just("vhbc") .ignore_then(text::int(10).try_map(|version: &str, span| { version.parse::().map_err(|_| Simple::new(None, span)) })) .map(LineKind::Version); - let begin_context = just("begin") - .ignore_then(required_hspace.clone()) - .ignore_then(just("context")) - .then_ignore(colon) - .to(LineKind::BeginContext); - let end_context = just("end") - .ignore_then(required_hspace.clone()) - .ignore_then(just("context")) - .to(LineKind::EndContext); - - let begin_section = just("begin") - .ignore_then(required_hspace.clone()) - .ignore_then(just("section")) - .ignore_then(required_hspace.clone()) + let begin_context = just("@>").to(LineKind::BeginContext); + let end_context = just("<@").to(LineKind::EndContext); + + let begin_section = just("~>") + .ignore_then(space.clone()) .ignore_then(name.clone()) - .then_ignore(colon) + .then_ignore(just(':')) .map(LineKind::BeginSection); - let end_section = just("end") - .ignore_then(required_hspace.clone()) - .ignore_then(just("section")) - .ignore_then(required_hspace.clone()) + let end_section = just("<~") + .ignore_then(space) .ignore_then(name.clone()) + .then_ignore(just('.')) .map(LineKind::EndSection); - let begin_header = just("begin") - .ignore_then(required_hspace.clone()) - .ignore_then(just("header")) - .ignore_then(required_hspace.clone()) - .ignore_then(name.clone()) - .then_ignore(colon) - .map(LineKind::BeginHeader); - let end_header = just("end") - .ignore_then(required_hspace.clone()) - .ignore_then(just("header")) - .ignore_then(required_hspace.clone()) - .ignore_then(name.clone()) - .map(LineKind::EndHeader); + let begin_header = just("!>").to(LineKind::BeginHeader); + let end_header = just("").to(LineKind::BeginBytecode); + let end_bytecode = just("<^").to(LineKind::EndBytecode); + + let blank = one_of(" \t").repeated().to(LineKind::Blank); let body = any().repeated().at_least(1).to(LineKind::Body); - hspace + just(' ') + .repeated() .ignore_then(choice(( version, begin_context, @@ -189,10 +154,16 @@ pub(super) fn lex_lines(text: &str) -> Result> { } } + let indent = text[start..content_end] + .bytes() + .take_while(|byte| *byte == b'\t') + .count(); + let line_start = start + indent; lines.push(SourceLine { - kind: parse_line(&text[start..content_end]) + kind: parse_line(&text[line_start..content_end]) .map_err(|err| eyre::eyre!("line {}: {err}", index + 1))?, full: start..full_end, + indent, number: index + 1, }); start = full_end; @@ -200,10 +171,16 @@ pub(super) fn lex_lines(text: &str) -> Result> { if start < text.len() { let number = lines.len() + 1; + let indent = text[start..] + .bytes() + .take_while(|byte| *byte == b'\t') + .count(); + let line_start = start + indent; lines.push(SourceLine { - kind: parse_line(&text[start..]) + kind: parse_line(&text[line_start..]) .map_err(|err| eyre::eyre!("line {}: {err}", number))?, full: start..text.len(), + indent, number, }); } @@ -241,11 +218,12 @@ impl<'a> LineCursor<'a> { pub(super) fn consume_context(cursor: &mut LineCursor<'_>) -> Result { while let Some(line) = cursor.next_significant() { if line.kind == LineKind::EndContext { + ensure_indent(line, 0, "context end")?; return Ok(line.full.start); } } - Err(eyre::eyre!("unterminated context; expected `end context`")) + Err(eyre::eyre!("unterminated context; expected `<@`")) } pub(super) struct TextSectionParseInfo<'a> { @@ -256,6 +234,7 @@ pub(super) struct TextSectionParseInfo<'a> { #[derive(Clone, Copy)] pub(super) struct ParentSection<'a> { pub(super) path: &'a SectionPath, + pub(super) indent: usize, } pub(super) fn parse_section( @@ -277,12 +256,16 @@ where .ok_or_else(|| eyre::eyre!("expected section"))?; debug_assert_eq!(consumed_begin.full, begin.full); + let section_indent = begin.indent; + match parent { + Some(parent) => ensure_indent(begin, parent.indent + 1, "child section")?, + None => ensure_indent(begin, 0, "root section")?, + } + let path = match parent { Some(parent) => { - validate_local_section_name(parent.path, context, §ion_name)?; - parent - .path - .child(find_section_name(context, parent.path, §ion_name)?) + validate_local_section_name(parent.path, §ion_name)?; + parent.path.child(section_name.clone()) } None => SectionPath::root(), }; @@ -299,6 +282,7 @@ where match &line.kind { LineKind::EndSection(name) => { + ensure_indent(line, section_indent, "section end")?; if name != §ion_name { return Err(line_error( line, @@ -318,73 +302,53 @@ where children, }); } - LineKind::BeginHeader(name) => { - if name != §ion_name { - return Err(line_error( - line, - format!( - "section `{}` has header marker for `{}`", - section_name, name - ), - )); - } + LineKind::BeginHeader => { + ensure_indent(line, section_indent + 1, "header")?; if header.is_some() { return Err(line_error( line, - format!( - "section `{}` declares duplicate header", - path.display(context) - ), + format!("section `{}` declares duplicate header", path), )); } header = Some(consume_named_block( cursor, §ion_name, "header", + section_indent + 1, |kind| match kind { - LineKind::EndHeader(name) => Some(name.as_str()), - _ => None, + LineKind::EndHeader => true, + _ => false, }, )?); } - LineKind::BeginBytecode(name) => { - if name != §ion_name { - return Err(line_error( - line, - format!( - "section `{}` has bytecode marker for `{}`", - section_name, name - ), - )); - } + LineKind::BeginBytecode => { + ensure_indent(line, section_indent + 1, "bytecode")?; if bytecode.is_some() { return Err(line_error( line, - format!( - "section `{}` declares duplicate bytecode", - path.display(context) - ), + format!("section `{}` declares duplicate bytecode", path), )); } bytecode = Some(consume_named_block( cursor, §ion_name, "bytecode", + section_indent + 1, |kind| match kind { - LineKind::EndBytecode(name) => Some(name.as_str()), - _ => None, + LineKind::EndBytecode => true, + _ => false, }, )?); } LineKind::BeginSection(child_name) => { - validate_local_section_name(&path, context, child_name)?; + ensure_indent(line, section_indent + 1, "child section")?; + validate_local_section_name(&path, child_name)?; if !child_names.insert(child_name.clone()) { return Err(line_error( line, format!( "section `{}` declares duplicate child `{}`", - path.display(context), - child_name + path, child_name ), )); } @@ -392,7 +356,10 @@ where cursor, context, TextSectionParseInfo { - parent: Some(ParentSection { path: &path }), + parent: Some(ParentSection { + path: &path, + indent: section_indent, + }), begin: line, }, )?; @@ -404,7 +371,7 @@ where _ => { return Err(line_error( line, - format!("unexpected content in section `{}`", path.display(context)), + format!("unexpected content in section `{}`", path), )); } } @@ -415,57 +382,46 @@ fn consume_named_block( cursor: &mut LineCursor<'_>, section_name: &str, label: &str, - end_name: impl Fn(&LineKind) -> Option<&str>, + block_indent: usize, + end_name: impl Fn(&LineKind) -> bool, ) -> Result> { let begin = cursor .next_significant() - .ok_or_else(|| eyre::eyre!("expected `begin {label} {section_name}:`"))?; + .ok_or_else(|| eyre::eyre!("expected `{label}` block in section `{section_name}`"))?; let start = begin.full.end; while let Some(line) = cursor.next_significant() { - if let Some(name) = end_name(&line.kind) { - if name != section_name { - return Err(line_error( - line, - format!( - "{label} `{}` ended with mismatched marker `{}`", - section_name, name - ), - )); - } + if end_name(&line.kind) { + ensure_indent(line, block_indent, label)?; return Ok(start..line.full.start); } } Err(line_error( begin, - format!("unterminated {label}; expected `end {label} {section_name}`"), + format!("unterminated {label}; expected `{}`", end_marker(label)), )) } -fn find_section_name(context: &C, parent: &SectionPath, name: &str) -> Result -where - C: BytecodeContext, -{ - let mut index = 0; - while let Some(candidate) = context.section_name(index) { - if candidate == name { - return Ok(index); - } - index = index.checked_add(1).ok_or_else(|| { - eyre::eyre!( - "section `{}` name lookup overflowed while resolving `{}`", - parent.display(context), - name - ) - })?; +fn ensure_indent(line: &SourceLine, expected: usize, label: &str) -> Result<()> { + if line.indent != expected { + return Err(line_error( + line, + format!( + "{label} must be indented with {expected} tab(s), found {}", + line.indent + ), + )); } + Ok(()) +} - Err(eyre::eyre!( - "section `{}` references missing section name `{}`", - parent.display(context), - name - )) +fn end_marker(label: &str) -> &'static str { + match label { + "header" => " "<^", + _ => "end marker", + } } pub(super) fn line_error(line: &SourceLine, message: impl std::fmt::Display) -> eyre::Report { diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index 08ef2d1..e4eca3e 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -319,22 +319,21 @@ fn binary_generated_loadable_routes_program_and_child_sections() { fn text_generated_loadable_routes_program_and_child_sections() { let file = text_file( &["child", "default_child"], - r#"begin section root: -begin bytecode root: -nop -end bytecode root -begin section child: -begin bytecode child: -alt -end bytecode child -end section child -begin section default_child: -begin bytecode default_child: -nop -end bytecode default_child -end section default_child -end section root -"#, + "~> root:\n\ +\t^>\n\ +\t\tnop\n\ +\t<^\n\ +\t~> child:\n\ +\t\t^>\n\ +\t\t\talt\n\ +\t\t<^\n\ +\t<~ child.\n\ +\t~> default_child:\n\ +\t\t^>\n\ +\t\t\tnop\n\ +\t\t<^\n\ +\t<~ default_child.\n\ +<~ root.\n", ); let mut machine = TextMachine::default(); @@ -377,15 +376,14 @@ fn binary_generated_loadable_parses_marked_header() { fn text_generated_loadable_parses_marked_header() { let file = text_file( &[], - r#"begin section root: -begin header root: -8 -end header root -begin bytecode root: -nop -end bytecode root -end section root -"#, + "~> root:\n\ +\t!>\n\ +\t\t8\n\ +\t\n\ +\t\tnop\n\ +\t<^\n\ +<~ root.\n", ); let mut machine = TextHeaderMachine::default(); @@ -446,23 +444,22 @@ fn binary_generated_loadable_routes_three_level_section_tree() { fn text_generated_loadable_routes_three_level_section_tree() { let file = text_file( &["middle", "leaf"], - r#"begin section root: -begin bytecode root: -nop -end bytecode root -begin section middle: -begin bytecode middle: -alt -end bytecode middle -begin section leaf: -begin bytecode leaf: -nop -alt -end bytecode leaf -end section leaf -end section middle -end section root -"#, + "~> root:\n\ +\t^>\n\ +\t\tnop\n\ +\t<^\n\ +\t~> middle:\n\ +\t\t^>\n\ +\t\t\talt\n\ +\t\t<^\n\ +\t\t~> leaf:\n\ +\t\t\t^>\n\ +\t\t\t\tnop\n\ +\t\t\t\talt\n\ +\t\t\t<^\n\ +\t\t<~ leaf.\n\ +\t<~ middle.\n\ +<~ root.\n", ); let mut machine = TextHostMachine::default(); @@ -513,12 +510,11 @@ fn binary_generated_loadable_requires_marked_children() { fn text_generated_loadable_requires_marked_children() { let file = text_file( &["child", "default_child"], - r#"begin section root: -begin bytecode root: -nop -end bytecode root -end section root -"#, + "~> root:\n\ +\t^>\n\ +\t\tnop\n\ +\t<^\n\ +<~ root.\n", ); let mut machine = TextMachine::default(); @@ -544,18 +540,17 @@ fn binary_generated_loadable_rejects_unexpected_direct_children() { fn text_generated_loadable_rejects_unexpected_direct_children() { let file = text_file( &["child", "default_child", "extra"], - r#"begin section root: -begin bytecode root: -nop -end bytecode root -begin section child: -end section child -begin section default_child: -end section default_child -begin section extra: -end section extra -end section root -"#, + "~> root:\n\ +\t^>\n\ +\t\tnop\n\ +\t<^\n\ +\t~> child:\n\ +\t<~ child.\n\ +\t~> default_child:\n\ +\t<~ default_child.\n\ +\t~> extra:\n\ +\t<~ extra.\n\ +<~ root.\n", ); let mut machine = TextMachine::default(); @@ -585,7 +580,7 @@ fn text_file(section_names: &[&str], sections: &str) -> BytecodeFile::from_text(&format!( - "vihaco version {VERSION}\nbegin context:\n{context}end context\n{sections}" + "vhbc{VERSION}\n\n@>\n{context}<@\n\n{sections}" )) .unwrap() } From 004029057da96dcd0e84fe9dce6fd19212a4baa6 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Thu, 25 Jun 2026 13:53:17 -0400 Subject: [PATCH 05/12] Export byte/text specific multi-section types; update docs --- crates/vihaco/src/binary/mod.rs | 4 +- crates/vihaco/src/lib.rs | 15 +++--- docs/src/pages/guide/composites.md | 73 ++++++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs index d3e75f3..749fda5 100644 --- a/crates/vihaco/src/binary/mod.rs +++ b/crates/vihaco/src/binary/mod.rs @@ -13,7 +13,7 @@ mod text; mod tests; pub use context::{BytecodeContext, ContextHandle, ProgramContext, ProgramGlobals}; -pub use file::{BytecodeFile, FileContents}; +pub use file::{BinaryBytecodeFile, BytecodeFile, FileContents, TextBytecodeFile}; pub use format::{decode_instruction_stream, CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION}; -pub use section::{SectionPath, SectionView}; +pub use section::{BinarySectionView, SectionPath, SectionView, TextSectionView}; pub use text::parse_instruction_stream; diff --git a/crates/vihaco/src/lib.rs b/crates/vihaco/src/lib.rs index 34b7df7..3b1fc2f 100644 --- a/crates/vihaco/src/lib.rs +++ b/crates/vihaco/src/lib.rs @@ -24,8 +24,9 @@ pub mod traits; pub mod value; pub use binary::{ - BytecodeContext, BytecodeFile, CompositeHeader, ConstantId, ContextHandle, ProgramContext, - ProgramGlobals, SectionPath, SectionView, + BinaryBytecodeFile, BinarySectionView, BytecodeContext, BytecodeFile, CompositeHeader, + ConstantId, ContextHandle, ProgramContext, ProgramGlobals, SectionPath, SectionView, + TextBytecodeFile, TextSectionView, }; pub use effect::Effects; pub use instruction_syntax::{ @@ -33,10 +34,10 @@ pub use instruction_syntax::{ InstructionSugarVariantSyntax, OperandKind, SugarOperandKind, }; pub use loader::{LoadInput, LoadSection, ModuleProgramLoader, ProgramLoader}; -pub use macros::{Instruction, Message, component, composite, observe}; +pub use macros::{component, composite, observe, Instruction, Message}; pub use runtime::{ - CompositeMetadata, EffectSink, GeneratedComponent, Message as MessageMarker, Observe, - expect_exactly_one_effect, + expect_exactly_one_effect, CompositeMetadata, EffectSink, GeneratedComponent, + Message as MessageMarker, Observe, }; pub use traits::{GetProgramGlobal, Reset}; pub use value::{Type, Value}; @@ -44,12 +45,12 @@ pub use value::{Type, Value}; #[cfg(test)] mod public_api_tests { use crate::{ - BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, - Reset, binary::ConstantId, instruction::{FromBytes, OpCode, WriteBytes}, module::FunctionInfo, observer::stdio::StdoutEffect, + BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, + Reset, }; struct PublicReset; diff --git a/docs/src/pages/guide/composites.md b/docs/src/pages/guide/composites.md index 9e7b0e8..3c3da5a 100644 --- a/docs/src/pages/guide/composites.md +++ b/docs/src/pages/guide/composites.md @@ -159,7 +159,7 @@ pub struct CpuMachine { } ``` -The field type must implement `CompositeHeader`, which has a blanket impl for types implementing `FromBytes + WriteBytes`. Generated loading parses `input.section.header_bytes()` with `SectionView::parse_header::()` and assigns the result to the field before loading the program or child sections. Composites without a `#[header]` field generate no header parsing code, no header trait bound, and no runtime header check. +For binary bytecode, the field type must implement `CompositeHeader`, which has a blanket impl for types implementing `FromBytes + WriteBytes`. Generated binary loading parses `input.section.header_bytes()` with `BinarySectionView::parse_header::()` and assigns the result to the field before loading the program or child sections. For text bytecode, generated loading trims `input.section.header_text()` and parses it with `FromStr`, so the header type must also implement `FromStr` when you load `TextBytecodeFile`. Composites without a `#[header]` field generate no header parsing code, no header trait bound, and no runtime header check. ### `#[loadable]` @@ -178,9 +178,9 @@ pub struct Machine { } ``` -`#[loadable]` must be used on a `#[device(...)]` field whose type implements `LoadSection` as well as `GeneratedComponent`. It uses the field name as the local section name. `#[loadable("name")]` overrides it. Names must be non-empty direct child names, so they cannot contain `/`. +`#[loadable]` must be used on a `#[device(...)]` field whose type implements `LoadSection` for the bytecode representation you are loading as well as `GeneratedComponent`. Binary bytecode delegates through `LoadSection, C>`; text bytecode delegates through `LoadSection`. The attribute uses the field name as the local section name. `#[loadable("name")]` overrides it. Names must be non-empty direct child names, so they cannot contain `/`. -Section identity is represented as a `SectionPath`, which is a vector of string-table indices for local path components. The root section is `SectionPath::root()` with zero components. A root child named `cpu` has the path `[cpu]`; a child named `alu` inside that section has `[cpu, alu]`. Generated loading asks the current section for direct children by local name, so the same composite can be loaded at the root or under another parent section. +Section identity is represented as a `SectionPath`, which is a vector of resolved local section names. The root section is `SectionPath::root()` with zero components. A root child named `cpu` has the path `cpu`; a child named `alu` inside that section has `cpu/alu`. Generated loading asks the current section for direct children by local name, so the same composite can be loaded at the root or under another parent section. Manual loaders can inspect a whole section subtree with `SectionView::walk()`, which yields the current section followed by its descendants in depth-first order. `SectionView::descendants()` uses the same order but skips the current section. To walk an entire file, use `BytecodeFile::sections()`. @@ -189,20 +189,20 @@ The generated loader is strict: - every `#[loadable]` device field must have exactly one matching direct child section - every direct child section must correspond to a `#[loadable]` device field - if the composite has no `#[program]` field, its own section bytecode must be empty -- composite headers are parsed by the macro only when the composite has a `#[header]` field; manual loaders can still inspect raw headers through `SectionView::header_bytes()` or `SectionView::parse_header::()` +- composite headers are parsed by the macro only when the composite has a `#[header]` field; manual loaders can still inspect binary headers through `BinarySectionView::header_bytes()` / `BinarySectionView::parse_header::()`, or text headers through `TextSectionView::header_text()` ## Multi-Section Bytecode The read-side bytecode API lives in `vihaco::binary` and `vihaco::loader`. ```rust ignore -fn load_machine<'bc>(file: &'bc vihaco::BytecodeFile) -> eyre::Result { +fn load_machine<'bc>(file: &'bc vihaco::BinaryBytecodeFile) -> eyre::Result { let mut machine = Machine::default(); machine.load_section(vihaco::LoadInput::from(file))?; Ok(machine) } -let file: vihaco::BytecodeFile = vihaco::BytecodeFile::from_bytes(bytes)?; +let file: vihaco::BinaryBytecodeFile = vihaco::BinaryBytecodeFile::from_bytes(bytes)?; let machine = load_machine(&file)?; ``` @@ -217,7 +217,7 @@ program context bytes root section bytes ``` -The program context contains the shared `Module` tables except `code` and `extra`: constants, strings, functions, labels, `main_function`, `file`, and source symbols. `ProgramContext` is the default context representation and is generic over the VM's constant value and type encodings. A bytecode file can also use a custom context type by implementing `BytecodeContext`; generated composite loading is generic over that context type, so Rust infers it from `BytecodeFile` / `LoadInput`. Section bytecode can refer to shared constants with `vihaco::ConstantId`, a `u32` newtype that implements the bytecode field traits. +The program context contains the shared `Module` tables except `code` and `extra`: constants, strings, functions, labels, `main_function`, `file`, and source symbols. `ProgramContext` is the default context representation and is generic over the VM's constant value and type encodings. A binary bytecode file can also use a custom context type by implementing `BytecodeContext`; generated composite loading is generic over that context type, so Rust infers it from `BinaryBytecodeFile` / `LoadInput, C>`. Section bytecode can refer to shared constants with `vihaco::ConstantId`, a `u32` newtype that implements the bytecode field traits. Each section is: @@ -242,9 +242,64 @@ u32 local_name_string u64 section_offset ``` -The section frame is part of every section, including the root section. The bytecode header starts after the composite header, and the section's bytecode immediately follows that length. Child-related metadata comes after the parent bytecode. `local_name_string` is resolved through `BytecodeContext::section_name` and represents the child's local section name. The parser builds each child's `SectionPath` by appending that component to the parent path. Child section offsets are relative to the start of the containing section. +The section frame is part of every section, including the root section. The bytecode header starts after the composite header, and the section's bytecode immediately follows that length. Child-related metadata comes after the parent bytecode. `local_name_string` is resolved through `BytecodeContext::section_name` and represents the child's local section name. The parser builds each child's `SectionPath` by appending that resolved name to the parent path. Child section offsets are relative to the start of the containing section. -`ProgramLoader` is the standard loader for fixed-width instruction streams. It decodes `section.bytecode()` with `decode_instruction_stream::()`, stores a cloned `BytecodeContextHandle`, implements `ProgramCounter`, and exposes functions, strings, and constants through `GetProgramGlobal` when `C: ProgramGlobals`. +### Text Multi-Section Bytecode + +Text bytecode uses `TextBytecodeFile`, `TextSectionView<'bc, C>`, `LoadInput`, and generated `LoadSection` machinery. The backing contents are the original source text and each section stores ranges into that string. Parse text files with `TextBytecodeFile::::from_text(source)`: + +```rust ignore +let file: vihaco::TextBytecodeFile = + vihaco::TextBytecodeFile::from_text(source)?; + +let mut machine = Machine::default(); +machine.load_section(vihaco::LoadInput::from(&file))?; +``` + +The file begins with the text magic/version marker, then a global context block: + +```text +vhbc1 + +@> +global context text +<@ +``` + +`vhbc1` is the text spelling of version 1. The context body is delegated to `C::from_bytes(context_text.as_bytes())`; custom text formats usually provide a custom `BytecodeContext` that interprets this block. The context start marker `@>` and end marker `<@` must be at indentation level 0. + +After the context comes the root section. Sections use `~> name:` to begin and `<~ name.` to end. The root section is still parsed as `SectionPath::root()`, so its marker name is only a matching delimiter; direct child section names become path components. + +```text +~> root: + !> + root header + + root bytecode + <^ + + ~> cpu: + ^> + cpu bytecode + <^ + <~ cpu. +<~ root. +``` + +Inside a section: + +- `!>` / `` / `<^` delimit the section bytecode text for `TextSectionView::text()` +- child sections are nested directly inside their parent section +- header, bytecode, and direct child section markers must be indented with exactly one tab more than their parent section +- section end markers must use the same indentation as their matching section start marker +- section names must be local names; `/` is rejected in a single marker name + +The text parser preserves the original header and bytecode ranges, including their leading tabs. Generated header loading trims `section.header_text()` before calling `FromStr` for the `#[header]` field type. Generated program loading delegates to `ProgramLoader`, which parses `section.text()` with `parse_instruction_stream::()`; instruction types loaded this way must implement both `Instruction` and `vihaco_parser_core::Parse`. + +`ProgramLoader` is the standard loader for section program streams. For binary bytecode it decodes `section.bytecode()` with `decode_instruction_stream::()`; for text bytecode it parses `section.text()` with `parse_instruction_stream::()`. In both cases it stores a cloned `ContextHandle`, implements `ProgramCounter`, and exposes functions, strings, and constants through `GetProgramGlobal` when `C: ProgramGlobals`. ## Effect Continuation Is Hand-Written From bbb50fe142dcfba622c7805e1750bfe1e4e49073 Mon Sep 17 00:00:00 2001 From: "Xiu-zhe (Roger) Luo" Date: Mon, 22 Jun 2026 14:38:57 -0400 Subject: [PATCH 06/12] ci(release): pin release-plz by version tag; add pull-requests: read (#26) Per release-plz's official quickstart, switch release-plz/action from a commit SHA back to the version tag (v0.5.130) -- consistent with the action's own docs and the other version-pinned actions in this repo. A comment flags that the "name@version" ref can be mangled by email-obfuscation tooling, so the ref must be verified after automated edits. Also add `pull-requests: read` to the release job, matching the recommended setup (release-plz uses it to link merged PRs in the generated release notes). Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/release-plz.yml | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 6069359..74cabf6 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -9,9 +9,23 @@ name: Release-plz # the release, creates the GitHub Release(s), and # publishes the changed crates to crates.io. # -# crates.io auth uses OIDC trusted publishing (no stored token) — see the -# `id-token: write` permission on the release job. Configure each crate's -# trusted publisher at https://crates.io/crates//settings once it exists. +# Auth: +# - GitHub: the default GITHUB_TOKEN (no PAT/App, so no org-admin setup). +# Tradeoff: PRs opened by GITHUB_TOKEN do not trigger CI, so the +# release PR won't run the CI workflow. That's acceptable here -- +# it only bumps versions/changelogs, and the feature PRs feeding +# the release already passed CI. To get CI on the release PR, +# swap in a PAT/App token later. +# - crates.io: OIDC trusted publishing (no stored token) -- see the +# `id-token: write` permission on the release job. Configure each +# crate's trusted publisher at crates.io/crates//settings. +# +# NOTE: pinned by version tag (matching release-plz's docs and the other +# actions in this repo). Heads-up for automated edits: a "name@version" ref +# looks like an email address, so some web proxies / AI assistants rewrite it +# into an obfuscated placeholder -- an invalid `uses:` ref that makes GitHub +# reject the whole workflow at startup. Verify the release-plz `uses:` lines +# after editing this file with such tools. on: push: @@ -27,8 +41,9 @@ jobs: name: Release-plz release runs-on: ubuntu-latest permissions: - contents: write # push tags + create GitHub Releases - id-token: write # OIDC: mint a short-lived crates.io token (trusted publishing) + contents: write # push tags + create GitHub Releases + pull-requests: read # link merged PRs in the release notes + id-token: write # OIDC: mint a short-lived crates.io token (trusted publishing) steps: - name: Generate GitHub App token uses: actions/create-github-app-token@v3 @@ -45,7 +60,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Run release-plz - uses: release-plz/[email protected] + uses: release-plz/action@v0.5.130 with: command: release env: @@ -82,7 +97,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - name: Run release-plz - uses: release-plz/[email protected] + uses: release-plz/action@v0.5.130 with: command: release-pr env: From faddcb42976690e06ff81223ae1b9a3634757d7a Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Thu, 25 Jun 2026 14:28:26 -0400 Subject: [PATCH 07/12] Update root section of text bytecode format to be named "/" --- crates/vihaco/src/binary/tests.rs | 32 ++++++++++++-------- crates/vihaco/src/binary/text.rs | 11 ++++++- crates/vihaco/tests/multisection_bytecode.rs | 20 ++++++------ docs/src/pages/guide/composites.md | 6 ++-- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index 214cc64..149e1f2 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -303,7 +303,7 @@ fn rejects_binary_instruction_stream_with_non_multiple_width() { fn parses_text_context_and_nested_sections() { let parsed = TextBytecodeFile::::from_text(&text_file( "", - "~> root:\n\ + "~> /:\n\ \t!>\n\ \t\troot header\n\ \t::from_text(&text_file("", "~> root:\n<~ root.\n")).unwrap(); + TextBytecodeFile::::from_text(&text_file("", "~> /:\n<~ /.\n")).unwrap(); let root = parsed.root(); assert_eq!(root.header_text(), ""); assert_eq!(root.text(), ""); } +#[test] +fn rejects_text_root_section_with_non_root_name() { + let err = TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ root.\n")) + .unwrap_err(); + + assert!(err.to_string().contains("root section must be named `/`")); +} + #[test] fn rejects_text_bad_version() { let err = TextBytecodeFile::::from_text(&format!( - "vhbc{}\n@>\n<@\n~> root:\n<~ root.\n", + "vhbc{}\n@>\n<@\n~> /:\n<~ /.\n", VERSION + 1 )) .unwrap_err(); @@ -377,8 +385,8 @@ fn rejects_text_bad_version() { #[test] fn rejects_text_missing_context_end() { - let err = TextBytecodeFile::::from_text("vhbc1\n@>\ncpu\n~> root:\n<~ root.\n") - .unwrap_err(); + let err = + TextBytecodeFile::::from_text("vhbc1\n@>\ncpu\n~> /:\n<~ /.\n").unwrap_err(); assert!(err.to_string().contains("unterminated context")); } @@ -387,7 +395,7 @@ fn rejects_text_missing_context_end() { fn rejects_text_non_local_child_section_name() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> root:\n\t~> gpu/core:\n\t<~ gpu/core.\n<~ root.\n", + "~> /:\n\t~> gpu/core:\n\t<~ gpu/core.\n<~ /.\n", )) .unwrap_err(); @@ -398,7 +406,7 @@ fn rejects_text_non_local_child_section_name() { fn rejects_text_duplicate_child_sections() { let err = TextBytecodeFile::::from_text(&text_file( "cpu\n", - "~> root:\n\t~> cpu:\n\t<~ cpu.\n\t~> cpu:\n\t<~ cpu.\n<~ root.\n", + "~> /:\n\t~> cpu:\n\t<~ cpu.\n\t~> cpu:\n\t<~ cpu.\n<~ /.\n", )) .unwrap_err(); @@ -407,7 +415,7 @@ fn rejects_text_duplicate_child_sections() { #[test] fn rejects_text_mismatched_section_end_marker() { - let err = TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ other.\n")) + let err = TextBytecodeFile::::from_text(&text_file("", "~> /:\n<~ other.\n")) .unwrap_err(); assert!(err.to_string().contains("mismatched marker `other`")); @@ -417,7 +425,7 @@ fn rejects_text_mismatched_section_end_marker() { fn rejects_text_body_directly_inside_section() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> root:\n\tthis line is not in a header or bytecode block\n<~ root.\n", + "~> /:\n\tthis line is not in a header or bytecode block\n<~ /.\n", )) .unwrap_err(); @@ -430,7 +438,7 @@ fn rejects_text_body_directly_inside_section() { fn rejects_text_child_section_indented_with_spaces() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> root:\n ~> cpu:\n <~ cpu.\n<~ root.\n", + "~> /:\n ~> cpu:\n <~ cpu.\n<~ /.\n", )) .unwrap_err(); @@ -441,7 +449,7 @@ fn rejects_text_child_section_indented_with_spaces() { fn rejects_text_header_indented_with_spaces() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> root:\n !>\n\t\troot header\n /:\n !>\n\t\troot header\n = extra::Err>; +const ROOT_SECTION_NAME: &str = "/"; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum LineKind { @@ -259,7 +260,15 @@ where let section_indent = begin.indent; match parent { Some(parent) => ensure_indent(begin, parent.indent + 1, "child section")?, - None => ensure_indent(begin, 0, "root section")?, + None => { + ensure_indent(begin, 0, "root section")?; + if section_name != ROOT_SECTION_NAME { + return Err(line_error( + begin, + format!("root section must be named `{ROOT_SECTION_NAME}`"), + )); + } + } } let path = match parent { diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index e4eca3e..3bb2194 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -319,7 +319,7 @@ fn binary_generated_loadable_routes_program_and_child_sections() { fn text_generated_loadable_routes_program_and_child_sections() { let file = text_file( &["child", "default_child"], - "~> root:\n\ + "~> /:\n\ \t^>\n\ \t\tnop\n\ \t<^\n\ @@ -333,7 +333,7 @@ fn text_generated_loadable_routes_program_and_child_sections() { \t\t\tnop\n\ \t\t<^\n\ \t<~ default_child.\n\ -<~ root.\n", +<~ /.\n", ); let mut machine = TextMachine::default(); @@ -376,14 +376,14 @@ fn binary_generated_loadable_parses_marked_header() { fn text_generated_loadable_parses_marked_header() { let file = text_file( &[], - "~> root:\n\ + "~> /:\n\ \t!>\n\ \t\t8\n\ \t\n\ \t\tnop\n\ \t<^\n\ -<~ root.\n", +<~ /.\n", ); let mut machine = TextHeaderMachine::default(); @@ -444,7 +444,7 @@ fn binary_generated_loadable_routes_three_level_section_tree() { fn text_generated_loadable_routes_three_level_section_tree() { let file = text_file( &["middle", "leaf"], - "~> root:\n\ + "~> /:\n\ \t^>\n\ \t\tnop\n\ \t<^\n\ @@ -459,7 +459,7 @@ fn text_generated_loadable_routes_three_level_section_tree() { \t\t\t<^\n\ \t\t<~ leaf.\n\ \t<~ middle.\n\ -<~ root.\n", +<~ /.\n", ); let mut machine = TextHostMachine::default(); @@ -510,11 +510,11 @@ fn binary_generated_loadable_requires_marked_children() { fn text_generated_loadable_requires_marked_children() { let file = text_file( &["child", "default_child"], - "~> root:\n\ + "~> /:\n\ \t^>\n\ \t\tnop\n\ \t<^\n\ -<~ root.\n", +<~ /.\n", ); let mut machine = TextMachine::default(); @@ -540,7 +540,7 @@ fn binary_generated_loadable_rejects_unexpected_direct_children() { fn text_generated_loadable_rejects_unexpected_direct_children() { let file = text_file( &["child", "default_child", "extra"], - "~> root:\n\ + "~> /:\n\ \t^>\n\ \t\tnop\n\ \t<^\n\ @@ -550,7 +550,7 @@ fn text_generated_loadable_rejects_unexpected_direct_children() { \t<~ default_child.\n\ \t~> extra:\n\ \t<~ extra.\n\ -<~ root.\n", +<~ /.\n", ); let mut machine = TextMachine::default(); diff --git a/docs/src/pages/guide/composites.md b/docs/src/pages/guide/composites.md index 3c3da5a..6c55125 100644 --- a/docs/src/pages/guide/composites.md +++ b/docs/src/pages/guide/composites.md @@ -268,10 +268,10 @@ global context text `vhbc1` is the text spelling of version 1. The context body is delegated to `C::from_bytes(context_text.as_bytes())`; custom text formats usually provide a custom `BytecodeContext` that interprets this block. The context start marker `@>` and end marker `<@` must be at indentation level 0. -After the context comes the root section. Sections use `~> name:` to begin and `<~ name.` to end. The root section is still parsed as `SectionPath::root()`, so its marker name is only a matching delimiter; direct child section names become path components. +After the context comes the root section. Sections use `~> name:` to begin and `<~ name.` to end. The top-level section must be named `/` (`~> /:` and `<~ /.`), and it is parsed as `SectionPath::root()`. Direct child section names become path components. ```text -~> root: +~> /: !> root header name:` to begin and ` cpu bytecode <^ <~ cpu. -<~ root. +<~ /. ``` Inside a section: From daaf946d5009dcc207a3baaf65c0eccb9b21b2c5 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Thu, 25 Jun 2026 14:29:52 -0400 Subject: [PATCH 08/12] Fix formatting issues --- crates/vihaco-derive/src/derive_machine.rs | 2 +- crates/vihaco/src/binary/file.rs | 8 +- crates/vihaco/src/binary/mod.rs | 2 +- crates/vihaco/src/binary/tests.rs | 14 +- crates/vihaco/src/lib.rs | 10 +- crates/vihaco/src/loader.rs | 6 +- crates/vihaco/tests/multisection_bytecode.rs | 160 +++++++++++-------- 7 files changed, 112 insertions(+), 90 deletions(-) diff --git a/crates/vihaco-derive/src/derive_machine.rs b/crates/vihaco-derive/src/derive_machine.rs index 4691911..525ddbc 100644 --- a/crates/vihaco-derive/src/derive_machine.rs +++ b/crates/vihaco-derive/src/derive_machine.rs @@ -3,7 +3,7 @@ use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; -use quote::{format_ident, quote, ToTokens}; +use quote::{ToTokens, format_ident, quote}; use std::collections::{BTreeMap, BTreeSet}; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index 6a1dcfd..0c434e4 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -4,14 +4,14 @@ use std::io::Cursor; use crate::binary::text::{ - consume_context, lex_lines, line_error as text_line_error, parse_section as parse_text_section, - verify_version, LineCursor, LineKind, TextSectionParseInfo, + LineCursor, LineKind, TextSectionParseInfo, consume_context, lex_lines, + line_error as text_line_error, parse_section as parse_text_section, verify_version, }; use super::{ context::{BytecodeContext, ContextHandle, ProgramContext}, format::BytecodeHeader, - parser::{checked_add, parse_section, SectionParseInfo}, + parser::{SectionParseInfo, checked_add, parse_section}, section::{SectionNode, SectionPath, SectionView}, }; @@ -145,7 +145,7 @@ where return Err(text_line_error( version, format!("expected `vhbc{}`", super::format::VERSION), - )) + )); } } diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs index 749fda5..e5aeba5 100644 --- a/crates/vihaco/src/binary/mod.rs +++ b/crates/vihaco/src/binary/mod.rs @@ -14,6 +14,6 @@ mod tests; pub use context::{BytecodeContext, ContextHandle, ProgramContext, ProgramGlobals}; pub use file::{BinaryBytecodeFile, BytecodeFile, FileContents, TextBytecodeFile}; -pub use format::{decode_instruction_stream, CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION}; +pub use format::{CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION, decode_instruction_stream}; pub use section::{BinarySectionView, SectionPath, SectionView, TextSectionView}; pub use text::parse_instruction_stream; diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index 149e1f2..74c7a5d 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -287,9 +287,10 @@ fn rejects_binary_bytecode_that_extends_past_section_end() { )) .unwrap_err(); - assert!(err - .to_string() - .contains("bytecode extends past section end")); + assert!( + err.to_string() + .contains("bytecode extends past section end") + ); } #[test] @@ -429,9 +430,10 @@ fn rejects_text_body_directly_inside_section() { )) .unwrap_err(); - assert!(err - .to_string() - .contains("unexpected content in section ``")); + assert!( + err.to_string() + .contains("unexpected content in section ``") + ); } #[test] diff --git a/crates/vihaco/src/lib.rs b/crates/vihaco/src/lib.rs index 3b1fc2f..191a8d3 100644 --- a/crates/vihaco/src/lib.rs +++ b/crates/vihaco/src/lib.rs @@ -34,10 +34,10 @@ pub use instruction_syntax::{ InstructionSugarVariantSyntax, OperandKind, SugarOperandKind, }; pub use loader::{LoadInput, LoadSection, ModuleProgramLoader, ProgramLoader}; -pub use macros::{component, composite, observe, Instruction, Message}; +pub use macros::{Instruction, Message, component, composite, observe}; pub use runtime::{ - expect_exactly_one_effect, CompositeMetadata, EffectSink, GeneratedComponent, - Message as MessageMarker, Observe, + CompositeMetadata, EffectSink, GeneratedComponent, Message as MessageMarker, Observe, + expect_exactly_one_effect, }; pub use traits::{GetProgramGlobal, Reset}; pub use value::{Type, Value}; @@ -45,12 +45,12 @@ pub use value::{Type, Value}; #[cfg(test)] mod public_api_tests { use crate::{ + BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, + Reset, binary::ConstantId, instruction::{FromBytes, OpCode, WriteBytes}, module::FunctionInfo, observer::stdio::StdoutEffect, - BytecodeContext, EffectSink, Effects, GeneratedComponent, LoadSection, ProgramGlobals, - Reset, }; struct PublicReset; diff --git a/crates/vihaco/src/loader.rs b/crates/vihaco/src/loader.rs index 1b46d4c..06d9610 100644 --- a/crates/vihaco/src/loader.rs +++ b/crates/vihaco/src/loader.rs @@ -2,14 +2,14 @@ // SPDX-License-Identifier: MIT use crate::{ + BytecodeContext, BytecodeFile, binary::{ - decode_instruction_stream, parse_instruction_stream, ConstantId, ContextHandle, - FileContents, ProgramContext, ProgramGlobals, SectionView, + ConstantId, ContextHandle, FileContents, ProgramContext, ProgramGlobals, SectionView, + decode_instruction_stream, parse_instruction_stream, }, module::{Module, NoInfo}, traits::{self, GetProgramGlobal, ProgramCounter}, value::{Type, Value}, - BytecodeContext, BytecodeFile, }; pub struct LoadInput<'bc, F = Vec, C = ProgramContext> diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index 3bb2194..9b5c1ac 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -5,10 +5,10 @@ use std::{io::Read, str::FromStr}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use vihaco::{ - binary::{FLAGS, MAGIC, VERSION}, - traits::{FromBytes, WriteBytes}, BytecodeContext, BytecodeFile, ConstantId, Effects, GeneratedComponent, GetProgramGlobal, Instruction, LoadInput, LoadSection, ProgramLoader, Value, + binary::{FLAGS, MAGIC, VERSION}, + traits::{FromBytes, WriteBytes}, }; const CHILD_NAME: u32 = 0; @@ -296,19 +296,23 @@ fn binary_generated_loadable_routes_program_and_child_sections() { vec![TestInst::Load(ConstantId(0))] ); assert_eq!(machine.default_child.program.code, vec![TestInst::Nop]); - assert!(machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .child - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .child + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); assert_eq!( machine.program.get_constant(ConstantId(0)).unwrap(), &Value::I64(9) @@ -342,19 +346,23 @@ fn text_generated_loadable_routes_program_and_child_sections() { assert_eq!(machine.program.code, vec![TextInst::Nop]); assert_eq!(machine.child.program.code, vec![TextInst::Alt]); assert_eq!(machine.default_child.program.code, vec![TextInst::Nop]); - assert!(machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .child - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .child + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); } #[test] @@ -417,27 +425,33 @@ fn binary_generated_loadable_routes_three_level_section_tree() { machine.middle.leaf.program.code, vec![TestInst::Nop, TestInst::Load(ConstantId(0))] ); - assert!(machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .middle - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .middle - .leaf - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .leaf + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); } #[test] @@ -471,27 +485,33 @@ fn text_generated_loadable_routes_three_level_section_tree() { machine.middle.leaf.program.code, vec![TextInst::Nop, TextInst::Alt] ); - assert!(machine - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .middle - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); - assert!(machine - .middle - .leaf - .program - .context - .as_ref() - .unwrap() - .ptr_eq(&file.context_handle())); + assert!( + machine + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); + assert!( + machine + .middle + .leaf + .program + .context + .as_ref() + .unwrap() + .ptr_eq(&file.context_handle()) + ); } #[test] From 848f99f71548bbf6721f8400b70705677a818c1e Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Thu, 25 Jun 2026 14:40:03 -0400 Subject: [PATCH 09/12] Fix clippy issues --- crates/vihaco/src/binary/file.rs | 3 +-- crates/vihaco/src/binary/format.rs | 2 +- crates/vihaco/src/binary/text.rs | 26 +++++++------------------- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index 0c434e4..50b8104 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -164,14 +164,13 @@ where let context_start = context_begin.full.end; let context_end = consume_context(&mut cursor)?; - let context = C::from_bytes(text[context_start..context_end].as_bytes())?; + let context = C::from_bytes(&text.as_bytes()[context_start..context_end])?; let Some(section_begin) = cursor.peek_significant() else { return Err(eyre::eyre!("expected root section")); }; let root = parse_text_section( &mut cursor, - &context, TextSectionParseInfo { parent: None, begin: section_begin, diff --git a/crates/vihaco/src/binary/format.rs b/crates/vihaco/src/binary/format.rs index b7ef5dc..c9e1412 100644 --- a/crates/vihaco/src/binary/format.rs +++ b/crates/vihaco/src/binary/format.rs @@ -201,7 +201,7 @@ pub fn decode_instruction_stream(bytes: &[u8]) -> eyre::Result() -> impl Parser<'src, &'src str, LineKind, ParseExtra<'src let end_context = just("<@").to(LineKind::EndContext); let begin_section = just("~>") - .ignore_then(space.clone()) - .ignore_then(name.clone()) + .ignore_then(space) + .ignore_then(name) .then_ignore(just(':')) .map(LineKind::BeginSection); let end_section = just("<~") .ignore_then(space) - .ignore_then(name.clone()) + .ignore_then(name) .then_ignore(just('.')) .map(LineKind::EndSection); @@ -238,14 +237,10 @@ pub(super) struct ParentSection<'a> { pub(super) indent: usize, } -pub(super) fn parse_section( +pub(super) fn parse_section( cursor: &mut LineCursor<'_>, - context: &C, info: TextSectionParseInfo<'_>, -) -> Result -where - C: BytecodeContext, -{ +) -> Result { let TextSectionParseInfo { parent, begin } = info; let section_name = match &begin.kind { LineKind::BeginSection(name) => name.clone(), @@ -324,10 +319,7 @@ where §ion_name, "header", section_indent + 1, - |kind| match kind { - LineKind::EndHeader => true, - _ => false, - }, + |kind| matches!(kind, LineKind::EndHeader), )?); } LineKind::BeginBytecode => { @@ -343,10 +335,7 @@ where §ion_name, "bytecode", section_indent + 1, - |kind| match kind { - LineKind::EndBytecode => true, - _ => false, - }, + |kind| matches!(kind, LineKind::EndBytecode), )?); } LineKind::BeginSection(child_name) => { @@ -363,7 +352,6 @@ where } let child = parse_section( cursor, - context, TextSectionParseInfo { parent: Some(ParentSection { path: &path, From b7df66e899ca5e5a00d6d28712ed11e96c8ac29c Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Mon, 29 Jun 2026 14:00:43 -0400 Subject: [PATCH 10/12] Fix bugs related to text parsing; update docs; implement text parsing for ProgramContext; refactor ProgramContext out of binary mod into its own program mod --- crates/vihaco-cpu/src/component.rs | 4 +- crates/vihaco-cpu/src/data.rs | 2 +- crates/vihaco-cpu/src/instruction.rs | 4 +- crates/vihaco-cpu/src/parse_helpers.rs | 2 +- crates/vihaco-derive/src/derive_machine.rs | 34 +- crates/vihaco/src/binary/context.rs | 217 +------- crates/vihaco/src/binary/file.rs | 8 +- crates/vihaco/src/binary/format.rs | 6 +- crates/vihaco/src/binary/mod.rs | 2 +- crates/vihaco/src/binary/section.rs | 36 +- crates/vihaco/src/binary/tests.rs | 187 ++++++- crates/vihaco/src/lib.rs | 10 +- crates/vihaco/src/loader.rs | 12 +- crates/vihaco/src/program.rs | 508 +++++++++++++++++++ crates/vihaco/src/syntax/types.rs | 2 +- crates/vihaco/src/traits/instruction.rs | 6 + crates/vihaco/src/traits/mod.rs | 2 +- crates/vihaco/src/value.rs | 109 ++++ crates/vihaco/tests/multisection_bytecode.rs | 34 +- docs/src/pages/guide/composites.md | 2 +- docs/src/pages/guide/parser.md | 2 +- 21 files changed, 916 insertions(+), 273 deletions(-) create mode 100644 crates/vihaco/src/program.rs diff --git a/crates/vihaco-cpu/src/component.rs b/crates/vihaco-cpu/src/component.rs index 6df2f66..2010cdd 100644 --- a/crates/vihaco-cpu/src/component.rs +++ b/crates/vihaco-cpu/src/component.rs @@ -8,8 +8,8 @@ use crate::StepOutcome; use crate::data::CPU; use crate::instruction::Instruction; use vihaco::Effects; -use vihaco::value::Value; -use vihaco::{component, frame::Frame, traits::*, value::Type}; +use vihaco::program::{Type, Value}; +use vihaco::{component, frame::Frame, traits::*}; impl Reset for CPU { fn reset(&mut self) { diff --git a/crates/vihaco-cpu/src/data.rs b/crates/vihaco-cpu/src/data.rs index 8007794..774482c 100644 --- a/crates/vihaco-cpu/src/data.rs +++ b/crates/vihaco-cpu/src/data.rs @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT -use vihaco::value::Value; +use vihaco::program::Value; use vihaco::{ frame::Frame, traits::{FrameMemory, StackFrame, StackMemory}, diff --git a/crates/vihaco-cpu/src/instruction.rs b/crates/vihaco-cpu/src/instruction.rs index df5911e..7d5f907 100644 --- a/crates/vihaco-cpu/src/instruction.rs +++ b/crates/vihaco-cpu/src/instruction.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: MIT use vihaco::Instruction; -use vihaco::value::{Type, Value}; +use vihaco::program::{Type, Value}; /// `#[derive(Parse)]` notes: /// @@ -210,7 +210,7 @@ impl vihaco::CanonicalInstructionSyntax for Instruction { mod parse_tests { use super::Instruction; use chumsky::Parser as _; - use vihaco::value::{Type, Value}; + use vihaco::program::{Type, Value}; use vihaco_parser_core::Parse; fn parse(input: &str) -> Instruction { diff --git a/crates/vihaco-cpu/src/parse_helpers.rs b/crates/vihaco-cpu/src/parse_helpers.rs index c0205c7..9286498 100644 --- a/crates/vihaco-cpu/src/parse_helpers.rs +++ b/crates/vihaco-cpu/src/parse_helpers.rs @@ -21,7 +21,7 @@ use chumsky::error::Simple; use chumsky::extra; use chumsky::prelude::*; -use vihaco::value::{Type, Value}; +use vihaco::program::{Type, Value}; use vihaco_parser_core::Parse; type E<'src> = extra::Err>; diff --git a/crates/vihaco-derive/src/derive_machine.rs b/crates/vihaco-derive/src/derive_machine.rs index 525ddbc..d879f7f 100644 --- a/crates/vihaco-derive/src/derive_machine.rs +++ b/crates/vihaco-derive/src/derive_machine.rs @@ -508,17 +508,12 @@ fn try_expand(input: DeriveInput) -> syn::Result { let ty = &loadable.ty; let name = &loadable.section_name; quote! { - let __vihaco_child = input.section.child(#name).ok_or_else(|| { - ::eyre::eyre!( - "section `{}` is missing required child section `{}`", - input.section.display_path(), - #name, - ) - })?; - <#ty as ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param>>::load_section( - &mut self.#field, - ::vihaco::loader::LoadInput::from(__vihaco_child) - )?; + if let ::std::option::Option::Some(__vihaco_child) = input.section.child(#name) { + <#ty as ::vihaco::loader::LoadSection<::std::vec::Vec, #loadable_context_param>>::load_section( + &mut self.#field, + ::vihaco::loader::LoadInput::from(__vihaco_child) + )?; + } } }) .collect(); @@ -653,17 +648,12 @@ fn try_expand(input: DeriveInput) -> syn::Result { let ty = &loadable.ty; let name = &loadable.section_name; quote! { - let __vihaco_child = input.section.child(#name).ok_or_else(|| { - ::eyre::eyre!( - "section `{}` is missing required child section `{}`", - input.section.display_path(), - #name, - ) - })?; - <#ty as ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param>>::load_section( - &mut self.#field, - ::vihaco::loader::LoadInput::from(__vihaco_child) - )?; + if let ::std::option::Option::Some(__vihaco_child) = input.section.child(#name) { + <#ty as ::vihaco::loader::LoadSection<::std::string::String, #loadable_context_param>>::load_section( + &mut self.#field, + ::vihaco::loader::LoadInput::from(__vihaco_child) + )?; + } } }) .collect(); diff --git a/crates/vihaco/src/binary/context.rs b/crates/vihaco/src/binary/context.rs index 7d06903..061b2a4 100644 --- a/crates/vihaco/src/binary/context.rs +++ b/crates/vihaco/src/binary/context.rs @@ -1,22 +1,11 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT -use std::{ - io::{Cursor, Read}, - sync::Arc, -}; +use std::sync::Arc; -use byteorder::{LittleEndian, ReadBytesExt}; +use crate::program::ProgramContext; -use crate::{ - module::{FunctionInfo, LabelInfo, Parameter, Signature, SourceSymbolInfo}, - traits::FromBytes, - value::{Type, Value}, -}; - -use super::format::ConstantId; - -/// The global context for a given program. +/// The global context for a given bytecode file. /// /// This should include all context needed for an entire section tree. /// Anything that should be shared across machines should be in a @@ -24,117 +13,9 @@ use super::format::ConstantId; pub trait BytecodeContext: Sized { fn from_bytes(bytes: &[u8]) -> eyre::Result; - fn section_name(&self, index: u32) -> Option<&str>; -} - -pub trait ProgramGlobals { - type Type; - type Value; - - fn get_function(&self, index: usize) -> eyre::Result>; - fn get_string(&self, index: usize) -> eyre::Result<&String>; - fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value>; -} - -#[derive(Debug, Clone, PartialEq, Default)] -pub struct ProgramContext { - pub constants: Vec, - pub strings: Vec, - pub functions: Vec>, - pub labels: Vec, - pub main_function: Option, - pub file: u32, - pub source_symbols: Vec, -} - -impl ProgramContext -where - V: FromBytes, - Ty: FromBytes, -{ - pub fn from_bytes(bytes: &[u8]) -> eyre::Result { - let mut cursor = Cursor::new(bytes); - let context = Self::read_from(&mut cursor)?; - if cursor.position() as usize != bytes.len() { - return Err(eyre::eyre!( - "program context has {} trailing bytes", - bytes.len() - cursor.position() as usize - )); - } - Ok(context) - } - - pub fn read_from(reader: &mut R) -> eyre::Result { - let constants = read_vec(reader, "constant", V::from_bytes)?; - let strings = read_vec(reader, "string", read_string)?; - let functions = read_vec(reader, "function", read_function_info)?; - let labels = read_vec(reader, "label", read_label_info)?; - let main_function = read_optional_u32(reader)?; - let file = reader.read_u32::()?; - let source_symbols = read_vec(reader, "source symbol", read_source_symbol_info)?; - - Ok(Self { - constants, - strings, - functions, - labels, - main_function, - file, - source_symbols, - }) - } -} - -impl BytecodeContext for ProgramContext -where - V: FromBytes, - Ty: FromBytes, -{ - fn from_bytes(bytes: &[u8]) -> eyre::Result { - ProgramContext::from_bytes(bytes) - } - - fn section_name(&self, index: u32) -> Option<&str> { - self.strings.get(index as usize).map(String::as_str) - } -} - -impl ProgramGlobals for ProgramContext -where - Ty: Clone, -{ - type Type = Ty; - type Value = V; - - fn get_function(&self, index: usize) -> eyre::Result> { - self.functions.get(index).cloned().ok_or_else(|| { - eyre::eyre!(format!( - "function index out of bounds: {} (max {})", - index, - self.functions.len() - )) - }) - } - - fn get_string(&self, index: usize) -> eyre::Result<&String> { - self.strings.get(index).ok_or_else(|| { - eyre::eyre!(format!( - "string index out of bounds: {} (max {})", - index, - self.strings.len() - )) - }) - } + fn from_text(text: &str) -> eyre::Result; - fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value> { - self.constants.get(id.0 as usize).ok_or_else(|| { - eyre::eyre!(format!( - "constant index out of bounds: {} (max {})", - id.0, - self.constants.len() - )) - }) - } + fn section_name(&self, index: u32) -> Option<&str>; } /// The public handle for a bytecode context. @@ -172,91 +53,3 @@ impl std::ops::Deref for ContextHandle { self.get() } } - -fn read_vec(reader: &mut R, label: &str, mut read_item: F) -> eyre::Result> -where - R: Read, - F: FnMut(&mut R) -> eyre::Result, -{ - let count = reader.read_u32::()? as usize; - let mut values = Vec::with_capacity(count); - for index in 0..count { - values.push( - read_item(reader) - .map_err(|err| eyre::eyre!("failed to read {label} table entry {index}: {err}"))?, - ); - } - Ok(values) -} - -fn read_string(reader: &mut R) -> eyre::Result { - let len = reader.read_u32::()? as usize; - let mut bytes = vec![0; len]; - reader.read_exact(&mut bytes)?; - Ok(String::from_utf8(bytes)?) -} - -fn read_function_info(reader: &mut R) -> eyre::Result> -where - R: Read, - Ty: FromBytes, -{ - let name = reader.read_u32::()?; - let signature = read_signature(reader)?; - let local_count = reader.read_u32::()?; - let start_address = reader.read_u32::()?; - let end_address = reader.read_u32::()?; - let file = reader.read_u32::()?; - - Ok(FunctionInfo { - name, - signature, - local_count, - start_address, - end_address, - file, - }) -} - -fn read_signature(reader: &mut R) -> eyre::Result> -where - R: Read, - Ty: FromBytes, -{ - let params = read_vec(reader, "parameter", read_parameter)?; - let ret = read_vec(reader, "return type", Ty::from_bytes)?; - Ok(Signature { params, ret }) -} - -fn read_parameter(reader: &mut R) -> eyre::Result> -where - R: Read, - Ty: FromBytes, -{ - let name = reader.read_u32::()?; - let ty = Ty::from_bytes(reader)?; - Ok(Parameter { name, ty }) -} - -fn read_label_info(reader: &mut R) -> eyre::Result { - let address = reader.read_u32::()?; - let name = reader.read_u32::()?; - Ok(LabelInfo { address, name }) -} - -fn read_source_symbol_info(reader: &mut R) -> eyre::Result { - let index = reader.read_u32::()?; - let name = read_string(reader)?; - Ok(SourceSymbolInfo { index, name }) -} - -fn read_optional_u32(reader: &mut R) -> eyre::Result> { - match reader.read_u8()? { - 0 => Ok(None), - 1 => Ok(Some(reader.read_u32::()?)), - other => Err(eyre::eyre!( - "invalid optional u32 discriminant {} in program context", - other - )), - } -} diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index 50b8104..36f000a 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -7,9 +7,10 @@ use crate::binary::text::{ LineCursor, LineKind, TextSectionParseInfo, consume_context, lex_lines, line_error as text_line_error, parse_section as parse_text_section, verify_version, }; +use crate::program::ProgramContext; use super::{ - context::{BytecodeContext, ContextHandle, ProgramContext}, + context::{BytecodeContext, ContextHandle}, format::BytecodeHeader, parser::{SectionParseInfo, checked_add, parse_section}, section::{SectionNode, SectionPath, SectionView}, @@ -164,7 +165,10 @@ where let context_start = context_begin.full.end; let context_end = consume_context(&mut cursor)?; - let context = C::from_bytes(&text.as_bytes()[context_start..context_end])?; + let context = C::from_text( + text.get(context_start..context_end) + .ok_or_else(|| eyre::eyre!("program context is out of bounds"))?, + )?; let Some(section_begin) = cursor.peek_significant() else { return Err(eyre::eyre!("expected root section")); diff --git a/crates/vihaco/src/binary/format.rs b/crates/vihaco/src/binary/format.rs index c9e1412..122f524 100644 --- a/crates/vihaco/src/binary/format.rs +++ b/crates/vihaco/src/binary/format.rs @@ -5,11 +5,11 @@ use std::io::{Cursor, Read}; use byteorder::{LittleEndian, ReadBytesExt}; -use crate::traits::{FromBytes, Instruction, OpCode, WriteBytes}; +use crate::traits::{FromBytes, FromText, Instruction, OpCode, WriteBytes}; -pub trait CompositeHeader: Sized + FromBytes + WriteBytes {} +pub trait CompositeHeader: Sized + FromBytes + FromText + WriteBytes {} -impl CompositeHeader for T where T: Sized + FromBytes + WriteBytes {} +impl CompositeHeader for T where T: Sized + FromBytes + FromText + WriteBytes {} #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ConstantId(pub u32); diff --git a/crates/vihaco/src/binary/mod.rs b/crates/vihaco/src/binary/mod.rs index e5aeba5..65df8a4 100644 --- a/crates/vihaco/src/binary/mod.rs +++ b/crates/vihaco/src/binary/mod.rs @@ -12,7 +12,7 @@ mod text; #[cfg(test)] mod tests; -pub use context::{BytecodeContext, ContextHandle, ProgramContext, ProgramGlobals}; +pub use context::{BytecodeContext, ContextHandle}; pub use file::{BinaryBytecodeFile, BytecodeFile, FileContents, TextBytecodeFile}; pub use format::{CompositeHeader, ConstantId, FLAGS, MAGIC, VERSION, decode_instruction_stream}; pub use section::{BinarySectionView, SectionPath, SectionView, TextSectionView}; diff --git a/crates/vihaco/src/binary/section.rs b/crates/vihaco/src/binary/section.rs index 17763b9..15a606c 100644 --- a/crates/vihaco/src/binary/section.rs +++ b/crates/vihaco/src/binary/section.rs @@ -4,9 +4,10 @@ use std::{io::Cursor, ops::Range}; use crate::binary::file::FileContents; +use crate::program::ProgramContext; use super::{ - context::{BytecodeContext, ContextHandle, ProgramContext}, + context::{BytecodeContext, ContextHandle}, format::CompositeHeader, }; @@ -124,10 +125,6 @@ where &self.node.path } - pub fn context(&self) -> &C { - self.context.get() - } - pub fn context_handle(&self) -> ContextHandle { self.context.clone() } @@ -219,4 +216,33 @@ where pub fn text(&self) -> &'bc str { &self.contents[self.node.bytecode.clone()] } + + /// Parse the specified composite header from the text format. + pub fn parse_header(&self) -> eyre::Result { + let text = self.header_text(); + let mut cursor = Cursor::new(text); + let header = H::from_text(&mut cursor)?; + if cursor.position() as usize != text.len() { + return Err(eyre::eyre!( + "section `{}` header has {} trailing bytes", + self.display_path(), + text.len() - cursor.position() as usize + )); + } + Ok(header) + } +} + +impl std::fmt::Debug for TextSectionView<'_, C> +where + C: BytecodeContext, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SectionView") + .field("path", &self.display_path().to_string()) + .field("header_len", &self.header_text().len()) + .field("text_len", &self.text().len()) + .field("child_count", &self.node.children.len()) + .finish() + } } diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index 74c7a5d..e6a615c 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -6,10 +6,9 @@ use super::format::{ }; use super::*; use crate::binary::file::{BinaryBytecodeFile, TextBytecodeFile}; -use crate::{ - traits::{FromBytes, WriteBytes}, - value::{Type, Value}, -}; +use crate::module::{FunctionInfo, LabelInfo, Parameter, Signature, SourceSymbolInfo}; +use crate::program::{ProgramContext, Type, Value}; +use crate::traits::{FromBytes, FromText, WriteBytes}; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; use std::io::Read; @@ -28,6 +27,14 @@ impl FromBytes for CustomValue { } } +impl FromText for CustomValue { + fn from_text(text: &mut R) -> eyre::Result { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + Ok(Self(buffer.trim().parse()?)) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct CustomType(u8); @@ -37,6 +44,14 @@ impl FromBytes for CustomType { } } +impl FromText for CustomType { + fn from_text(text: &mut R) -> eyre::Result { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + Ok(Self(buffer.trim().parse()?)) + } +} + #[derive(Debug, Clone, PartialEq)] struct WrappedContext { inner: ProgramContext, @@ -58,6 +73,12 @@ impl BytecodeContext for WrappedContext { fn section_name(&self, index: u32) -> Option<&str> { self.inner.section_name(index) } + + fn from_text(text: &str) -> eyre::Result { + Ok(Self { + inner: ProgramContext::from_text(text)?, + }) + } } impl BytecodeContext for TextContext { @@ -75,6 +96,17 @@ impl BytecodeContext for TextContext { fn section_name(&self, index: u32) -> Option<&str> { self.section_names.get(index as usize).map(String::as_str) } + + fn from_text(text: &str) -> eyre::Result { + let raw = text.to_string(); + let section_names = raw + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect(); + Ok(Self { raw, section_names }) + } } #[test] @@ -181,6 +213,14 @@ fn binary_parse_header_consumes_the_whole_header() { } } + impl FromText for Header { + fn from_text(text: &mut R) -> eyre::Result { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + Ok(Header(buffer.trim().parse()?)) + } + } + impl WriteBytes for Header { fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { io.write_u32::(self.0)?; @@ -365,6 +405,145 @@ fn parses_text_section_without_header_or_bytecode_as_empty_ranges() { assert_eq!(root.text(), ""); } +#[test] +fn parses_text_program_context_tables() { + let parsed: TextBytecodeFile = TextBytecodeFile::from_text(&text_file( + ".constants\n\ +i64 42\n\ +bool true\n\ +str 4\n\ +fn 1\n\ +heap 0\n\ +\n\ +.strings\n\ +\"main\"\n\ +\"helper\"\n\ +\"x\"\n\ +\"flag\"\n\ +\"config.json\"\n\ +\"entry\"\n\ +\"loop\"\n\ +\"done\"\n\ +\"cpu\"\n\ +\n\ +.functions\n\ +fn 0 (2: i64, 3: bool) -> i64 2 0 12 4\n\ +fn 1 () -> (bool, i64) 0 12 24 4\n\ +\n\ +.labels\n\ +0 5\n\ +6 6\n\ +12 7\n\ +\n\ +.main 0\n\ +.file 4\n\ +\n\ +.source-symbols\n\ +0 \"cpu\"\n\ +1 \"memory\"\n\ +2 \"timer\"\n", + "~> /:\n<~ /.\n", + )) + .unwrap(); + + assert_eq!( + parsed.context().constants, + vec![ + Value::I64(42), + Value::Bool(true), + Value::String(4), + Value::FunctionRef(1), + Value::HeapRef(0), + ] + ); + assert_eq!( + parsed.context().strings, + vec![ + "main".to_string(), + "helper".to_string(), + "x".to_string(), + "flag".to_string(), + "config.json".to_string(), + "entry".to_string(), + "loop".to_string(), + "done".to_string(), + "cpu".to_string(), + ] + ); + assert_eq!( + parsed.context().functions, + vec![ + FunctionInfo { + name: 0, + signature: Signature { + params: vec![ + Parameter { + name: 2, + ty: Type::I64, + }, + Parameter { + name: 3, + ty: Type::Bool, + }, + ], + ret: vec![Type::I64], + }, + local_count: 2, + start_address: 0, + end_address: 12, + file: 4, + }, + FunctionInfo { + name: 1, + signature: Signature { + params: vec![], + ret: vec![Type::Bool, Type::I64], + }, + local_count: 0, + start_address: 12, + end_address: 24, + file: 4, + }, + ] + ); + assert_eq!( + parsed.context().labels, + vec![ + LabelInfo { + address: 0, + name: 5, + }, + LabelInfo { + address: 6, + name: 6, + }, + LabelInfo { + address: 12, + name: 7, + }, + ] + ); + assert_eq!(parsed.context().main_function, Some(0)); + assert_eq!(parsed.context().file, 4); + assert_eq!( + parsed.context().source_symbols, + vec![ + SourceSymbolInfo { + index: 0, + name: "cpu".to_string(), + }, + SourceSymbolInfo { + index: 1, + name: "memory".to_string(), + }, + SourceSymbolInfo { + index: 2, + name: "timer".to_string(), + }, + ] + ); +} + #[test] fn rejects_text_root_section_with_non_root_name() { let err = TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ root.\n")) diff --git a/crates/vihaco/src/lib.rs b/crates/vihaco/src/lib.rs index 191a8d3..8cf47cd 100644 --- a/crates/vihaco/src/lib.rs +++ b/crates/vihaco/src/lib.rs @@ -17,16 +17,18 @@ pub mod macros; pub mod metadata; pub mod module; pub mod observer; +pub mod program; pub mod runtime; pub mod syntax; #[doc(hidden)] pub mod traits; -pub mod value; +pub mod value { + pub use crate::program::{Type, Value}; +} pub use binary::{ BinaryBytecodeFile, BinarySectionView, BytecodeContext, BytecodeFile, CompositeHeader, - ConstantId, ContextHandle, ProgramContext, ProgramGlobals, SectionPath, SectionView, - TextBytecodeFile, TextSectionView, + ConstantId, ContextHandle, SectionPath, SectionView, TextBytecodeFile, TextSectionView, }; pub use effect::Effects; pub use instruction_syntax::{ @@ -35,12 +37,12 @@ pub use instruction_syntax::{ }; pub use loader::{LoadInput, LoadSection, ModuleProgramLoader, ProgramLoader}; pub use macros::{Instruction, Message, component, composite, observe}; +pub use program::{ProgramContext, ProgramGlobals, Type, Value}; pub use runtime::{ CompositeMetadata, EffectSink, GeneratedComponent, Message as MessageMarker, Observe, expect_exactly_one_effect, }; pub use traits::{GetProgramGlobal, Reset}; -pub use value::{Type, Value}; #[cfg(test)] mod public_api_tests { diff --git a/crates/vihaco/src/loader.rs b/crates/vihaco/src/loader.rs index 06d9610..cf824ce 100644 --- a/crates/vihaco/src/loader.rs +++ b/crates/vihaco/src/loader.rs @@ -4,14 +4,15 @@ use crate::{ BytecodeContext, BytecodeFile, binary::{ - ConstantId, ContextHandle, FileContents, ProgramContext, ProgramGlobals, SectionView, - decode_instruction_stream, parse_instruction_stream, + ConstantId, ContextHandle, FileContents, SectionView, decode_instruction_stream, + parse_instruction_stream, }, module::{Module, NoInfo}, + program::{ProgramContext, ProgramGlobals, Type, Value}, traits::{self, GetProgramGlobal, ProgramCounter}, - value::{Type, Value}, }; +/// The input given to a specific loadable machine. pub struct LoadInput<'bc, F = Vec, C = ProgramContext> where F: FileContents, @@ -67,6 +68,11 @@ where } } +/// Allow a machine to load a section. +/// +/// When used with the [`vihaco_derive::composite`] macro, any field marked +/// `#[program]` will automatically have the section routed to it; +/// that field should implement the logic for loading a section. pub trait LoadSection, C = ProgramContext> where F: FileContents, diff --git a/crates/vihaco/src/program.rs b/crates/vihaco/src/program.rs new file mode 100644 index 0000000..2477582 --- /dev/null +++ b/crates/vihaco/src/program.rs @@ -0,0 +1,508 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::io::{Cursor, Read}; + +use byteorder::{LittleEndian, ReadBytesExt}; +use chumsky::{error::Simple, extra, prelude::*}; + +use crate::{ + binary::{BytecodeContext, ConstantId}, + module::{FunctionInfo, LabelInfo, Parameter, Signature, SourceSymbolInfo}, + traits::{FromBytes, FromText}, +}; + +#[path = "value.rs"] +pub mod value; + +pub use value::{Type, Value}; + +type ContextParseExtra<'src> = extra::Err>; + +pub trait ProgramGlobals { + type Type; + type Value; + + fn get_function(&self, index: usize) -> eyre::Result>; + fn get_string(&self, index: usize) -> eyre::Result<&String>; + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value>; +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ProgramContext { + pub constants: Vec, + pub strings: Vec, + pub functions: Vec>, + pub labels: Vec, + pub main_function: Option, + pub file: u32, + pub source_symbols: Vec, +} + +impl ProgramContext +where + V: FromBytes, + Ty: FromBytes, +{ + pub fn from_bytes(bytes: &[u8]) -> eyre::Result { + let mut cursor = Cursor::new(bytes); + let context = Self::read_from(&mut cursor)?; + if cursor.position() as usize != bytes.len() { + return Err(eyre::eyre!( + "program context has {} trailing bytes", + bytes.len() - cursor.position() as usize + )); + } + Ok(context) + } + + pub fn read_from(reader: &mut R) -> eyre::Result { + let constants = read_vec(reader, "constant", V::from_bytes)?; + let strings = read_vec(reader, "string", read_string)?; + let functions = read_vec(reader, "function", read_function_info)?; + let labels = read_vec(reader, "label", read_label_info)?; + let main_function = read_optional_u32(reader)?; + let file = reader.read_u32::()?; + let source_symbols = read_vec(reader, "source symbol", read_source_symbol_info)?; + + Ok(Self { + constants, + strings, + functions, + labels, + main_function, + file, + source_symbols, + }) + } +} + +impl ProgramContext +where + V: FromText, + Ty: FromText, +{ + pub fn from_text(text: &str) -> eyre::Result { + let normalized = normalize_context_text(text); + context_text_parser::() + .parse(normalized.as_str()) + .into_result() + .map_err(format_context_parse_errors) + } +} + +impl BytecodeContext for ProgramContext +where + V: FromBytes + FromText, + Ty: FromBytes + FromText, +{ + fn from_bytes(bytes: &[u8]) -> eyre::Result { + ProgramContext::from_bytes(bytes) + } + + fn section_name(&self, index: u32) -> Option<&str> { + self.strings.get(index as usize).map(String::as_str) + } + + fn from_text(text: &str) -> eyre::Result { + ProgramContext::from_text(text) + } +} + +impl ProgramGlobals for ProgramContext +where + Ty: Clone, +{ + type Type = Ty; + type Value = V; + + fn get_function(&self, index: usize) -> eyre::Result> { + self.functions.get(index).cloned().ok_or_else(|| { + eyre::eyre!(format!( + "function index out of bounds: {} (max {})", + index, + self.functions.len() + )) + }) + } + + fn get_string(&self, index: usize) -> eyre::Result<&String> { + self.strings.get(index).ok_or_else(|| { + eyre::eyre!(format!( + "string index out of bounds: {} (max {})", + index, + self.strings.len() + )) + }) + } + + fn get_constant(&self, id: ConstantId) -> eyre::Result<&Self::Value> { + self.constants.get(id.0 as usize).ok_or_else(|| { + eyre::eyre!(format!( + "constant index out of bounds: {} (max {})", + id.0, + self.constants.len() + )) + }) + } +} + +fn read_vec(reader: &mut R, label: &str, mut read_item: F) -> eyre::Result> +where + R: Read, + F: FnMut(&mut R) -> eyre::Result, +{ + let count = reader.read_u32::()? as usize; + let mut values = Vec::with_capacity(count); + for index in 0..count { + values.push( + read_item(reader) + .map_err(|err| eyre::eyre!("failed to read {label} table entry {index}: {err}"))?, + ); + } + Ok(values) +} + +fn read_string(reader: &mut R) -> eyre::Result { + let len = reader.read_u32::()? as usize; + let mut bytes = vec![0; len]; + reader.read_exact(&mut bytes)?; + Ok(String::from_utf8(bytes)?) +} + +fn read_function_info(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let name = reader.read_u32::()?; + let signature = read_signature(reader)?; + let local_count = reader.read_u32::()?; + let start_address = reader.read_u32::()?; + let end_address = reader.read_u32::()?; + let file = reader.read_u32::()?; + + Ok(FunctionInfo { + name, + signature, + local_count, + start_address, + end_address, + file, + }) +} + +fn read_signature(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let params = read_vec(reader, "parameter", read_parameter)?; + let ret = read_vec(reader, "return type", Ty::from_bytes)?; + Ok(Signature { params, ret }) +} + +fn read_parameter(reader: &mut R) -> eyre::Result> +where + R: Read, + Ty: FromBytes, +{ + let name = reader.read_u32::()?; + let ty = Ty::from_bytes(reader)?; + Ok(Parameter { name, ty }) +} + +fn read_label_info(reader: &mut R) -> eyre::Result { + let address = reader.read_u32::()?; + let name = reader.read_u32::()?; + Ok(LabelInfo { address, name }) +} + +fn read_source_symbol_info(reader: &mut R) -> eyre::Result { + let index = reader.read_u32::()?; + let name = read_string(reader)?; + Ok(SourceSymbolInfo { index, name }) +} + +fn read_optional_u32(reader: &mut R) -> eyre::Result> { + match reader.read_u8()? { + 0 => Ok(None), + 1 => Ok(Some(reader.read_u32::()?)), + other => Err(eyre::eyre!( + "invalid optional u32 discriminant {} in program context", + other + )), + } +} + +fn context_text_parser<'src, V, Ty>() +-> impl Parser<'src, &'src str, ProgramContext, ContextParseExtra<'src>> +where + V: FromText + 'src, + Ty: FromText + 'src, +{ + let constants = context_line_entry::(".strings") + .repeated() + .collect::>(); + let strings = string_line().repeated().collect::>(); + let functions = function_line::().repeated().collect::>(); + let labels = label_line().repeated().collect::>(); + let source_symbols = source_symbol_line().repeated().collect::>(); + + heading(".constants") + .ignore_then(constants) + .then_ignore(heading(".strings")) + .then(strings) + .then_ignore(heading(".functions")) + .then(functions) + .then_ignore(heading(".labels")) + .then(labels) + .then(main_function_line()) + .then(file_line()) + .then_ignore(heading(".source-symbols")) + .then(source_symbols) + .then_ignore(end()) + .map( + |( + (((((constants, strings), functions), labels), main_function), file), + source_symbols, + )| { + ProgramContext { + constants, + strings, + functions, + labels, + main_function, + file, + source_symbols, + } + }, + ) +} + +fn heading<'src>( + name: &'static str, +) -> impl Parser<'src, &'src str, (), ContextParseExtra<'src>> + Clone { + just(name).then_ignore(eol()).ignored() +} + +fn eol<'src>() -> impl Parser<'src, &'src str, (), ContextParseExtra<'src>> + Clone { + just('\r').or_not().then_ignore(just('\n')).ignored() +} + +fn inline_ws<'src>() -> impl Parser<'src, &'src str, (), ContextParseExtra<'src>> + Clone { + one_of(" \t").repeated().ignored() +} + +fn required_inline_ws<'src>() -> impl Parser<'src, &'src str, (), ContextParseExtra<'src>> + Clone { + one_of(" \t").repeated().at_least(1).ignored() +} + +fn u32_text<'src>() -> impl Parser<'src, &'src str, u32, ContextParseExtra<'src>> + Clone { + text::int(10) + .try_map(|text: &str, span| text.parse::().map_err(|_| Simple::new(None, span))) +} + +fn context_line_entry<'src, T>( + next_marker: &'static str, +) -> impl Parser<'src, &'src str, T, ContextParseExtra<'src>> +where + T: FromText + 'src, +{ + just(next_marker) + .not() + .ignore_then( + any() + .filter(|ch: &char| !matches!(*ch, '\r' | '\n')) + .repeated() + .at_least(1) + .to_slice(), + ) + .then_ignore(eol()) + .try_map(|text: &str, span| { + parse_text_entry(text.trim()).map_err(|_| Simple::new(None, span)) + }) +} + +fn string_line<'src>() -> impl Parser<'src, &'src str, String, ContextParseExtra<'src>> + Clone { + string_literal().then_ignore(inline_ws()).then_ignore(eol()) +} + +fn function_line<'src, Ty>() +-> impl Parser<'src, &'src str, FunctionInfo, ContextParseExtra<'src>> +where + Ty: FromText + 'src, +{ + just("fn") + .ignore_then(required_inline_ws()) + .ignore_then(u32_text()) + .then_ignore(required_inline_ws()) + .then(signature_text::()) + .then_ignore(required_inline_ws()) + .then(u32_text()) + .then_ignore(required_inline_ws()) + .then(u32_text()) + .then_ignore(required_inline_ws()) + .then(u32_text()) + .then_ignore(required_inline_ws()) + .then(u32_text()) + .then_ignore(inline_ws()) + .then_ignore(eol()) + .map( + |(((((name, signature), local_count), start_address), end_address), file)| { + FunctionInfo { + name, + signature, + local_count, + start_address, + end_address, + file, + } + }, + ) +} + +fn signature_text<'src, Ty>() -> impl Parser<'src, &'src str, Signature, ContextParseExtra<'src>> +where + Ty: FromText + 'src, +{ + just('(') + .ignore_then(inline_ws()) + .ignore_then( + parameter_text::() + .separated_by(comma_separator()) + .collect::>(), + ) + .then_ignore(inline_ws()) + .then_ignore(just(')')) + .then_ignore(inline_ws()) + .then_ignore(just("->")) + .then_ignore(inline_ws()) + .then(return_types_text::()) + .map(|(params, ret)| Signature { params, ret }) +} + +fn parameter_text<'src, Ty>() -> impl Parser<'src, &'src str, Parameter, ContextParseExtra<'src>> +where + Ty: FromText + 'src, +{ + u32_text() + .then_ignore(inline_ws()) + .then_ignore(just(':')) + .then_ignore(inline_ws()) + .then(type_text::()) + .map(|(name, ty)| Parameter { name, ty }) +} + +fn return_types_text<'src, Ty>() -> impl Parser<'src, &'src str, Vec, ContextParseExtra<'src>> +where + Ty: FromText + 'src, +{ + let parenthesized = just('(') + .ignore_then(inline_ws()) + .ignore_then( + type_text::() + .separated_by(comma_separator()) + .collect::>(), + ) + .then_ignore(inline_ws()) + .then_ignore(just(')')); + + parenthesized.or(type_text::().map(|ty| vec![ty])) +} + +fn type_text<'src, Ty>() -> impl Parser<'src, &'src str, Ty, ContextParseExtra<'src>> +where + Ty: FromText + 'src, +{ + any() + .filter(|ch: &char| !ch.is_whitespace() && !matches!(*ch, ',' | ')')) + .repeated() + .at_least(1) + .to_slice() + .try_map(|text: &str, span| parse_text_entry(text).map_err(|_| Simple::new(None, span))) +} + +fn comma_separator<'src>() -> impl Parser<'src, &'src str, (), ContextParseExtra<'src>> + Clone { + inline_ws() + .ignore_then(just(',')) + .then_ignore(inline_ws()) + .ignored() +} + +fn label_line<'src>() -> impl Parser<'src, &'src str, LabelInfo, ContextParseExtra<'src>> + Clone { + u32_text() + .then_ignore(required_inline_ws()) + .then(u32_text()) + .then_ignore(inline_ws()) + .then_ignore(eol()) + .map(|(address, name)| LabelInfo { address, name }) +} + +fn main_function_line<'src>() +-> impl Parser<'src, &'src str, Option, ContextParseExtra<'src>> + Clone { + just(".main") + .ignore_then(required_inline_ws()) + .ignore_then(just("none").to(None).or(u32_text().map(Some))) + .then_ignore(inline_ws()) + .then_ignore(eol()) +} + +fn file_line<'src>() -> impl Parser<'src, &'src str, u32, ContextParseExtra<'src>> + Clone { + just(".file") + .ignore_then(required_inline_ws()) + .ignore_then(u32_text()) + .then_ignore(inline_ws()) + .then_ignore(eol()) +} + +fn source_symbol_line<'src>() +-> impl Parser<'src, &'src str, SourceSymbolInfo, ContextParseExtra<'src>> + Clone { + u32_text() + .then_ignore(required_inline_ws()) + .then(string_literal()) + .then_ignore(inline_ws()) + .then_ignore(eol()) + .map(|(index, name)| SourceSymbolInfo { index, name }) +} + +fn string_literal<'src>() -> impl Parser<'src, &'src str, String, ContextParseExtra<'src>> + Clone { + let escape = just('\\').ignore_then(choice(( + just('"').to('"'), + just('\\').to('\\'), + just('n').to('\n'), + just('t').to('\t'), + just('r').to('\r'), + just('0').to('\0'), + ))); + let char_or_escape = choice(( + escape, + any().and_is(just('"').not()).and_is(just('\\').not()), + )); + just('"') + .ignore_then(char_or_escape.repeated().collect::()) + .then_ignore(just('"')) +} + +fn parse_text_entry(text: &str) -> eyre::Result { + let mut cursor = Cursor::new(text.as_bytes()); + T::from_text(&mut cursor) +} + +fn normalize_context_text(text: &str) -> String { + let mut normalized = String::new(); + for line in text.lines().map(str::trim).filter(|line| !line.is_empty()) { + normalized.push_str(line); + normalized.push('\n'); + } + normalized +} + +fn format_context_parse_errors(errors: Vec>) -> eyre::Report { + let error = errors + .into_iter() + .next() + .map(|error| format!("{error:?}")) + .unwrap_or_else(|| "unknown parse error".to_string()); + eyre::eyre!("failed to parse text program context: {error}") +} diff --git a/crates/vihaco/src/syntax/types.rs b/crates/vihaco/src/syntax/types.rs index 80c667c..aa7a3c6 100644 --- a/crates/vihaco/src/syntax/types.rs +++ b/crates/vihaco/src/syntax/types.rs @@ -31,7 +31,7 @@ pub struct Param { } /// Bare type token — `"i64"`, `"f64"`, …. The resolver translates to -/// [`crate::value::Type`]. +/// [`crate::program::Type`]. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RawType(pub String); diff --git a/crates/vihaco/src/traits/instruction.rs b/crates/vihaco/src/traits/instruction.rs index 643f6ee..6e2b9cf 100644 --- a/crates/vihaco/src/traits/instruction.rs +++ b/crates/vihaco/src/traits/instruction.rs @@ -7,6 +7,12 @@ pub trait FromBytes { Self: Sized; } +pub trait FromText { + fn from_text(text: &mut R) -> eyre::Result + where + Self: Sized; +} + pub trait FromBytesWithOpcode: Sized { fn from_bytes_with_opcode(bytes: &mut R, opcode: u8) -> eyre::Result; } diff --git a/crates/vihaco/src/traits/mod.rs b/crates/vihaco/src/traits/mod.rs index 465af99..57e5c7b 100644 --- a/crates/vihaco/src/traits/mod.rs +++ b/crates/vihaco/src/traits/mod.rs @@ -6,7 +6,7 @@ mod instruction; mod machine; pub use event_sink::EffectSink; -pub use instruction::{FromBytes, FromBytesWithOpcode, Instruction, OpCode, WriteBytes}; +pub use instruction::{FromBytes, FromBytesWithOpcode, FromText, Instruction, OpCode, WriteBytes}; pub use machine::{FrameMemory, GetProgramGlobal, ProgramCounter, StackFrame, StackMemory, Stdout}; pub trait Reset { diff --git a/crates/vihaco/src/value.rs b/crates/vihaco/src/value.rs index d359eb7..e90cffa 100644 --- a/crates/vihaco/src/value.rs +++ b/crates/vihaco/src/value.rs @@ -4,8 +4,11 @@ use std::convert::TryFrom; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; +use chumsky::{error::Simple, extra, prelude::*}; use eyre::Result; +type ValueParseExtra<'src> = extra::Err>; + #[derive(Debug, Clone, Copy, PartialEq)] pub enum Value { Undefined, @@ -194,6 +197,21 @@ impl crate::traits::FromBytes for Type { } } +impl crate::traits::FromText for Type { + fn from_text(text: &mut R) -> eyre::Result + where + Self: Sized, + { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + type_text_parser() + .then_ignore(end()) + .parse(buffer.trim()) + .into_result() + .map_err(format_value_parse_errors) + } +} + impl crate::traits::WriteBytes for Type { fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { let type_byte = match self { @@ -257,6 +275,21 @@ impl crate::traits::FromBytes for Value { } } +impl crate::traits::FromText for Value { + fn from_text(text: &mut R) -> eyre::Result + where + Self: Sized, + { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + value_text_parser() + .then_ignore(end()) + .parse(buffer.trim()) + .into_result() + .map_err(format_value_parse_errors) + } +} + impl crate::traits::WriteBytes for Value { fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { match self { @@ -299,3 +332,79 @@ impl crate::traits::WriteBytes for Value { Ok(()) } } + +fn type_text_parser<'src>() -> impl Parser<'src, &'src str, Type, ValueParseExtra<'src>> + Clone { + choice(( + just("undefined").to(Type::Undefined), + just("string").to(Type::String), + just("str").to(Type::String), + just("bool").to(Type::Bool), + just("i64").to(Type::I64), + just("u32").to(Type::U32), + just("u64").to(Type::U64), + just("f64").to(Type::F64), + just("fn").to(Type::FunctionRef), + just("heap").to(Type::HeapRef), + )) +} + +fn value_text_parser<'src>() -> impl Parser<'src, &'src str, Value, ValueParseExtra<'src>> + Clone { + let ws = one_of(" \t").repeated().at_least(1).ignored(); + choice(( + just("undefined").to(Value::Undefined), + just("string") + .or(just("str")) + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::String), + just("bool") + .ignore_then(ws) + .ignore_then(choice((just("true").to(true), just("false").to(false)))) + .map(Value::Bool), + just("i64") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::I64), + just("u32") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::U32), + just("u64") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::U64), + just("f64") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::F64), + just("fn") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::FunctionRef), + just("heap") + .ignore_then(ws) + .ignore_then(scalar_text::()) + .map(Value::HeapRef), + )) +} + +fn scalar_text<'src, T>() -> impl Parser<'src, &'src str, T, ValueParseExtra<'src>> + Clone +where + T: std::str::FromStr, +{ + any() + .filter(|ch: &char| !ch.is_whitespace()) + .repeated() + .at_least(1) + .to_slice() + .try_map(|text: &str, span| text.parse::().map_err(|_| Simple::new(None, span))) +} + +fn format_value_parse_errors(errors: Vec>) -> eyre::Report { + let error = errors + .into_iter() + .next() + .map(|error| format!("{error:?}")) + .unwrap_or_else(|| "unknown parse error".to_string()); + eyre::eyre!("failed to parse value text: {error}") +} diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index 9b5c1ac..9bf97b4 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -8,7 +8,7 @@ use vihaco::{ BytecodeContext, BytecodeFile, ConstantId, Effects, GeneratedComponent, GetProgramGlobal, Instruction, LoadInput, LoadSection, ProgramLoader, Value, binary::{FLAGS, MAGIC, VERSION}, - traits::{FromBytes, WriteBytes}, + traits::{FromBytes, FromText, WriteBytes}, }; const CHILD_NAME: u32 = 0; @@ -46,6 +46,14 @@ impl FromBytes for TestHeader { } } +impl FromText for TestHeader { + fn from_text(text: &mut R) -> eyre::Result { + let mut buffer = String::new(); + text.read_to_string(&mut buffer)?; + Ok(buffer.trim().parse()?) + } +} + impl WriteBytes for TestHeader { fn write_bytes(&self, io: &mut W) -> eyre::Result<()> { io.write_u32::(self.cores)?; @@ -71,6 +79,10 @@ struct TextContext { impl BytecodeContext for TextContext { fn from_bytes(bytes: &[u8]) -> eyre::Result { let text = std::str::from_utf8(bytes)?; + Self::from_text(text) + } + + fn from_text(text: &str) -> eyre::Result { Ok(Self { section_names: text .lines() @@ -515,19 +527,23 @@ fn text_generated_loadable_routes_three_level_section_tree() { } #[test] -fn binary_generated_loadable_requires_marked_children() { +fn binary_generated_loadable_allows_missing_marked_children() { let root = binary_section_bytes(b"", &[TestInst::Nop], vec![]); let file: BytecodeFile> = BytecodeFile::from_bytes(binary_file_bytes(context_bytes(), root)).unwrap(); let mut machine = Machine::default(); - let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + machine.load_section(LoadInput::from(&file)).unwrap(); - assert!(err.to_string().contains("missing required child section")); + assert_eq!(machine.program.code, vec![TestInst::Nop]); + assert!(machine.child.program.code.is_empty()); + assert!(machine.child.program.context.is_none()); + assert!(machine.default_child.program.code.is_empty()); + assert!(machine.default_child.program.context.is_none()); } #[test] -fn text_generated_loadable_requires_marked_children() { +fn text_generated_loadable_allows_missing_marked_children() { let file = text_file( &["child", "default_child"], "~> /:\n\ @@ -538,9 +554,13 @@ fn text_generated_loadable_requires_marked_children() { ); let mut machine = TextMachine::default(); - let err = machine.load_section(LoadInput::from(&file)).unwrap_err(); + machine.load_section(LoadInput::from(&file)).unwrap(); - assert!(err.to_string().contains("missing required child section")); + assert_eq!(machine.program.code, vec![TextInst::Nop]); + assert!(machine.child.program.code.is_empty()); + assert!(machine.child.program.context.is_none()); + assert!(machine.default_child.program.code.is_empty()); + assert!(machine.default_child.program.context.is_none()); } #[test] diff --git a/docs/src/pages/guide/composites.md b/docs/src/pages/guide/composites.md index 6c55125..4ceedfd 100644 --- a/docs/src/pages/guide/composites.md +++ b/docs/src/pages/guide/composites.md @@ -217,7 +217,7 @@ program context bytes root section bytes ``` -The program context contains the shared `Module` tables except `code` and `extra`: constants, strings, functions, labels, `main_function`, `file`, and source symbols. `ProgramContext` is the default context representation and is generic over the VM's constant value and type encodings. A binary bytecode file can also use a custom context type by implementing `BytecodeContext`; generated composite loading is generic over that context type, so Rust infers it from `BinaryBytecodeFile` / `LoadInput, C>`. Section bytecode can refer to shared constants with `vihaco::ConstantId`, a `u32` newtype that implements the bytecode field traits. +The program context contains the shared `Module` tables except `code` and `extra`: constants, strings, functions, labels, `main_function`, `file`, and source symbols. `vihaco::program::ProgramContext` is the default context representation and is generic over the VM's constant value and type encodings. A binary bytecode file can also use a custom context type by implementing `BytecodeContext`; generated composite loading is generic over that context type, so Rust infers it from `BinaryBytecodeFile` / `LoadInput, C>`. Section bytecode can refer to shared constants with `vihaco::ConstantId`, a `u32` newtype that implements the bytecode field traits. Each section is: diff --git a/docs/src/pages/guide/parser.md b/docs/src/pages/guide/parser.md index d499287..79c6c46 100644 --- a/docs/src/pages/guide/parser.md +++ b/docs/src/pages/guide/parser.md @@ -115,7 +115,7 @@ That's it for the instruction-level surface. CPU instructions are mostly bare-form mnemonics with optional dot-qualified types: ```rust ignore -use vihaco::value::{Type, Value}; +use vihaco::program::{Type, Value}; use vihaco::Instruction; #[derive(Debug, Clone, PartialEq, Instruction, vihaco_parser::Parse)] From d746b6e5048af362df349ec5547b2b55b76a9ea9 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Tue, 30 Jun 2026 11:44:18 -0400 Subject: [PATCH 11/12] Update text based bytecode grammar to be reasonable --- crates/vihaco/src/binary/file.rs | 4 +- crates/vihaco/src/binary/tests.rs | 112 +++++++++++++------ crates/vihaco/src/binary/text.rs | 100 ++++++++++++----- crates/vihaco/tests/multisection_bytecode.rs | 90 +++++++-------- docs/src/pages/guide/composites.md | 34 +++--- 5 files changed, 210 insertions(+), 130 deletions(-) diff --git a/crates/vihaco/src/binary/file.rs b/crates/vihaco/src/binary/file.rs index 36f000a..b90c44d 100644 --- a/crates/vihaco/src/binary/file.rs +++ b/crates/vihaco/src/binary/file.rs @@ -151,10 +151,10 @@ where } let Some(context_begin) = cursor.next_significant() else { - return Err(eyre::eyre!("expected `@>`")); + return Err(eyre::eyre!("expected `.global:`")); }; if context_begin.kind != LineKind::BeginContext { - return Err(text_line_error(context_begin, "expected `@>`")); + return Err(text_line_error(context_begin, "expected `.global:`")); } if context_begin.indent != 0 { return Err(text_line_error( diff --git a/crates/vihaco/src/binary/tests.rs b/crates/vihaco/src/binary/tests.rs index e6a615c..b97cc7a 100644 --- a/crates/vihaco/src/binary/tests.rs +++ b/crates/vihaco/src/binary/tests.rs @@ -344,30 +344,30 @@ fn rejects_binary_instruction_stream_with_non_multiple_width() { fn parses_text_context_and_nested_sections() { let parsed = TextBytecodeFile::::from_text(&text_file( "", - "~> /:\n\ -\t!>\n\ + ".section(root):\n\ +\t.header(root):\n\ \t\troot header\n\ -\t\n\ +\t.header(root).\n\ +\t.text(root):\n\ \t\troot bytecode\n\ -\t<^\n\ -\t~> cpu:\n\ -\t\t!>\n\ +\t.text(root).\n\ +\t.section(cpu):\n\ +\t\t.header(cpu):\n\ \t\t\tcpu header\n\ -\t\t\n\ +\t\t.header(cpu).\n\ +\t\t.text(cpu):\n\ \t\t\tcpu bytecode\n\ -\t\t<^\n\ -\t\t~> alu:\n\ -\t\t\t!>\n\ +\t\t.text(cpu).\n\ +\t\t.section(alu):\n\ +\t\t\t.header(alu):\n\ \t\t\t\talu header\n\ -\t\t\t\n\ +\t\t\t.header(alu).\n\ +\t\t\t.text(alu):\n\ \t\t\t\talu bytecode\n\ -\t\t\t<^\n\ -\t\t<~ alu.\n\ -\t<~ cpu.\n\ -<~ /.\n", +\t\t\t.text(alu).\n\ +\t\t.section(alu).\n\ +\t.section(cpu).\n\ +.section(root).\n", )) .unwrap(); @@ -397,8 +397,11 @@ fn parses_text_context_and_nested_sections() { #[test] fn parses_text_section_without_header_or_bytecode_as_empty_ranges() { - let parsed = - TextBytecodeFile::::from_text(&text_file("", "~> /:\n<~ /.\n")).unwrap(); + let parsed = TextBytecodeFile::::from_text(&text_file( + "", + ".section(root):\n.section(root).\n", + )) + .unwrap(); let root = parsed.root(); assert_eq!(root.header_text(), ""); @@ -442,7 +445,7 @@ fn 1 () -> (bool, i64) 0 12 24 4\n\ 0 \"cpu\"\n\ 1 \"memory\"\n\ 2 \"timer\"\n", - "~> /:\n<~ /.\n", + ".section(root):\n.section(root).\n", )) .unwrap(); @@ -546,16 +549,22 @@ fn 1 () -> (bool, i64) 0 12 24 4\n\ #[test] fn rejects_text_root_section_with_non_root_name() { - let err = TextBytecodeFile::::from_text(&text_file("", "~> root:\n<~ root.\n")) - .unwrap_err(); + let err = TextBytecodeFile::::from_text(&text_file( + "", + ".section(other):\n.section(other).\n", + )) + .unwrap_err(); - assert!(err.to_string().contains("root section must be named `/`")); + assert!( + err.to_string() + .contains("root section must be named `root`") + ); } #[test] fn rejects_text_bad_version() { let err = TextBytecodeFile::::from_text(&format!( - "vhbc{}\n@>\n<@\n~> /:\n<~ /.\n", + "vhbc{}\n.global:\n.global.\n.section(root):\n.section(root).\n", VERSION + 1 )) .unwrap_err(); @@ -565,8 +574,10 @@ fn rejects_text_bad_version() { #[test] fn rejects_text_missing_context_end() { - let err = - TextBytecodeFile::::from_text("vhbc1\n@>\ncpu\n~> /:\n<~ /.\n").unwrap_err(); + let err = TextBytecodeFile::::from_text( + "vhbc1\n.global:\ncpu\n.section(root):\n.section(root).\n", + ) + .unwrap_err(); assert!(err.to_string().contains("unterminated context")); } @@ -575,7 +586,7 @@ fn rejects_text_missing_context_end() { fn rejects_text_non_local_child_section_name() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> /:\n\t~> gpu/core:\n\t<~ gpu/core.\n<~ /.\n", + ".section(root):\n\t.section(gpu/core):\n\t.section(gpu/core).\n.section(root).\n", )) .unwrap_err(); @@ -586,7 +597,7 @@ fn rejects_text_non_local_child_section_name() { fn rejects_text_duplicate_child_sections() { let err = TextBytecodeFile::::from_text(&text_file( "cpu\n", - "~> /:\n\t~> cpu:\n\t<~ cpu.\n\t~> cpu:\n\t<~ cpu.\n<~ /.\n", + ".section(root):\n\t.section(cpu):\n\t.section(cpu).\n\t.section(cpu):\n\t.section(cpu).\n.section(root).\n", )) .unwrap_err(); @@ -595,17 +606,48 @@ fn rejects_text_duplicate_child_sections() { #[test] fn rejects_text_mismatched_section_end_marker() { - let err = TextBytecodeFile::::from_text(&text_file("", "~> /:\n<~ other.\n")) - .unwrap_err(); + let err = TextBytecodeFile::::from_text(&text_file( + "", + ".section(root):\n.section(other).\n", + )) + .unwrap_err(); assert!(err.to_string().contains("mismatched marker `other`")); } +#[test] +fn rejects_text_header_marker_with_wrong_section_name() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + ".section(root):\n\t.header(cpu):\n\t.header(cpu).\n.section(root).\n", + )) + .unwrap_err(); + + assert!( + err.to_string() + .contains("header marker for section `root` uses mismatched name `cpu`") + ); +} + +#[test] +fn rejects_text_bytecode_end_marker_with_wrong_section_name() { + let err = TextBytecodeFile::::from_text(&text_file( + "", + ".section(root):\n\t.text(root):\n\t.text(cpu).\n.section(root).\n", + )) + .unwrap_err(); + + assert!( + err.to_string() + .contains("bytecode marker for section `root` uses mismatched name `cpu`") + ); +} + #[test] fn rejects_text_body_directly_inside_section() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> /:\n\tthis line is not in a header or bytecode block\n<~ /.\n", + ".section(root):\n\tthis line is not in a header or bytecode block\n.section(root).\n", )) .unwrap_err(); @@ -619,7 +661,7 @@ fn rejects_text_body_directly_inside_section() { fn rejects_text_child_section_indented_with_spaces() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> /:\n ~> cpu:\n <~ cpu.\n<~ /.\n", + ".section(root):\n .section(cpu):\n .section(cpu).\n.section(root).\n", )) .unwrap_err(); @@ -630,7 +672,7 @@ fn rejects_text_child_section_indented_with_spaces() { fn rejects_text_header_indented_with_spaces() { let err = TextBytecodeFile::::from_text(&text_file( "", - "~> /:\n !>\n\t\troot header\n Vec<&str> { } fn text_file(context: &str, sections: &str) -> String { - format!("vhbc{VERSION}\n\n@>\n{context}<@\n\n{sections}") + format!("vhbc{VERSION}\n\n.global:\n{context}.global.\n\n{sections}") } fn write_string(bytes: &mut Vec, value: &str) { diff --git a/crates/vihaco/src/binary/text.rs b/crates/vihaco/src/binary/text.rs index e48619a..244cbb8 100644 --- a/crates/vihaco/src/binary/text.rs +++ b/crates/vihaco/src/binary/text.rs @@ -14,7 +14,7 @@ use super::{ }; type ParseExtra<'src> = extra::Err>; -const ROOT_SECTION_NAME: &str = "/"; +const ROOT_SECTION_NAME: &str = "root"; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum LineKind { @@ -23,10 +23,10 @@ pub(super) enum LineKind { EndContext, BeginSection(String), EndSection(String), - BeginHeader, - EndHeader, - BeginBytecode, - EndBytecode, + BeginHeader(String), + EndHeader(String), + BeginBytecode(String), + EndBytecode(String), Body, Blank, } @@ -78,9 +78,8 @@ fn parse_line(line: &str) -> Result { } fn line_parser<'src>() -> impl Parser<'src, &'src str, LineKind, ParseExtra<'src>> { - let space = one_of(" \t").repeated().at_least(1); let name = any() - .filter(|c: &char| !c.is_whitespace() && !matches!(*c, ':' | '.')) + .filter(|c: &char| !c.is_whitespace() && !matches!(*c, ':' | '.' | '(' | ')')) .repeated() .at_least(1) .collect::(); @@ -91,25 +90,35 @@ fn line_parser<'src>() -> impl Parser<'src, &'src str, LineKind, ParseExtra<'src })) .map(LineKind::Version); - let begin_context = just("@>").to(LineKind::BeginContext); - let end_context = just("<@").to(LineKind::EndContext); + let begin_context = just(".global:").to(LineKind::BeginContext); + let end_context = just(".global.").to(LineKind::EndContext); - let begin_section = just("~>") - .ignore_then(space) + let begin_section = just(".section(") .ignore_then(name) - .then_ignore(just(':')) + .then_ignore(just("):")) .map(LineKind::BeginSection); - let end_section = just("<~") - .ignore_then(space) + let end_section = just(".section(") .ignore_then(name) - .then_ignore(just('.')) + .then_ignore(just(").")) .map(LineKind::EndSection); - let begin_header = just("!>").to(LineKind::BeginHeader); - let end_header = just("").to(LineKind::BeginBytecode); - let end_bytecode = just("<^").to(LineKind::EndBytecode); + let begin_bytecode = just(".text(") + .ignore_then(name) + .then_ignore(just("):")) + .map(LineKind::BeginBytecode); + let end_bytecode = just(".text(") + .ignore_then(name) + .then_ignore(just(").")) + .map(LineKind::EndBytecode); let blank = one_of(" \t").repeated().to(LineKind::Blank); let body = any().repeated().at_least(1).to(LineKind::Body); @@ -223,7 +232,7 @@ pub(super) fn consume_context(cursor: &mut LineCursor<'_>) -> Result { } } - Err(eyre::eyre!("unterminated context; expected `<@`")) + Err(eyre::eyre!("unterminated context; expected `.global.`")) } pub(super) struct TextSectionParseInfo<'a> { @@ -306,8 +315,9 @@ pub(super) fn parse_section( children, }); } - LineKind::BeginHeader => { + LineKind::BeginHeader(name) => { ensure_indent(line, section_indent + 1, "header")?; + ensure_block_marker_name(line, "header", name, §ion_name)?; if header.is_some() { return Err(line_error( line, @@ -319,11 +329,11 @@ pub(super) fn parse_section( §ion_name, "header", section_indent + 1, - |kind| matches!(kind, LineKind::EndHeader), )?); } - LineKind::BeginBytecode => { + LineKind::BeginBytecode(name) => { ensure_indent(line, section_indent + 1, "bytecode")?; + ensure_block_marker_name(line, "bytecode", name, §ion_name)?; if bytecode.is_some() { return Err(line_error( line, @@ -335,7 +345,6 @@ pub(super) fn parse_section( §ion_name, "bytecode", section_indent + 1, - |kind| matches!(kind, LineKind::EndBytecode), )?); } LineKind::BeginSection(child_name) => { @@ -380,7 +389,6 @@ fn consume_named_block( section_name: &str, label: &str, block_indent: usize, - end_name: impl Fn(&LineKind) -> bool, ) -> Result> { let begin = cursor .next_significant() @@ -388,18 +396,48 @@ fn consume_named_block( let start = begin.full.end; while let Some(line) = cursor.next_significant() { - if end_name(&line.kind) { + if let Some(marker_name) = block_end_marker_name(&line.kind, label) { ensure_indent(line, block_indent, label)?; + ensure_block_marker_name(line, label, marker_name, section_name)?; return Ok(start..line.full.start); } } Err(line_error( begin, - format!("unterminated {label}; expected `{}`", end_marker(label)), + format!( + "unterminated {label}; expected `{}`", + end_marker(label, section_name) + ), )) } +fn block_end_marker_name<'a>(kind: &'a LineKind, label: &str) -> Option<&'a str> { + match (label, kind) { + ("header", LineKind::EndHeader(name)) | ("bytecode", LineKind::EndBytecode(name)) => { + Some(name) + } + _ => None, + } +} + +fn ensure_block_marker_name( + line: &SourceLine, + label: &str, + marker_name: &str, + section_name: &str, +) -> Result<()> { + if marker_name != section_name { + return Err(line_error( + line, + format!( + "{label} marker for section `{section_name}` uses mismatched name `{marker_name}`" + ), + )); + } + Ok(()) +} + fn ensure_indent(line: &SourceLine, expected: usize, label: &str) -> Result<()> { if line.indent != expected { return Err(line_error( @@ -413,11 +451,11 @@ fn ensure_indent(line: &SourceLine, expected: usize, label: &str) -> Result<()> Ok(()) } -fn end_marker(label: &str) -> &'static str { +fn end_marker(label: &str, section_name: &str) -> String { match label { - "header" => " "<^", - _ => "end marker", + "header" => format!(".header({section_name})."), + "bytecode" => format!(".text({section_name})."), + _ => "end marker".to_string(), } } diff --git a/crates/vihaco/tests/multisection_bytecode.rs b/crates/vihaco/tests/multisection_bytecode.rs index 9bf97b4..c4a7d8d 100644 --- a/crates/vihaco/tests/multisection_bytecode.rs +++ b/crates/vihaco/tests/multisection_bytecode.rs @@ -335,21 +335,21 @@ fn binary_generated_loadable_routes_program_and_child_sections() { fn text_generated_loadable_routes_program_and_child_sections() { let file = text_file( &["child", "default_child"], - "~> /:\n\ -\t^>\n\ + ".section(root):\n\ +\t.text(root):\n\ \t\tnop\n\ -\t<^\n\ -\t~> child:\n\ -\t\t^>\n\ +\t.text(root).\n\ +\t.section(child):\n\ +\t\t.text(child):\n\ \t\t\talt\n\ -\t\t<^\n\ -\t<~ child.\n\ -\t~> default_child:\n\ -\t\t^>\n\ +\t\t.text(child).\n\ +\t.section(child).\n\ +\t.section(default_child):\n\ +\t\t.text(default_child):\n\ \t\t\tnop\n\ -\t\t<^\n\ -\t<~ default_child.\n\ -<~ /.\n", +\t\t.text(default_child).\n\ +\t.section(default_child).\n\ +.section(root).\n", ); let mut machine = TextMachine::default(); @@ -396,14 +396,14 @@ fn binary_generated_loadable_parses_marked_header() { fn text_generated_loadable_parses_marked_header() { let file = text_file( &[], - "~> /:\n\ -\t!>\n\ + ".section(root):\n\ +\t.header(root):\n\ \t\t8\n\ -\t\n\ +\t.header(root).\n\ +\t.text(root):\n\ \t\tnop\n\ -\t<^\n\ -<~ /.\n", +\t.text(root).\n\ +.section(root).\n", ); let mut machine = TextHeaderMachine::default(); @@ -470,22 +470,22 @@ fn binary_generated_loadable_routes_three_level_section_tree() { fn text_generated_loadable_routes_three_level_section_tree() { let file = text_file( &["middle", "leaf"], - "~> /:\n\ -\t^>\n\ + ".section(root):\n\ +\t.text(root):\n\ \t\tnop\n\ -\t<^\n\ -\t~> middle:\n\ -\t\t^>\n\ +\t.text(root).\n\ +\t.section(middle):\n\ +\t\t.text(middle):\n\ \t\t\talt\n\ -\t\t<^\n\ -\t\t~> leaf:\n\ -\t\t\t^>\n\ +\t\t.text(middle).\n\ +\t\t.section(leaf):\n\ +\t\t\t.text(leaf):\n\ \t\t\t\tnop\n\ \t\t\t\talt\n\ -\t\t\t<^\n\ -\t\t<~ leaf.\n\ -\t<~ middle.\n\ -<~ /.\n", +\t\t\t.text(leaf).\n\ +\t\t.section(leaf).\n\ +\t.section(middle).\n\ +.section(root).\n", ); let mut machine = TextHostMachine::default(); @@ -546,11 +546,11 @@ fn binary_generated_loadable_allows_missing_marked_children() { fn text_generated_loadable_allows_missing_marked_children() { let file = text_file( &["child", "default_child"], - "~> /:\n\ -\t^>\n\ + ".section(root):\n\ +\t.text(root):\n\ \t\tnop\n\ -\t<^\n\ -<~ /.\n", +\t.text(root).\n\ +.section(root).\n", ); let mut machine = TextMachine::default(); @@ -580,17 +580,17 @@ fn binary_generated_loadable_rejects_unexpected_direct_children() { fn text_generated_loadable_rejects_unexpected_direct_children() { let file = text_file( &["child", "default_child", "extra"], - "~> /:\n\ -\t^>\n\ + ".section(root):\n\ +\t.text(root):\n\ \t\tnop\n\ -\t<^\n\ -\t~> child:\n\ -\t<~ child.\n\ -\t~> default_child:\n\ -\t<~ default_child.\n\ -\t~> extra:\n\ -\t<~ extra.\n\ -<~ /.\n", +\t.text(root).\n\ +\t.section(child):\n\ +\t.section(child).\n\ +\t.section(default_child):\n\ +\t.section(default_child).\n\ +\t.section(extra):\n\ +\t.section(extra).\n\ +.section(root).\n", ); let mut machine = TextMachine::default(); @@ -620,7 +620,7 @@ fn text_file(section_names: &[&str], sections: &str) -> BytecodeFile::from_text(&format!( - "vhbc{VERSION}\n\n@>\n{context}<@\n\n{sections}" + "vhbc{VERSION}\n\n.global:\n{context}.global.\n\n{sections}" )) .unwrap() } diff --git a/docs/src/pages/guide/composites.md b/docs/src/pages/guide/composites.md index 4ceedfd..620dc2a 100644 --- a/docs/src/pages/guide/composites.md +++ b/docs/src/pages/guide/composites.md @@ -261,41 +261,41 @@ The file begins with the text magic/version marker, then a global context block: ```text vhbc1 -@> +.global: global context text -<@ +.global. ``` -`vhbc1` is the text spelling of version 1. The context body is delegated to `C::from_bytes(context_text.as_bytes())`; custom text formats usually provide a custom `BytecodeContext` that interprets this block. The context start marker `@>` and end marker `<@` must be at indentation level 0. +`vhbc1` is the text spelling of version 1. The context body is delegated to `C::from_text(context_text)`; custom text formats usually provide a custom `BytecodeContext` that interprets this block. The context start marker `.global:` and end marker `.global.` must be at indentation level 0. -After the context comes the root section. Sections use `~> name:` to begin and `<~ name.` to end. The top-level section must be named `/` (`~> /:` and `<~ /.`), and it is parsed as `SectionPath::root()`. Direct child section names become path components. +After the context comes the root section. Sections use `.section(name):` to begin and `.section(name).` to end. The top-level section must be named `root` (`.section(root):` and `.section(root).`), and it is parsed as `SectionPath::root()`. Direct child section names become path components. ```text -~> /: - !> +.section(root): + .header(root): root header - + .text(root): root bytecode - <^ + .text(root). - ~> cpu: - ^> + .section(cpu): + .text(cpu): cpu bytecode - <^ - <~ cpu. -<~ /. + .text(cpu). + .section(cpu). +.section(root). ``` Inside a section: -- `!>` / `` / `<^` delimit the section bytecode text for `TextSectionView::text()` +- `.header(name):` / `.header(name).` delimit the composite header text for `TextSectionView::header_text()` +- `.text(name):` / `.text(name).` delimit the section bytecode text for `TextSectionView::text()` - child sections are nested directly inside their parent section - header, bytecode, and direct child section markers must be indented with exactly one tab more than their parent section - section end markers must use the same indentation as their matching section start marker -- section names must be local names; `/` is rejected in a single marker name +- section names must be local names; `/` is rejected in a child marker name The text parser preserves the original header and bytecode ranges, including their leading tabs. Generated header loading trims `section.header_text()` before calling `FromStr` for the `#[header]` field type. Generated program loading delegates to `ProgramLoader`, which parses `section.text()` with `parse_instruction_stream::()`; instruction types loaded this way must implement both `Instruction` and `vihaco_parser_core::Parse`. From 931b3542bf5a9e011f3eec672ecd199c1ff6d7e5 Mon Sep 17 00:00:00 2001 From: Rob Patterson Date: Tue, 30 Jun 2026 16:38:32 -0400 Subject: [PATCH 12/12] Rename derive_machine file to reflect handling of composite attribute; remove derive macro for Machine --- .../src/{derive_machine.rs => attr_composite.rs} | 0 crates/vihaco-derive/src/lib.rs | 9 ++------- 2 files changed, 2 insertions(+), 7 deletions(-) rename crates/vihaco-derive/src/{derive_machine.rs => attr_composite.rs} (100%) diff --git a/crates/vihaco-derive/src/derive_machine.rs b/crates/vihaco-derive/src/attr_composite.rs similarity index 100% rename from crates/vihaco-derive/src/derive_machine.rs rename to crates/vihaco-derive/src/attr_composite.rs diff --git a/crates/vihaco-derive/src/lib.rs b/crates/vihaco-derive/src/lib.rs index dc4627e..81b01f4 100644 --- a/crates/vihaco-derive/src/lib.rs +++ b/crates/vihaco-derive/src/lib.rs @@ -2,10 +2,10 @@ // SPDX-License-Identifier: MIT mod attr_component; +mod attr_composite; mod attr_observe; mod common; mod derive_instruction; -mod derive_machine; mod derive_message; use proc_macro::TokenStream; @@ -22,15 +22,10 @@ pub fn derive_message(input: TokenStream) -> TokenStream { derive_message::expand(input) } -#[proc_macro_derive(Machine, attributes(device, program, loadable, header))] -pub fn derive_machine(input: TokenStream) -> TokenStream { - derive_machine::expand(input) -} - #[proc_macro_attribute] pub fn composite(_attr: TokenStream, item: TokenStream) -> TokenStream { let original = proc_macro2::TokenStream::from(item.clone()); - let generated = proc_macro2::TokenStream::from(derive_machine::expand(item)); + let generated = proc_macro2::TokenStream::from(attr_composite::expand(item)); let mut sanitized: DeriveInput = match syn::parse2(original) { Ok(input) => input, Err(err) => return err.into_compile_error().into(),