Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@

- [#1007]: Remove error-prone implementations of `Hash`, `PartialOrd`, and `Ord` from all
events except `BytesText` and `BytesCData`, which was introduced in [#1005].
- [#1017]: Fix panic and wrong output when the indent character is not ASCII.
`Indentation` used a character count as a byte index into its indent buffer, and
`Serializer::indent` truncated its `char` argument with `as u8`.

### Misc Changes

Expand All @@ -35,6 +38,7 @@
[#1009]: https://github.com/tafia/quick-xml/pull/1009
[#1010]: https://github.com/tafia/quick-xml/pull/1010
[#1015]: https://github.com/tafia/quick-xml/pull/1015
[#1017]: https://github.com/tafia/quick-xml/pull/1017


## 0.42.0 -- 2026-08-22
Expand Down
2 changes: 1 addition & 1 deletion src/se/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -817,7 +817,7 @@ impl<'w, 'r, W: Write> Serializer<'w, 'r, W> {
/// );
/// ```
pub fn indent(&mut self, indent_char: char, indent_size: usize) -> &mut Self {
self.ser.indent = Indent::Owned(Indentation::new(indent_char as u8, indent_size));
self.ser.indent = Indent::Owned(Indentation::with_char(indent_char, indent_size));
self
}

Expand Down
21 changes: 16 additions & 5 deletions src/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -678,13 +678,16 @@ pub(crate) struct Indentation {
indent_size: usize,
/// Used as a cache for the string used for indentation
indents: String,
/// The current amount of indentation
/// The current amount of indentation, in bytes of `indents`
current_indent_len: usize,
}

impl Indentation {
pub fn new(indent_char: u8, indent_size: usize) -> Self {
let indent_char = char::from(indent_char);
Self::with_char(char::from(indent_char), indent_size)
}

pub fn with_char(indent_char: char, indent_size: usize) -> Self {
Self {
should_line_break: false,
indent_char,
Expand All @@ -694,15 +697,21 @@ impl Indentation {
}
}

/// Size of one indentation level in bytes
#[inline]
const fn level_len(&self) -> usize {
self.indent_size * self.indent_char.len_utf8()
}

/// Increase indentation by one level
pub fn grow(&mut self) {
self.current_indent_len += self.indent_size;
self.current_indent_len += self.level_len();
self.ensure(self.current_indent_len);
}

/// Decrease indentation by one level. Do nothing, if level already zero
pub fn shrink(&mut self) {
self.current_indent_len = self.current_indent_len.saturating_sub(self.indent_size);
self.current_indent_len = self.current_indent_len.saturating_sub(self.level_len());
}

/// Returns indent string for current level
Expand All @@ -711,8 +720,10 @@ impl Indentation {
}

/// Returns indent with current indent plus additional indent
///
/// `additional_indent` is counted in characters, not bytes.
pub fn additional(&mut self, additional_indent: usize) -> &str {
let new_len = self.current_indent_len + additional_indent;
let new_len = self.current_indent_len + additional_indent * self.indent_char.len_utf8();
self.ensure(new_len);
&self.indents[..new_len]
}
Expand Down
167 changes: 167 additions & 0 deletions tests/writer-indentation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,170 @@ mod in_attributes_multi {
);
}
}

/// `Writer::new_with_indent` converts the indent byte with `char::from`, so bytes
/// above 0x7F select a character that is two bytes long in UTF-8.
mod multi_byte_indent_char {
use super::*;
use pretty_assertions::assert_eq;

/// NO-BREAK SPACE is `char::from(0xA0)` and occupies two bytes in UTF-8.
const NBSP: char = '\u{a0}';

#[test]
fn nested() {
let mut buffer = Vec::new();
let mut writer = Writer::new_with_indent(&mut buffer, 0xA0, 2);

let start = BytesStart::new("paired");
let end = start.to_end().into_owned();

writer
.write_event(Event::Start(start.clone()))
.expect("write start 1 tag failed");
writer
.write_event(Event::Start(start))
.expect("write start 2 tag failed");
writer
.write_event(Event::Empty(BytesStart::new("inner")))
.expect("write inner tag failed");
writer
.write_event(Event::End(end.clone()))
.expect("write end tag 2 failed");
writer
.write_event(Event::End(end))
.expect("write end tag 1 failed");

let i = NBSP.to_string().repeat(2);
assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
format!(
"<paired>\n{i}<paired>\n{i}{i}<inner/>\n{i}</paired>\n</paired>",
i = i
)
);
}

#[test]
fn in_attributes() {
let mut buffer = Vec::new();
let mut writer = Writer::new_with_indent(&mut buffer, 0xA0, 2);

writer
.create_element("element")
.new_line()
.with_attribute(("first", "1"))
.write_empty()
.expect("write tag failed");

assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
format!("<element\n{i}first=\"1\"/>", i = NBSP.to_string().repeat(2))
);
}

/// An odd `indent_size` makes the byte length of one level an odd number,
/// which used to land in the middle of a two byte character and panic.
#[test]
fn odd_indent_size() {
let mut buffer = Vec::new();
let mut writer = Writer::new_with_indent(&mut buffer, 0xA0, 1);

let start = BytesStart::new("paired");
writer
.write_event(Event::Start(start.clone()))
.expect("write start tag failed");
writer
.write_event(Event::Empty(BytesStart::new("inner")))
.expect("write inner tag failed");
writer
.write_event(Event::End(start.to_end()))
.expect("write end tag failed");

assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
format!("<paired>\n{i}<inner/>\n</paired>", i = NBSP)
);
}

/// Attribute indent is added on top of the current element indent, so the
/// conversion must apply to the added level only, not to the sum.
#[test]
fn in_attributes_nested() {
let mut buffer = Vec::new();
let mut writer = Writer::new_with_indent(&mut buffer, 0xA0, 2);

let outer = BytesStart::new("outer");
writer
.write_event(Event::Start(outer.clone()))
.expect("write outer tag failed");
writer
.create_element("element")
.new_line()
.with_attribute(("first", "1"))
.write_empty()
.expect("write tag failed");
writer
.write_event(Event::End(outer.to_end()))
.expect("write end tag failed");

let i = NBSP.to_string();
assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
format!(
"<outer>\n{i2}<element\n{i4}first=\"1\"/>\n</outer>",
i2 = i.repeat(2),
i4 = i.repeat(4)
)
);
}

/// ASCII indents must keep working exactly as before.
#[test]
fn ascii_is_unchanged() {
let mut buffer = Vec::new();
let mut writer = Writer::new_with_indent(&mut buffer, b' ', 2);

let start = BytesStart::new("paired");
writer
.write_event(Event::Start(start.clone()))
.expect("write start tag failed");
writer
.write_event(Event::Empty(BytesStart::new("inner")))
.expect("write inner tag failed");
writer
.write_event(Event::End(start.to_end()))
.expect("write end tag failed");

assert_eq!(
std::str::from_utf8(&buffer).unwrap(),
"<paired>\n <inner/>\n</paired>"
);
}

/// `Serializer::indent` takes a `char`, so it must not be truncated to a byte.
#[cfg(feature = "serialize")]
#[test]
fn serializer_indent_char_is_not_truncated() {
use quick_xml::se::Serializer;
use serde::Serialize;

#[derive(Serialize)]
struct Response {
message: &'static str,
}

// IDEOGRAPHIC SPACE: three bytes in UTF-8, `as u8` truncates it to NUL.
let mut output = String::new();
let mut serializer = Serializer::with_root(&mut output, Some("response")).unwrap();
serializer.indent('\u{3000}', 2);
Response { message: "Success" }
.serialize(serializer)
.unwrap();

assert_eq!(
output,
"<response>\n\u{3000}\u{3000}<message>Success</message>\n</response>"
);
}
}
Loading