libmagic_rs/output/format/mod.rs
1// Copyright (c) 2025-2026 the libmagic-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Printf-style format specifier substitution for magic rule messages.
5//!
6//! Magic file messages frequently contain C-style format specifiers such as
7//! `%lld`, `%02x`, or `%s` that reference the rule's read value. GNU `file`
8//! renders the message with the value substituted at the specifier's
9//! position; without this pass libmagic-rs would emit the literal
10//! specifier tokens (e.g., `at_offset %lld`) and diverge visibly from
11//! `file(1)` output.
12//!
13//! The substitution is intentionally narrow: it supports the subset of
14//! C's `printf` syntax that appears in shipping magic corpora (notably
15//! `third_party/tests/searchbug.magic` and the GNU `file` `Magdir`
16//! collection). Unrecognized specifiers pass through literally with a
17//! `debug!` log rather than erroring -- matching the evaluator's
18//! graceful-skip discipline.
19//!
20//! Width masking for hex specifiers uses [`crate::parser::ast::TypeKind::bit_width`]
21//! so that e.g. a signed byte rendered with `%02x` produces the unsigned
22//! 8-bit interpretation (`0xff`, not `0xffffffffffffffff`).
23//!
24//! See the project plan at
25//! `docs/plans/2026-04-22-001-feat-meta-type-offset-and-format-substitution-plan.md`
26//! for scope, and GOTCHAS.md S14.2 for historical context.
27
28use log::debug;
29
30use crate::parser::ast::{TypeKind, Value};
31
32/// Substitute printf-style format specifiers in a magic rule message.
33///
34/// Walks `template` left to right. Plain text is copied verbatim; on
35/// each `%`, the full specifier (`%[flags][width][.precision][length]<conv>`)
36/// is parsed and substituted from `value`. `%%` emits a single `%`.
37/// Unrecognized or malformed specifiers are passed through literally
38/// with a `debug!` log.
39///
40/// `type_kind` is consulted only for hex specifiers, which need the
41/// natural bit width of the underlying read to mask sign-extended
42/// values correctly. For non-hex specifiers `type_kind` is ignored.
43///
44/// For a `%s` substitution against a `string`/`pstring`/`string16` value,
45/// the rendered field is bounded to at most `MAX_DESCRIPTION_FIELD_LEN`
46/// bytes (cut on a UTF-8 character boundary); `regex`/`search` values
47/// render through a separate, unbounded path and are exempt. This entry
48/// point never stops at a newline (R2's gate) -- use
49/// [`format_magic_message_with_gate`] to opt into that.
50///
51/// # Examples
52///
53/// ```
54/// use libmagic_rs::output::format::format_magic_message;
55/// use libmagic_rs::parser::ast::{TypeKind, Value};
56///
57/// let out = format_magic_message(
58/// "at_offset %lld",
59/// &Value::Uint(11),
60/// &TypeKind::Byte { signed: false },
61/// );
62/// assert_eq!(out, "at_offset 11");
63///
64/// let out = format_magic_message(
65/// "followed_by 0x%02x",
66/// &Value::Uint(0x31),
67/// &TypeKind::Byte { signed: false },
68/// );
69/// assert_eq!(out, "followed_by 0x31");
70///
71/// // Unknown specifier falls through literally.
72/// let out = format_magic_message("%q", &Value::Uint(0), &TypeKind::Byte { signed: false });
73/// assert_eq!(out, "%q");
74///
75/// // `%%` is an escaped literal percent.
76/// let out = format_magic_message("100%% sure", &Value::Uint(0), &TypeKind::Byte { signed: false });
77/// assert_eq!(out, "100% sure");
78/// ```
79#[must_use]
80pub fn format_magic_message(template: &str, value: &Value, type_kind: &TypeKind) -> String {
81 format_magic_message_with_gate(template, value, type_kind, false)
82}
83
84/// Substitute printf-style format specifiers in a magic rule message,
85/// additionally carrying R2's newline-stop gate signal.
86///
87/// Identical to [`format_magic_message`] except for `stop_at_newline`: when
88/// set, a `%s` substitution against a `string`/`pstring`/`string16` value
89/// additionally stops at the first `\r` or `\n` byte within the
90/// `MAX_DESCRIPTION_FIELD_LEN`-byte bound. The caller computes
91/// `stop_at_newline` from the rule's operator and pattern (see
92/// `evaluator::engine::value_eval::newline_stop_gate` in the crate
93/// source) -- neither reaches this formatter otherwise (R14).
94/// `regex`/`search` values ignore this flag entirely (R5).
95#[must_use]
96// Indexing is invariant-safe: every `bytes[i]` is guarded by an
97// `i < bytes.len()` loop condition.
98#[allow(clippy::indexing_slicing)]
99pub fn format_magic_message_with_gate(
100 template: &str,
101 value: &Value,
102 type_kind: &TypeKind,
103 stop_at_newline: bool,
104) -> String {
105 let mut out = String::with_capacity(template.len());
106 let bytes = template.as_bytes();
107 let mut i = 0;
108 // Start of the most recent run of non-`%` bytes. We copy the run
109 // as a string slice rather than byte-by-byte so non-ASCII UTF-8
110 // code points survive intact. Scanning still happens at the byte
111 // level (safe because `%` is ASCII 0x25 and cannot appear as a
112 // UTF-8 continuation byte, which is always >= 0x80).
113 let mut plain_start = 0;
114
115 while i < bytes.len() {
116 if bytes[i] != b'%' {
117 i += 1;
118 continue;
119 }
120
121 // Flush any pending plain-text run as a single UTF-8 slice.
122 if plain_start < i {
123 out.push_str(&template[plain_start..i]);
124 }
125
126 // Start of a format specifier at position i.
127 let spec_start = i;
128 let Some(parsed_spec) = parse_spec(bytes, i + 1) else {
129 // Malformed specifier (e.g., trailing `%` with nothing after,
130 // or a sequence that doesn't end in a valid conversion char).
131 // Pass through the remaining literal and stop scanning.
132 debug!(
133 "format_magic_message: malformed specifier at byte {i} in template {template:?}; passing through remainder literally",
134 );
135 out.push_str(&template[i..]);
136 // Skip the trailing flush -- we have already emitted the
137 // remainder above.
138 plain_start = bytes.len();
139 break;
140 };
141 let next_i = parsed_spec.end;
142 if let Some(rendered) = render(&parsed_spec, value, type_kind, stop_at_newline) {
143 out.push_str(&rendered);
144 } else {
145 // Type mismatch or unsupported conversion; pass through the
146 // literal specifier and log.
147 let literal = &template[spec_start..next_i];
148 debug!(
149 "format_magic_message: unsupported specifier {literal:?} for value {value:?}; passing through literally",
150 );
151 out.push_str(literal);
152 }
153 i = next_i;
154 plain_start = i;
155 }
156
157 // Flush any trailing plain-text run.
158 if plain_start < bytes.len() {
159 out.push_str(&template[plain_start..]);
160 }
161
162 out
163}
164
165/// Kinds of conversion characters we recognize.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167enum Conv {
168 /// `%d`, `%i`, `%ld`, `%lld` -- signed decimal.
169 Signed,
170 /// `%u`, `%lu`, `%llu` -- unsigned decimal.
171 Unsigned,
172 /// `%x` -- lowercase hex.
173 HexLower,
174 /// `%X` -- uppercase hex.
175 HexUpper,
176 /// `%o` -- octal.
177 Octal,
178 /// `%s` -- string.
179 Str,
180 /// `%c` -- single character (full 0x00-0xff byte range via Latin-1 code points).
181 Char,
182 /// `%%` -- literal percent.
183 Percent,
184}
185
186/// Parsed format specifier.
187#[derive(Debug, Clone)]
188struct Spec {
189 zero_pad: bool,
190 left_align: bool,
191 alt_form: bool,
192 width: usize,
193 /// `.<digits>` precision. Currently honored only for `%s` (`Conv::Str`),
194 /// where it truncates the rendered string to at most `precision`
195 /// characters -- e.g. sgml's `%.3s` renders the XML version `1.0` from a
196 /// longer field. `None` means no precision was given (no truncation).
197 precision: Option<usize>,
198 conv: Conv,
199 /// Byte index of the character *after* this specifier in the template.
200 end: usize,
201}
202
203/// Maximum width value accepted from a format specifier.
204///
205/// Caps the field width to prevent crafted magic rules with enormous widths
206/// (e.g., `%999999999d`) from driving unbounded `repeat_n` allocations in the
207/// padding helpers. 4096 is generous for any real magic-corpus usage.
208const MAX_FORMAT_WIDTH: usize = 4096;
209
210/// Bound on how much file-derived text a `%s` substitution renders, taken
211/// from GNU `file`'s `MAXstring - 1` (`src/file.h`). `MAXstring` was 96
212/// through `file` 5.38 and became 128 at 5.39, so this value (127) holds
213/// for `file` 5.39 and later only.
214///
215/// Applies only to the value-buffer types `string`, `pstring`, and
216/// `string16` ([`is_bounded_string_family`]); `regex` and `search` are
217/// bounded elsewhere by their own scan-window limits and are exempt from
218/// this bound and from the newline stop (R5).
219///
220/// `pub(crate)` so the any-value and ordering-display *readers*
221/// (`evaluator::types::any_value_string_bound`,
222/// `evaluator::engine::value_eval::string_ordering_display_value`) can
223/// reuse the same constant rather than redefining it (R12, KTD2) --
224/// keeping the read/anchor bound and the render bound numerically
225/// identical by construction.
226pub(crate) const MAX_DESCRIPTION_FIELD_LEN: usize = 127;
227
228/// Shared first step of both description bounds: clamp to
229/// `MAX_DESCRIPTION_FIELD_LEN` and, when gated, stop at the first `\r` or
230/// `\n` inside that window.
231///
232/// Callers then walk the result back to their own boundary kind -- a `str`
233/// char boundary for [`bound_description_field`], a UTF-8 continuation-byte
234/// boundary for [`bound_bytes`] -- which is why only this prefix is shared.
235// Slicing is invariant-safe: `cut` is clamped to `bytes.len()` on the line
236// above before `bytes[..cut]` is taken.
237#[allow(clippy::indexing_slicing)]
238fn bounded_newline_cut(bytes: &[u8], stop_at_newline: bool) -> usize {
239 let mut cut = bytes.len().min(MAX_DESCRIPTION_FIELD_LEN);
240 if stop_at_newline
241 && let Some(newline_pos) = bytes[..cut].iter().position(|&b| b == b'\r' || b == b'\n')
242 {
243 cut = newline_pos;
244 }
245 cut
246}
247
248/// Parse a format specifier starting at `start` (the first byte after the
249/// leading `%`). Returns `None` if the sequence does not end in a
250/// recognized conversion character.
251// Indexing is invariant-safe: every `bytes[i]` is guarded by an
252// `i < bytes.len()` loop or branch condition.
253#[allow(clippy::indexing_slicing)]
254fn parse_spec(bytes: &[u8], start: usize) -> Option<Spec> {
255 let mut i = start;
256 let mut zero_pad = false;
257 let mut left_align = false;
258 let mut alt_form = false;
259
260 // Flags (subset: 0, -, #). Other flags (+, space) are parsed but ignored.
261 while i < bytes.len() {
262 match bytes[i] {
263 b'0' => {
264 zero_pad = true;
265 i += 1;
266 }
267 b'-' => {
268 left_align = true;
269 i += 1;
270 }
271 b'#' => {
272 alt_form = true;
273 i += 1;
274 }
275 b'+' | b' ' => {
276 // Accepted for syntactic completeness, no rendering effect
277 // in the current subset.
278 i += 1;
279 }
280 _ => break,
281 }
282 }
283
284 // Width (decimal digits). Capped at MAX_FORMAT_WIDTH to prevent
285 // unbounded allocations from crafted format strings.
286 let mut width: usize = 0;
287 while i < bytes.len() && bytes[i].is_ascii_digit() {
288 let digit = (bytes[i] - b'0') as usize;
289 width = width
290 .saturating_mul(10)
291 .saturating_add(digit)
292 .min(MAX_FORMAT_WIDTH);
293 i += 1;
294 }
295
296 // Precision (`.<digits>`). Captured for `%s` truncation (see `render`);
297 // numeric rendering is still whole-value and ignores it. A bare `.` with
298 // no digits means precision 0 (C semantics). The digit run is capped at
299 // MAX_FORMAT_WIDTH for the same crafted-input protection as `width`.
300 let mut precision: Option<usize> = None;
301 if i < bytes.len() && bytes[i] == b'.' {
302 i += 1;
303 let mut prec: usize = 0;
304 while i < bytes.len() && bytes[i].is_ascii_digit() {
305 let digit = (bytes[i] - b'0') as usize;
306 prec = prec
307 .saturating_mul(10)
308 .saturating_add(digit)
309 .min(MAX_FORMAT_WIDTH);
310 i += 1;
311 }
312 precision = Some(prec);
313 }
314
315 // Length modifier (`h`, `hh`, `l`, `ll`, `j`, `z`, `t`). We consume
316 // these for syntactic completeness but never rely on them -- all
317 // numeric rendering uses full u64/i64 width.
318 while i < bytes.len() {
319 match bytes[i] {
320 b'l' | b'h' | b'j' | b'z' | b't' => i += 1,
321 _ => break,
322 }
323 }
324
325 if i >= bytes.len() {
326 return None;
327 }
328
329 let conv = match bytes[i] {
330 b'd' | b'i' => Conv::Signed,
331 b'u' => Conv::Unsigned,
332 b'x' => Conv::HexLower,
333 b'X' => Conv::HexUpper,
334 b'o' => Conv::Octal,
335 b's' => Conv::Str,
336 b'c' => Conv::Char,
337 b'%' => Conv::Percent,
338 _ => return None,
339 };
340 i += 1;
341
342 Some(Spec {
343 zero_pad,
344 left_align,
345 alt_form,
346 width,
347 precision,
348 conv,
349 end: i,
350 })
351}
352
353/// Render the specifier against `value`, or return `None` if the value
354/// is type-incompatible with the conversion.
355///
356/// `stop_at_newline` is R2's gate signal (see [`format_magic_message`]);
357/// it is consulted only by the `%s` arm.
358fn render(
359 spec: &Spec,
360 value: &Value,
361 type_kind: &TypeKind,
362 stop_at_newline: bool,
363) -> Option<String> {
364 match spec.conv {
365 Conv::Percent => Some("%".to_string()),
366 Conv::Str => Some(render_str_spec(spec, value, type_kind, stop_at_newline)),
367 Conv::Signed => {
368 let n = coerce_to_i64(value)?;
369 Some(pad_numeric(&n.to_string(), spec))
370 }
371 Conv::Unsigned => {
372 let n = coerce_to_u64(value)?;
373 Some(pad_numeric(&n.to_string(), spec))
374 }
375 Conv::HexLower => {
376 let n = coerce_to_u64_masked(value, type_kind)?;
377 // C printf suppresses the `0x`/`0X` alt-form prefix when the
378 // value is zero: `printf("%#x", 0)` emits `"0"`, not `"0x0"`.
379 let prefix = if spec.alt_form && n != 0 { "0x" } else { "" };
380 Some(render_prefixed_int(&format!("{n:x}"), prefix, spec))
381 }
382 Conv::HexUpper => {
383 let n = coerce_to_u64_masked(value, type_kind)?;
384 let prefix = if spec.alt_form && n != 0 { "0X" } else { "" };
385 Some(render_prefixed_int(&format!("{n:X}"), prefix, spec))
386 }
387 Conv::Octal => {
388 let n = coerce_to_u64_masked(value, type_kind)?;
389 // C printf uses a single "0" prefix for %#o (not Rust's "0o"),
390 // and suppresses the prefix when the value itself is zero --
391 // the resulting digit `0` already satisfies the "starts with
392 // 0" invariant that the alt-form is meant to guarantee.
393 let prefix = if spec.alt_form && n != 0 { "0" } else { "" };
394 Some(render_prefixed_int(&format!("{n:o}"), prefix, spec))
395 }
396 Conv::Char => {
397 let n = coerce_to_u64(value)?;
398 let byte = u8::try_from(n).ok()?;
399 // GNU `file` / C printf `%c` converts the int argument to
400 // unsigned char and emits it directly for all byte values
401 // 0x00-0xff. Rust's `String` must be valid UTF-8, so we
402 // embed bytes >= 0x80 as their Latin-1 code points (U+0080
403 // through U+00FF) via `char::from(u8)` which is infallible
404 // and lossless. Consumers with UTF-8 terminals see the
405 // 2-byte UTF-8 encoding of that code point; consumers
406 // iterating the returned bytes directly can recover the
407 // original byte by re-encoding the code point as Latin-1.
408 //
409 // POSIX: the `0` flag is ignored for `%c` -- zero-padding only
410 // applies to numeric/float conversions. Always use space-padding
411 // for `%c`, matching C printf behavior.
412 Some(pad_non_numeric(&char::from(byte).to_string(), spec))
413 }
414 }
415}
416
417/// Whether `type_kind` is one of the value-buffer types R1/R2 bound:
418/// `string`, `pstring`, `string16`. `regex` and `search` render through a
419/// separate, unbounded path (R5); other types render `%s` via decimal or
420/// lossy-UTF-8 conversion and are far under the bound regardless.
421fn is_bounded_string_family(type_kind: &TypeKind) -> bool {
422 matches!(
423 type_kind,
424 TypeKind::String { .. } | TypeKind::String16 { .. } | TypeKind::PString { .. }
425 )
426}
427
428/// Bound `base` to at most `MAX_DESCRIPTION_FIELD_LEN` bytes, cutting at
429/// a UTF-8 character boundary at or before the limit (R3): a multi-byte
430/// character straddling the limit is dropped whole rather than split,
431/// which renders a few bytes shorter than `file`'s raw-byte cut -- an
432/// accepted parity tolerance.
433///
434/// When `stop_at_newline` is set (R2's gate -- an any-value rule, or an
435/// ordering-compared rule whose pattern's first byte is a null byte; see
436/// `evaluator::engine::value_eval::newline_stop_gate`), the bound
437/// additionally stops at the first `\r` or `\n` byte within the limit.
438// Indexing/slicing is invariant-safe: `cut` is clamped to `bytes.len()`
439// above and only ever walked downward to a valid char boundary before
440// either slice is taken.
441#[allow(clippy::indexing_slicing)]
442fn bound_description_field(base: &str, stop_at_newline: bool) -> String {
443 let mut cut = bounded_newline_cut(base.as_bytes(), stop_at_newline);
444 while cut > 0 && !base.is_char_boundary(cut) {
445 cut -= 1;
446 }
447 base[..cut].to_string()
448}
449
450/// Raw-byte counterpart of [`bound_description_field`] for `Value::Bytes`.
451///
452/// Operates on the file-derived byte slice directly, before any lossy
453/// UTF-8 decode, so the `MAX_DESCRIPTION_FIELD_LEN` budget is measured on
454/// file bytes rather than on decode-inflated `U+FFFD` bytes (see
455/// [`render_string_bounded`] for why that ordering matters).
456///
457/// After cutting to the byte/newline limit, walks back over any trailing
458/// UTF-8 continuation bytes (`0b10xxxxxx`) so a valid multi-byte character
459/// straddling the cut is dropped whole (R3) rather than left to decode as
460/// a stray replacement character. This is a lightweight structural check,
461/// not a full UTF-8 validity scan: arbitrary (possibly non-UTF-8) bytes
462/// are always safe to slice at any offset, and the later lossy decode
463/// handles whatever is left over.
464// Indexing/slicing is invariant-safe: `cut` is clamped to `bytes.len()`
465// above and only ever walked downward before either slice is taken;
466// `bytes.get(cut)` guards the boundary-walk read.
467#[allow(clippy::indexing_slicing)]
468fn bound_bytes(bytes: &[u8], stop_at_newline: bool) -> Vec<u8> {
469 let mut cut = bounded_newline_cut(bytes, stop_at_newline);
470 while cut > 0
471 && bytes
472 .get(cut)
473 .is_some_and(|&b| b & 0b1100_0000 == 0b1000_0000)
474 {
475 cut -= 1;
476 }
477 bytes[..cut].to_vec()
478}
479
480/// Render a [`Value`] for `%s`, applying the R1/R2 description bound when
481/// `apply_bound` is set ([`is_bounded_string_family`] gates this at the
482/// call site).
483///
484/// The R1 127-byte budget is measured on FILE-DERIVED bytes, before any
485/// lossy UTF-8 decode: for `Value::Bytes` the bound ([`bound_bytes`]) is
486/// applied to the raw byte slice first, and only the already-bounded
487/// result is decoded via lossy UTF-8. Measuring after decode would let a
488/// single invalid byte inflate the count -- `String::from_utf8_lossy`
489/// expands each invalid byte into the 3-byte `U+FFFD` replacement
490/// character, so bounding the decoded `String`'s byte length would burn
491/// the budget up to 3x too fast and cut file-derived content short. This
492/// is the high-byte UTF-8 corruption bug class documented in GOTCHAS.md.
493///
494/// `Value::String` is already valid UTF-8 carrying the file's byte count
495/// 1:1 (it comes from `read_string`/`read_string_exact`), so it is bounded
496/// directly via [`bound_description_field`]. Numeric values render far
497/// under the bound regardless and are never bound.
498fn render_string_bounded(value: &Value, apply_bound: bool, stop_at_newline: bool) -> String {
499 match value {
500 Value::String(s) if apply_bound => bound_description_field(s, stop_at_newline),
501 Value::String(s) => s.clone(),
502 Value::Bytes(b) if apply_bound => {
503 String::from_utf8_lossy(&bound_bytes(b, stop_at_newline)).into_owned()
504 }
505 Value::Bytes(b) => String::from_utf8_lossy(b).into_owned(),
506 Value::Uint(n) => n.to_string(),
507 Value::Int(n) => n.to_string(),
508 Value::Float(f) => f.to_string(),
509 }
510}
511
512/// Render a `%s` specifier: base string (bounded per R1/R2 for
513/// string-family types), then optional `.<precision>` truncation, then
514/// width padding.
515///
516/// The description bound and `.<precision>` truncation compose as a
517/// minimum: the bound is applied first, so a precision wider than the
518/// bound is a no-op past it, and a precision narrower than the bound still
519/// truncates further.
520///
521/// Precision truncates to at most `precision` characters. Truncation is
522/// **char-wise**, not byte-wise: C's `%.Ns` truncates by bytes, but our value
523/// is a Rust `String` and a byte-wise cut could split a multi-byte UTF-8
524/// sequence. For the ASCII version/name fields that use precision in the magic
525/// corpus (`%.3s`, `%-.4s`, `%.10s`) the two are identical; char-wise is the
526/// safe choice for the rare non-ASCII case.
527///
528/// Width padding (via [`pad_non_numeric`]) is applied after truncation so
529/// `%4.4s` and `%-.4s` render correctly -- previously `%s` dropped width
530/// entirely.
531fn render_str_spec(
532 spec: &Spec,
533 value: &Value,
534 type_kind: &TypeKind,
535 stop_at_newline: bool,
536) -> String {
537 let apply_bound = is_bounded_string_family(type_kind);
538 let bounded = render_string_bounded(value, apply_bound, stop_at_newline);
539 // Truncate to `p` chars in a single pass bounded by `p` (no preceding full
540 // `chars().count()` pass, and no byte slicing -- the repo forbids `&s[n..]`
541 // for UTF-8 safety). `take(p)` stops after at most `p` chars regardless of
542 // string length; when `p >= len` this collects an identical copy, which is
543 // a cheap, correct no-op for the rare precision case.
544 let truncated = match spec.precision {
545 Some(p) => bounded.chars().take(p).collect::<String>(),
546 None => bounded,
547 };
548 pad_non_numeric(&truncated, spec)
549}
550
551/// Coerce a numeric-ish [`Value`] to `i64`. Float values are truncated
552/// toward zero (documented intent -- matches C's `(long long)float`
553/// semantics that libmagic's `printf` path relies on). String/Bytes
554/// values have no sensible mapping and return `None`.
555#[allow(
556 clippy::cast_possible_truncation,
557 clippy::cast_sign_loss,
558 clippy::cast_possible_wrap
559)]
560fn coerce_to_i64(value: &Value) -> Option<i64> {
561 match value {
562 Value::Int(n) => Some(*n),
563 // u64 -> i64 bit-pattern reinterpret: matches C's implicit
564 // cast in `printf("%lld", (unsigned long long)...)`.
565 Value::Uint(n) => Some(*n as i64),
566 // f64 -> i64 truncation toward zero, matching C behavior for
567 // `printf("%d", (double)...)`.
568 Value::Float(f) => Some(*f as i64),
569 Value::String(_) | Value::Bytes(_) => None,
570 }
571}
572
573/// Coerce a numeric-ish [`Value`] to `u64`. Mirrors [`coerce_to_i64`]
574/// but preserves the unsigned bit pattern when the source is signed.
575#[allow(
576 clippy::cast_possible_truncation,
577 clippy::cast_sign_loss,
578 clippy::cast_precision_loss
579)]
580fn coerce_to_u64(value: &Value) -> Option<u64> {
581 match value {
582 Value::Uint(n) => Some(*n),
583 // i64 -> u64 bit-pattern reinterpret for rendering; parallels
584 // the `coerce_to_i64` case.
585 Value::Int(n) => Some(*n as u64),
586 Value::Float(f) => Some(*f as u64),
587 Value::String(_) | Value::Bytes(_) => None,
588 }
589}
590
591/// Coerce a numeric-ish [`Value`] to `u64`, masked to the natural bit
592/// width of `type_kind`. Used by hex/octal specifiers to avoid
593/// surprising sign-extended renderings like `byte = -1` rendering as
594/// `ffffffffffffffff` when the user expected `ff`.
595fn coerce_to_u64_masked(value: &Value, type_kind: &TypeKind) -> Option<u64> {
596 let raw = coerce_to_u64(value)?;
597 let mask = match type_kind.bit_width() {
598 Some(8) => 0xff_u64,
599 Some(16) => 0xffff_u64,
600 Some(32) => 0xffff_ffff_u64,
601 // 64-bit, unknown width, or any other case: no mask needed.
602 _ => return Some(raw),
603 };
604 Some(raw & mask)
605}
606
607/// Render a numeric body with an alt-form prefix (`0x` / `0o` / empty),
608/// applying width and padding correctly.
609///
610/// For zero-padded widths (`%#0Nx`), C printf inserts zeros *between*
611/// the prefix and the digits: `%#06x` + `0xab` -> `0x00ab`, not
612/// ` 0xab`. For space-padded widths (`%#Nx`), the spaces go *before*
613/// the prefix: `%#6x` + `0xab` -> ` 0xab`. For left-aligned widths
614/// (`%-#6x`), trailing spaces follow the digits: `0xab `.
615fn render_prefixed_int(digits: &str, prefix: &str, spec: &Spec) -> String {
616 // The effective body length for width comparison is prefix + digits.
617 let body_len = prefix.len() + digits.len();
618 if body_len >= spec.width {
619 return format!("{prefix}{digits}");
620 }
621 let pad = spec.width - body_len;
622 if spec.zero_pad && !spec.left_align {
623 // Zeros insert between the prefix and the digits.
624 let zeros: String = std::iter::repeat_n('0', pad).collect();
625 format!("{prefix}{zeros}{digits}")
626 } else if spec.left_align {
627 let spaces: String = std::iter::repeat_n(' ', pad).collect();
628 format!("{prefix}{digits}{spaces}")
629 } else {
630 let spaces: String = std::iter::repeat_n(' ', pad).collect();
631 format!("{spaces}{prefix}{digits}")
632 }
633}
634
635/// Apply width and alignment to a non-numeric rendered body using space-only padding.
636///
637/// Used for `%c` (and any other non-numeric conversion where the POSIX `0` flag
638/// must be ignored). Zero-padding is not applied regardless of `spec.zero_pad`.
639fn pad_non_numeric(body: &str, spec: &Spec) -> String {
640 if body.len() >= spec.width {
641 return body.to_string();
642 }
643 let pad = spec.width - body.len();
644 let padding: String = std::iter::repeat_n(' ', pad).collect();
645 if spec.left_align {
646 format!("{body}{padding}")
647 } else {
648 format!("{padding}{body}")
649 }
650}
651
652/// Apply width and padding to an already-rendered numeric body.
653///
654/// For zero-padded right-aligned formatting, a leading `-` sign is kept at
655/// the front while zeros are inserted between the sign and the magnitude
656/// digits -- matching C printf semantics (e.g., `%05d` with `-7` → `-0007`,
657/// not `000-7`).
658fn pad_numeric(body: &str, spec: &Spec) -> String {
659 if body.len() >= spec.width {
660 return body.to_string();
661 }
662 // C printf sign-aware zero-padding: sign goes before the zeros.
663 if spec.zero_pad
664 && !spec.left_align
665 && let Some(digits) = body.strip_prefix('-')
666 {
667 let needed = spec.width.saturating_sub(1 + digits.len());
668 if needed == 0 {
669 return body.to_string();
670 }
671 let zeros: String = std::iter::repeat_n('0', needed).collect();
672 return format!("-{zeros}{digits}");
673 }
674 let pad = spec.width - body.len();
675 let pad_char = if spec.zero_pad && !spec.left_align {
676 '0'
677 } else {
678 ' '
679 };
680 let padding: String = std::iter::repeat_n(pad_char, pad).collect();
681 if spec.left_align {
682 format!("{body}{padding}")
683 } else {
684 format!("{padding}{body}")
685 }
686}
687
688#[cfg(test)]
689mod tests;