diff --git a/Cargo.lock b/Cargo.lock index 70bd1de..6b3cd06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,21 @@ checksum = "9b2a72055cd9cffc40c9f75f1e5810c80559e158796cf2202292ce4745889588" name = "pinocchio" version = "0.6.0" +[[package]] +name = "pinocchio-log" +version = "0.1.0" +dependencies = [ + "pinocchio-log-macro", +] + +[[package]] +name = "pinocchio-log-macro" +version = "0.1.0" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "pinocchio-pubkey" version = "0.2.1" @@ -37,6 +52,41 @@ dependencies = [ "pinocchio-pubkey", ] +[[package]] +name = "proc-macro2" +version = "1.0.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + [[package]] name = "pinocchio-token" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index e7c7548..9709685 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "2" -members = ["programs/system", "programs/token", "sdk/pinocchio", "sdk/pubkey"] +members = [ + "programs/system", + "programs/token", + "sdk/log/crate", + "sdk/log/macro", + "sdk/pinocchio", + "sdk/pubkey", +] [workspace.package] edition = "2021" diff --git a/sdk/log/crate/Cargo.toml b/sdk/log/crate/Cargo.toml new file mode 100644 index 0000000..5a3dc27 --- /dev/null +++ b/sdk/log/crate/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "pinocchio-log" +description = "Lightweight log utility for Solana programs" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +readme = "./README.md" +repository = { workspace = true } + +[dependencies] +pinocchio-log-macro = { version = "^0", path = "../macro", optional = true } + +[lints.rust] +unexpected_cfgs = {level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } + +[features] +default = ["macro"] +macro = ["dep:pinocchio-log-macro"] diff --git a/sdk/log/crate/README.md b/sdk/log/crate/README.md new file mode 100644 index 0000000..9428a36 --- /dev/null +++ b/sdk/log/crate/README.md @@ -0,0 +1,79 @@ +# pinocchio-log + +Lightweight log utility for Solana programs. + +## Overview + +Currently, logging messages that require formatting are a bit heavy on the CU consumption. There are two aspects when comes to determining the cost of a log message: + +1. `base cost`: this is the cost of the log syscall. It will either be the [`syscall_base_cost`](https://github.com/anza-xyz/agave/blob/master/compute-budget/src/compute_budget.rs#L167) (currently `100` CU) or a number of CUs equal to the length of the message, whichever value is higher. + +2. `formatting cost`: the compute units required to format the message. This is variable and depends on the number and type of the arguments. Formatting is performed using Rust built-in `format!` routines, which in turn use `format_args!`. + +It is known that Rust formatting routines are CPU-intensive for constrained environments. This has been noted on both the `solana-program` [`msg!`](https://docs.rs/solana-program/latest/solana_program/macro.msg.html) documentation and more generally on [rust development](https://github.com/rust-lang/rust/issues/99012). + +While the cost related to (1) is *fixed*, in the sense that it does not change with the addition of formatting, it is possible to improve the overall cost of logging a formatted message using a lightweight formatting routine — this is what this crate does. + +This crate defines a lightweight `Logger` type to format log messages and a companion `log!` macro. The logger is a fixed size buffer that can be used to format log messages before sending them to the log output. Any type that implements the `Log` trait can be appended to the logger. + +Below is a sample of the improvements observed when formatting log messages, measured in terms of compute units (CU): +| Ouput message | `log!` | `msg!` | Improvement (%) | +|------------------------------------|--------|--------------|-----------------| +| `"Hello world!"` | 103 | 103 | - | +| `"lamports={}"` + `u64` | 374 | 627 (+253) | 40% | +| `"{}"` + `[&str; 2]` | 384 | 1648 (+1264) | 76% | +| `"{}"` + `[u64; 2]` | 601 | 1060 (+459) | 44% | +| `"lamports={}"` + `i64` | 389 | 660 (+271) | 41% | +| `"{}"` + `[u8; 32]` (pubkey bytes) | 3147 | 8401 (+5254) | 62% | + +> Note: The improvement in CU is accumulative, meaning that if you are logging multiple `u64` values, there will be a 40% improvement per formatted `u64` value. + +## Features + +* Zero dependencies and `no_std` crate +* Independent of SDK (i.e., works with `pinocchio`, `solana-program` or `anchor`) +* Support for `&str`, unsigned and signed integer types +* `log!` macro to facilitate log message formatting + +## Getting Started + +From your project folder: +```bash +cargo add pinocchio-log +``` + +## Usage + +The `Logger` can be used directly: +```rust +use pinocchio_log::logger::Logger; + +let mut logger = Logger::<100>::default(); +logger.append("Hello "); +logger.append("world!"); +logger.log(); +``` + + or via the `log!` macro: + ```rust +use pinocchio_log::log + +let amount = 1_000_000_000; +log!("transfer amount: {}", amount); +``` + +Since the formatting routine does not perform additional allocations, the `Logger` type has a fixed size specified on its creation. When using the `log!` macro, it is also possible to specify the size of the logger buffer: + +```rust +use pinocchio_log::log + +let amount = 1_000_000_000; +log!(100, "transfer amount: {}", amount); +``` +## Limitations + +Currently the `log!` macro does not offer extra formatting options apart from the placeholder "`{}`" for argument values. + +## License + +The code is licensed under the [Apache License Version 2.0](LICENSE) diff --git a/sdk/log/crate/src/lib.rs b/sdk/log/crate/src/lib.rs new file mode 100644 index 0000000..ad2e6fe --- /dev/null +++ b/sdk/log/crate/src/lib.rs @@ -0,0 +1,113 @@ +//! Lightweight log utility for Solana programs. +//! +//! This crate provides a `Logger` struct that can be used to efficiently log messages +//! in a Solana program. The `Logger` struct is a wrapper around a fixed-size buffer, +//! where types that implement the `Log` trait can be appended to the buffer. +//! +//! The `Logger` struct is generic over the size of the buffer, and the buffer size +//! should be chosen based on the expected size of the log messages. When the buffer is +//! full, the log message will be truncated. This is represented by the `@` character +//! at the end of the log message. +//! +//! # Example +//! +//! Creating a `Logger` with a buffer size of 100 bytes, and appending a string and an +//! `u64` value: +//! +//! ``` +//! use pinocchio_log::logger::Logger; +//! +//! let mut logger = Logger::<100>::default(); +//! logger.append("balance="); +//! logger.append(1_000_000_000); +//! logger.log(); +//! +//! // Clear the logger buffer. +//! logger.clear(); +//! +//! logger.append(&["Hello ", "world!"]); +//! logger.log(); +//! ``` + +#![no_std] + +pub mod logger; + +#[cfg(feature = "macro")] +pub use pinocchio_log_macro::*; + +#[cfg(test)] +mod tests { + use crate::logger::Logger; + + #[test] + fn test_logger() { + let mut logger = Logger::<100>::default(); + logger.append("Hello "); + logger.append("world!"); + + assert!(&*logger == "Hello world!".as_bytes()); + + logger.clear(); + + logger.append("balance="); + logger.append(1_000_000_000); + + assert!(&*logger == "balance=1000000000".as_bytes()); + } + + #[test] + fn test_logger_trucated() { + let mut logger = Logger::<8>::default(); + logger.append("Hello "); + logger.append("world!"); + + assert!(&*logger == "Hello w@".as_bytes()); + + let mut logger = Logger::<12>::default(); + + logger.append("balance="); + logger.append(1_000_000_000); + + assert!(&*logger == "balance=100@".as_bytes()); + } + + #[test] + fn test_logger_slice() { + let mut logger = Logger::<20>::default(); + logger.append(&["Hello ", "world!"]); + + assert!(&*logger == "[\"Hello \", \"world!\"]".as_bytes()); + + let mut logger = Logger::<20>::default(); + logger.append(&[123, 456]); + + assert!(&*logger == "[123, 456]".as_bytes()); + } + + #[test] + fn test_logger_truncated_slice() { + let mut logger = Logger::<5>::default(); + logger.append(&["Hello ", "world!"]); + + assert!(&*logger == "[\"He@".as_bytes()); + + let mut logger = Logger::<4>::default(); + logger.append(&[123, 456]); + + assert!(&*logger == "[12@".as_bytes()); + } + + #[test] + fn test_logger_signed() { + let mut logger = Logger::<2>::default(); + logger.append(-2); + + assert!(&*logger == "-2".as_bytes()); + + let mut logger = Logger::<5>::default(); + logger.append(-200_000_000); + + assert!(&*logger == "-200@".as_bytes()); + } +} diff --git a/sdk/log/crate/src/logger.rs b/sdk/log/crate/src/logger.rs new file mode 100644 index 0000000..58e1a05 --- /dev/null +++ b/sdk/log/crate/src/logger.rs @@ -0,0 +1,424 @@ +use core::{mem::MaybeUninit, ops::Deref, slice::from_raw_parts}; + +#[cfg(target_os = "solana")] +extern "C" { + pub fn sol_log_(message: *const u8, len: u64); + + pub fn sol_memcpy_(dst: *mut u8, src: *const u8, n: u64); +} + +#[cfg(not(target_os = "solana"))] +extern crate std; + +/// Byte representation of the digits [0, 9]. +const DIGITS: [u8; 10] = [b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9']; + +/// Byte represeting a truncated log. +const TRUCATED: u8 = b'@'; + +/// An uninitialized byte. +const UNINIT_BYTE: MaybeUninit = MaybeUninit::uninit(); + +/// Logger to efficiently format log messages. +/// +/// The logger is a fixed size buffer that can be used to format log messages +/// before sending them to the log output. Any type that implements the `Log` +/// trait can be appended to the logger. +pub struct Logger { + // Byte buffer to store the log message. + buffer: [MaybeUninit; BUFFER], + + // Remaining space in the buffer. + offset: usize, +} + +impl Default for Logger { + fn default() -> Self { + Self { + buffer: [UNINIT_BYTE; BUFFER], + offset: 0, + } + } +} + +impl Deref for Logger { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + unsafe { from_raw_parts(self.buffer.as_ptr() as *const _, self.offset) } + } +} + +impl Logger { + /// Append a value to the logger. + #[inline(always)] + pub fn append(&mut self, value: T) { + if self.is_full() { + if BUFFER > 0 { + unsafe { + let last = self.buffer.get_unchecked_mut(BUFFER - 1); + last.write(TRUCATED); + } + } + } else { + self.offset += value.write(&mut self.buffer[self.offset..]); + } + } + + /// Log the message in the buffer. + #[inline(always)] + pub fn log(&self) { + log_message(self); + } + + /// Clear the buffer. + #[inline(always)] + pub fn clear(&mut self) { + self.offset = 0; + } + + /// Check if the buffer is empty. + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.offset == 0 + } + + /// Check if the buffer is full. + #[inline(always)] + pub fn is_full(&self) -> bool { + self.offset == BUFFER + } + + /// Get the length of the buffer. + #[inline(always)] + pub fn len(&self) -> usize { + self.offset + } + + /// Get the remaining space in the buffer. + #[inline(always)] + pub fn remaining(&self) -> usize { + BUFFER - self.offset + } +} + +/// Log a message. +#[inline(always)] +pub fn log_message(message: &[u8]) { + #[cfg(target_os = "solana")] + unsafe { + sol_log_(message.as_ptr(), message.len() as u64); + } + #[cfg(not(target_os = "solana"))] + { + let message = core::str::from_utf8(message).unwrap(); + std::println!("{}", message); + } +} + +/// Trait to specify the log behavior for a type. +pub trait Log { + fn debug(&self, buffer: &mut [MaybeUninit]) -> usize { + self.write(buffer) + } + + fn write(&self, buffer: &mut [MaybeUninit]) -> usize; +} + +/// Implement the log trait for unsigned integer types. +macro_rules! impl_log_for_unsigned_integer { + ( $type:tt, $max_digits:literal ) => { + impl Log for $type { + fn write(&self, buffer: &mut [MaybeUninit]) -> usize { + if buffer.is_empty() { + return 0; + } + + match *self { + // Handle zero as a special case. + 0 => { + unsafe { + buffer.get_unchecked_mut(0).write(*DIGITS.get_unchecked(0)); + } + 1 + } + mut value => { + let mut digits = [UNINIT_BYTE; $max_digits]; + let mut offset = $max_digits; + + while value > 0 { + let remainder = value % 10; + value /= 10; + offset -= 1; + + unsafe { + digits + .get_unchecked_mut(offset) + .write(*DIGITS.get_unchecked(remainder as usize)); + } + } + + // Number of available digits to write. + let available = $max_digits - offset; + // Size of the buffer. + let length = buffer.len(); + + // Determines if the value was truncated or not by calculating the + // number of digits that can be written. + let (overflow, written) = if available <= length { + (false, available) + } else { + (true, length) + }; + + unsafe { + let ptr = buffer.as_mut_ptr(); + #[cfg(target_os = "solana")] + sol_memcpy_( + ptr as *mut _, + digits[offset..].as_ptr() as *const _, + length as u64, + ); + #[cfg(not(target_os = "solana"))] + core::ptr::copy_nonoverlapping(digits[offset..].as_ptr(), ptr, written); + } + + // There might not have been space for all the value. + if overflow { + unsafe { + let last = buffer.get_unchecked_mut(written - 1); + last.write(TRUCATED); + } + } + + written + } + } + } + } + }; +} + +// Supported unsigned integer types. +impl_log_for_unsigned_integer!(u8, 3); +impl_log_for_unsigned_integer!(u16, 5); +impl_log_for_unsigned_integer!(u32, 10); +impl_log_for_unsigned_integer!(u64, 20); +impl_log_for_unsigned_integer!(u128, 39); + +/// Implement the log trait for the signed integer types. +macro_rules! impl_log_for_signed { + ( $type:tt, $max_digits:literal ) => { + impl Log for $type { + fn write(&self, buffer: &mut [MaybeUninit]) -> usize { + if buffer.is_empty() { + return 0; + } + + match *self { + // Handle zero as a special case. + 0 => { + unsafe { + buffer.get_unchecked_mut(0).write(*DIGITS.get_unchecked(0)); + } + 1 + } + mut value => { + let mut delta = 0; + + if *self < 0 { + unsafe { + buffer.get_unchecked_mut(0).write(b'-'); + } + delta += 1; + value = -value + }; + + let mut digits = [UNINIT_BYTE; $max_digits]; + let mut offset = $max_digits; + + while value > 0 { + let remainder = value % 10; + value /= 10; + offset -= 1; + + unsafe { + digits + .get_unchecked_mut(offset) + .write(*DIGITS.get_unchecked(remainder as usize)); + } + } + + // Number of available digits to write. + let available = $max_digits - offset; + // Size of the buffer. + let length = buffer.len() - delta; + + // Determines if the value was truncated or not by calculating the + // number of digits that can be written. + let (overflow, written) = if available <= length { + (false, available) + } else { + (true, length) + }; + + unsafe { + let ptr = buffer[delta..].as_mut_ptr(); + #[cfg(target_os = "solana")] + sol_memcpy_( + ptr as *mut _, + digits[offset..].as_ptr() as *const _, + length as u64, + ); + #[cfg(not(target_os = "solana"))] + core::ptr::copy_nonoverlapping(digits[offset..].as_ptr(), ptr, written); + } + + // There might not have been space for all the value. + if overflow { + unsafe { + let last = buffer.get_unchecked_mut(written + delta - 1); + last.write(TRUCATED); + } + } + + written + delta + } + } + } + } + }; +} + +// Supported signed integer types. +impl_log_for_signed!(i8, 3); +impl_log_for_signed!(i16, 5); +impl_log_for_signed!(i32, 10); +impl_log_for_signed!(i64, 19); +impl_log_for_signed!(i128, 39); + +/// Implement the log trait for the &str type. +impl Log for &str { + fn debug(&self, buffer: &mut [MaybeUninit]) -> usize { + if buffer.is_empty() { + return 0; + } + + unsafe { + buffer.get_unchecked_mut(0).write(b'"'); + } + + let mut offset = 1; + offset += self.write(&mut buffer[offset..]); + + match buffer.len() - offset { + 0 => unsafe { + buffer.get_unchecked_mut(offset - 1).write(TRUCATED); + }, + _ => { + unsafe { + buffer.get_unchecked_mut(offset).write(b'"'); + } + offset += 1; + } + } + + offset + } + + fn write(&self, buffer: &mut [MaybeUninit]) -> usize { + let length = core::cmp::min(buffer.len(), self.len()); + let offset = &mut buffer[..length]; + + for (d, s) in offset.iter_mut().zip(self.bytes()) { + d.write(s); + } + + // There might not have been space for all the value. + if length != self.len() { + unsafe { + let last = buffer.get_unchecked_mut(length - 1); + last.write(TRUCATED); + } + } + + length + } +} + +/// Implement the log trait for the slice type. +macro_rules! impl_log_for_slice { + ( [$type:ident] ) => { + impl<$type> Log for &[$type] + where + $type: Log + { + impl_log_for_slice!(@generate_write); + } + }; + ( [$type:ident; $size:ident] ) => { + impl<$type, const $size: usize> Log for &[$type; $size] + where + $type: Log + { + impl_log_for_slice!(@generate_write); + } + }; + ( @generate_write ) => { + fn write(&self, buffer: &mut [MaybeUninit]) -> usize { + if buffer.is_empty() { + return 0; + } + + // Size of the buffer. + let length = buffer.len(); + + unsafe { + buffer.get_unchecked_mut(0).write(b'['); + } + + let mut offset = 1; + + for value in self.iter() { + if offset >= length { + unsafe { + buffer.get_unchecked_mut(offset - 1).write(TRUCATED); + } + offset = length; + break; + } + + if offset > 1 { + if offset + 2 >= length { + unsafe { + buffer.get_unchecked_mut(length - 1).write(TRUCATED); + } + offset = length; + break; + } else { + unsafe { + buffer.get_unchecked_mut(offset).write(b','); + buffer.get_unchecked_mut(offset + 1).write(b' '); + } + offset += 2; + } + } + + offset += value.debug(&mut buffer[offset..]); + } + + if offset < length { + unsafe { + buffer.get_unchecked_mut(offset).write(b']'); + } + offset += 1; + } + + offset + } + }; +} + +// Supported slice types. +impl_log_for_slice!([T]); +impl_log_for_slice!([T; N]); diff --git a/sdk/log/macro/Cargo.toml b/sdk/log/macro/Cargo.toml new file mode 100644 index 0000000..51879ca --- /dev/null +++ b/sdk/log/macro/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "pinocchio-log-macro" +description = "Macro for pinocchio log utility" +version = "0.1.0" +edition = { workspace = true } +license = { workspace = true } +readme = "./README.md" +repository = { workspace = true } + +[lib] +proc-macro = true + +[dependencies] +quote = "^1.0" +syn = { version = "^1.0", features = ["extra-traits", "full"] } diff --git a/sdk/log/macro/README.md b/sdk/log/macro/README.md new file mode 100644 index 0000000..5da26a0 --- /dev/null +++ b/sdk/log/macro/README.md @@ -0,0 +1,34 @@ +# pinocchio-log-macro + +Companion `log!` macro for [`pinocchio-log`](https://crates.io/crates/pinocchio-log). It automates the creation of a `Logger` object to log a message. It support a limited subset of the [`format!`](https://doc.rust-lang.org/std/fmt/) syntax. The macro parses the format string at compile time and generates the calls to a `Logger` object to generate the corresponding formatted message. + +## Usage + +The macro works very similar to `solana-program` [`msg!`](https://docs.rs/solana-program/latest/solana_program/macro.msg.html) macro. + +To output a simple message (static `&str`): +```rust +use pinocchio_log::log + +log!("a simple log"); +``` + +To ouput a formatted message: +```rust +use pinocchio_log::log + +let amount = 1_000_000_000; +log!("transfer amount: {}", amount); +``` + +Since a `Logger` size is statically determined, messages are limited to `200` length by default. When logging larger messages, it is possible to increase the logger buffer size: +```rust +use pinocchio_log::log + +let very_long_message = "..."; +log!(500, "message: {}", very_long_message); +``` + +## License + +The code is licensed under the [Apache License Version 2.0](LICENSE) diff --git a/sdk/log/macro/src/lib.rs b/sdk/log/macro/src/lib.rs new file mode 100644 index 0000000..a543c15 --- /dev/null +++ b/sdk/log/macro/src/lib.rs @@ -0,0 +1,164 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + parse_macro_input, parse_str, + punctuated::Punctuated, + Error, Expr, LitInt, LitStr, Token, +}; + +/// The default buffer size for the logger. +const DEFAULT_BUFFER_SIZE: &str = "200"; + +/// Represents the input arguments to the `log!` macro. +struct LogArgs { + /// The length of the buffer to use for the logger. + /// + /// This does not have effect when the literal `str` does + /// not have value placeholders. + buffer_len: LitInt, + + /// The literal formatting string passed to the macro. + /// + /// The `str` might have value placeholders. While this is + /// not a requirement, the number of placeholders must + /// match the number of args. + format_string: LitStr, + + /// The arguments passed to the macro. + /// + /// The arguments represent the values to replace the + /// placeholders on the format `str`. Valid values must implement + /// the [`Log`] trait. + args: Punctuated, +} + +impl Parse for LogArgs { + fn parse(input: ParseStream) -> syn::Result { + // Optional buffer length. + let buffer_len = if input.peek(LitInt) { + let literal = input.parse()?; + // Parse the comma after the buffer length. + input.parse::()?; + literal + } else { + parse_str::(DEFAULT_BUFFER_SIZE)? + }; + + let format_string = input.parse()?; + // Check if there are any arguments passed to the macro. + let args = if input.is_empty() { + Punctuated::new() + } else { + input.parse::()?; + Punctuated::parse_terminated(input)? + }; + + Ok(LogArgs { + buffer_len, + format_string, + args, + }) + } +} + +/// Companion `log!` macro for `pinocchio-log`. +/// +/// The macro automates the creation of a `Logger` object to log a message. +/// It support a limited subset of the [`format!`](https://doc.rust-lang.org/std/fmt/) syntax. +/// The macro parses the format string at compile time and generates the calls to a `Logger` +/// object to generate the corresponding formatted message. +/// +/// # Arguments +/// +/// - `buffer_len`: The length of the buffer to use for the logger (default to `200`). This is an optional argument. +/// - `format_string`: The literal string to log. This string can contain placeholders `{}` to be replaced by the arguments. +/// - `args`: The arguments to replace the placeholders in the format string. The arguments must implement the `Log` trait. +#[proc_macro] +pub fn log(input: TokenStream) -> TokenStream { + // Parse the input into a `LogArgs`. + let LogArgs { + buffer_len, + format_string, + args, + } = parse_macro_input!(input as LogArgs); + let parsed_string = format_string.value(); + + // Check if there are any `{}` placeholders in the format string. + // + // When the format string has placeholders, the list of arguments must + // not be empty. The number of placehilders will be validated later. + let needs_formatting = parsed_string.contains("{}"); + + if !(needs_formatting || args.is_empty()) { + return Error::new_spanned( + format_string, + "the format string must contain a `{}` placeholder for each value.", + ) + .to_compile_error() + .into(); + } + + if needs_formatting { + // The parts of the format string with the placeholders replaced by arguments. + let mut replaced_parts = Vec::new(); + // The number of placeholders in the format string. + let mut part_count = 0; + // The number of arguments passed to the macro. + let mut arg_count = 0; + + let part_iter = parsed_string.split("{}").peekable(); + let mut arg_iter = args.iter(); + + // Replace each occurrence of `{}` with their corresponding argument value. + for part in part_iter { + if !part.is_empty() { + replaced_parts.push(quote! { logger.append(#part) }); + } + part_count += 1; + + if let Some(arg) = arg_iter.next() { + replaced_parts.push(quote! { logger.append(#arg) }); + arg_count += 1; + } + } + + if (part_count - 1) != arg_count { + let arg_message = if arg_count == 0 { + "but no arguments were given".to_string() + } else { + format!( + "but there is {} {}", + arg_count, + if arg_count == 1 { + "argument" + } else { + "arguments" + } + ) + }; + + return Error::new_spanned( + format_string, + format!( + "{} positional arguments in format string, {}", + part_count - 1, + arg_message + ), + ) + .to_compile_error() + .into(); + } + + // Generate the output string as a compile-time constant + TokenStream::from(quote! { + { + let mut logger = pinocchio_log::logger::Logger::<#buffer_len>::default(); + #(#replaced_parts;)* + logger.log(); + } + }) + } else { + TokenStream::from(quote! {pinocchio_log::logger::log_message(#format_string.as_bytes());}) + } +}