Skip to content

fix: Split out Message from Title #243

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jul 3, 2025
Merged
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
2 changes: 1 addition & 1 deletion examples/elide_header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def foobar(door, bar={}):
.fold(false)
.annotation(AnnotationKind::Primary.span(56..58).label("B006")),
)
.element(Level::HELP.title("Replace with `None`; initialize within function"))];
.element(Level::HELP.message("Replace with `None`; initialize within function"))];

let renderer = Renderer::styled();
anstream::println!("{}", renderer.render(message));
Expand Down
4 changes: 2 additions & 2 deletions examples/highlight_title.rs → examples/highlight_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ fn main() {
let magenta = annotate_snippets::renderer::AnsiColor::Magenta
.on_default()
.effects(Effects::BOLD);
let title = format!(
let message = format!(
"expected fn pointer `{}for<'a>{} fn(Box<{}(dyn Any + Send + 'a){}>) -> Pin<_>`
found fn item `fn(Box<{}(dyn Any + Send + 'static){}>) -> Pin<_> {}{{wrapped_fn}}{}`",
magenta.render(),
Expand Down Expand Up @@ -57,7 +57,7 @@ fn main() {
.label("arguments to this function are incorrect"),
),
)
.element(Level::NOTE.pre_styled_title(&title)),
.element(Level::NOTE.message(&message)),
Group::with_title(Level::NOTE.title("function defined here")).element(
Snippet::source(source)
.path("$DIR/highlighting.rs")
Expand Down
File renamed without changes
21 changes: 12 additions & 9 deletions src/level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use crate::renderer::stylesheet::Stylesheet;
use crate::snippet::{ERROR_TXT, HELP_TXT, INFO_TXT, NOTE_TXT, WARNING_TXT};
use crate::{OptionCow, Title};
use crate::{Message, OptionCow, Title};
use anstyle::Style;
use std::borrow::Cow;

Expand Down Expand Up @@ -73,22 +73,27 @@ impl<'a> Level<'a> {
}

impl<'a> Level<'a> {
/// A text [`Element`][crate::Element] to start a [`Group`][crate::Group]
///
/// See [`Group::with_title`][crate::Group::with_title]
///
/// <div class="warning">
///
/// Text passed to this function is considered "untrusted input", as such
/// all text is passed through a normalization function. Pre-styled text is
/// not allowed to be passed to this function.
///
/// </div>
pub fn title(self, title: impl Into<Cow<'a, str>>) -> Title<'a> {
pub fn title(self, text: impl Into<Cow<'a, str>>) -> Title<'a> {
Title {
level: self,
id: None,
title: title.into(),
is_pre_styled: false,
text: text.into(),
}
}

/// A text [`Element`][crate::Element] in a [`Group`][crate::Group]
///
/// <div class="warning">
///
/// Text passed to this function is allowed to be pre-styled, as such all
Expand All @@ -97,12 +102,10 @@ impl<'a> Level<'a> {
/// used to normalize untrusted text before it is passed to this function.
///
/// </div>
pub fn pre_styled_title(self, title: impl Into<Cow<'a, str>>) -> Title<'a> {
Title {
pub fn message(self, text: impl Into<Cow<'a, str>>) -> Message<'a> {
Message {
level: self,
id: None,
title: title.into(),
is_pre_styled: true,
text: text.into(),
}
}

Expand Down
99 changes: 85 additions & 14 deletions src/renderer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ use crate::renderer::source_map::{
};
use crate::renderer::styled_buffer::StyledBuffer;
use crate::snippet::Id;
use crate::{Annotation, AnnotationKind, Element, Group, Origin, Patch, Snippet, Title};
use crate::{Annotation, AnnotationKind, Element, Group, Message, Origin, Patch, Snippet, Title};
pub use anstyle::*;
use margin::Margin;
use std::borrow::Cow;
Expand Down Expand Up @@ -303,7 +303,20 @@ impl Renderer {
title,
max_line_num_len,
title_style,
matches!(peek, Some(Element::Title(_))),
matches!(peek, Some(Element::Title(_) | Element::Message(_))),
buffer_msg_line_offset,
);
last_was_suggestion = false;
}
Element::Message(title) => {
let title_style = TitleStyle::Secondary;
let buffer_msg_line_offset = buffer.num_lines();
self.render_title(
&mut buffer,
title,
max_line_num_len,
title_style,
matches!(peek, Some(Element::Title(_) | Element::Message(_))),
buffer_msg_line_offset,
);
last_was_suggestion = false;
Expand Down Expand Up @@ -336,6 +349,16 @@ impl Renderer {
);
}

Some(Element::Message(level))
if level.level.name != Some(None) =>
{
self.draw_col_separator_no_space(
&mut buffer,
current_line,
max_line_num_len + 1,
);
}

None if group_len > 1 => self.draw_col_separator_end(
&mut buffer,
current_line,
Expand Down Expand Up @@ -384,7 +407,8 @@ impl Renderer {
if g == 0
&& (matches!(section, Element::Origin(_))
|| (matches!(section, Element::Title(_)) && i == 0)
|| matches!(section, Element::Title(level) if level.level.name == Some(None)))
|| matches!(section, Element::Title(level) if level.level.name == Some(None))
|| matches!(section, Element::Message(level) if level.level.name == Some(None)))
{
let current_line = buffer.num_lines();
if peek.is_none() && group_len > 1 {
Expand All @@ -394,6 +418,13 @@ impl Renderer {
max_line_num_len + 1,
);
} else if matches!(peek, Some(Element::Title(level)) if level.level.name != Some(None))
{
self.draw_col_separator_no_space(
&mut buffer,
current_line,
max_line_num_len + 1,
);
} else if matches!(peek, Some(Element::Message(level)) if level.level.name != Some(None))
{
self.draw_col_separator_no_space(
&mut buffer,
Expand Down Expand Up @@ -503,23 +534,23 @@ impl Renderer {
fn render_title(
&self,
buffer: &mut StyledBuffer,
title: &Title<'_>,
title: &dyn MessageOrTitle,
max_line_num_len: usize,
title_style: TitleStyle,
is_cont: bool,
buffer_msg_line_offset: usize,
) {
let (label_style, title_element_style) = match title_style {
TitleStyle::MainHeader => (
ElementStyle::Level(title.level.level),
ElementStyle::Level(title.level().level),
if self.short_message {
ElementStyle::NoStyle
} else {
ElementStyle::MainHeaderMsg
},
),
TitleStyle::Header => (
ElementStyle::Level(title.level.level),
ElementStyle::Level(title.level().level),
ElementStyle::HeaderMsg,
),
TitleStyle::Secondary => {
Expand All @@ -538,10 +569,10 @@ impl Renderer {
};
let mut label_width = 0;

if title.level.name != Some(None) {
buffer.append(buffer_msg_line_offset, title.level.as_str(), label_style);
label_width += title.level.as_str().len();
if let Some(Id { id: Some(id), url }) = &title.id {
if title.level().name != Some(None) {
buffer.append(buffer_msg_line_offset, title.level().as_str(), label_style);
label_width += title.level().as_str().len();
if let Some(Id { id: Some(id), url }) = &title.id() {
buffer.append(buffer_msg_line_offset, "[", label_style);
if let Some(url) = url.as_ref() {
buffer.append(
Expand Down Expand Up @@ -584,10 +615,10 @@ impl Renderer {
label_width
});

let (title_str, style) = if title.is_pre_styled {
(title.title.to_string(), ElementStyle::NoStyle)
let (title_str, style) = if title.is_pre_styled() {
(title.text().to_owned(), ElementStyle::NoStyle)
} else {
(normalize_whitespace(&title.title), title_element_style)
(normalize_whitespace(title.text()), title_element_style)
};
for (i, text) in title_str.lines().enumerate() {
if i != 0 {
Expand Down Expand Up @@ -2532,6 +2563,43 @@ impl Renderer {
}
}

trait MessageOrTitle {
fn level(&self) -> &Level<'_>;
fn id(&self) -> Option<&Id<'_>>;
fn text(&self) -> &str;
fn is_pre_styled(&self) -> bool;
}

impl MessageOrTitle for Title<'_> {
fn level(&self) -> &Level<'_> {
&self.level
}
fn id(&self) -> Option<&Id<'_>> {
self.id.as_ref()
}
fn text(&self) -> &str {
self.text.as_ref()
}
fn is_pre_styled(&self) -> bool {
false
}
}

impl MessageOrTitle for Message<'_> {
fn level(&self) -> &Level<'_> {
&self.level
}
fn id(&self) -> Option<&Id<'_>> {
None
}
fn text(&self) -> &str {
self.text.as_ref()
}
fn is_pre_styled(&self) -> bool {
true
}
}

// instead of taking the String length or dividing by 10 while > 0, we multiply a limit by 10 until
// we're higher. If the loop isn't exited by the `return`, the last multiplication will wrap, which
// is OK, because while we cannot fit a higher power of 10 in a usize, the loop will end anyway.
Expand Down Expand Up @@ -2846,7 +2914,10 @@ fn max_line_number(groups: &[Group<'_>]) -> usize {
v.elements
.iter()
.map(|s| match s {
Element::Title(_) | Element::Origin(_) | Element::Padding(_) => 0,
Element::Title(_)
| Element::Message(_)
| Element::Origin(_)
| Element::Padding(_) => 0,
Element::Cause(cause) => {
let end = cause
.markers
Expand Down
21 changes: 18 additions & 3 deletions src/snippet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ impl<'a> Group<'a> {
#[non_exhaustive]
pub enum Element<'a> {
Title(Title<'a>),
Message(Message<'a>),
Cause(Snippet<'a, Annotation<'a>>),
Suggestion(Snippet<'a, Patch<'a>>),
Origin(Origin<'a>),
Expand All @@ -75,6 +76,12 @@ impl<'a> From<Title<'a>> for Element<'a> {
}
}

impl<'a> From<Message<'a>> for Element<'a> {
fn from(value: Message<'a>) -> Self {
Element::Message(value)
}
}

impl<'a> From<Snippet<'a, Annotation<'a>>> for Element<'a> {
fn from(value: Snippet<'a, Annotation<'a>>) -> Self {
Element::Cause(value)
Expand Down Expand Up @@ -103,15 +110,14 @@ impl From<Padding> for Element<'_> {
#[derive(Clone, Debug)]
pub struct Padding;

/// A text [`Element`] in a [`Group`]
/// A text [`Element`] to start a [`Group`]
///
/// See [`Level::title`] to create this.
#[derive(Clone, Debug)]
pub struct Title<'a> {
pub(crate) level: Level<'a>,
pub(crate) id: Option<Id<'a>>,
pub(crate) title: Cow<'a, str>,
pub(crate) is_pre_styled: bool,
pub(crate) text: Cow<'a, str>,
}

impl<'a> Title<'a> {
Expand Down Expand Up @@ -144,6 +150,15 @@ impl<'a> Title<'a> {
}
}

/// A text [`Element`] in a [`Group`]
///
/// See [`Level::message`] to create this.
#[derive(Clone, Debug)]
pub struct Message<'a> {
pub(crate) level: Level<'a>,
pub(crate) text: Cow<'a, str>,
}

/// A source view [`Element`] in a [`Group`]
///
/// If you do not have [source][Snippet::source] available, see instead [`Origin`]
Expand Down
6 changes: 3 additions & 3 deletions tests/examples.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ fn highlight_source() {
}

#[test]
fn highlight_title() {
let target = "highlight_title";
let expected = snapbox::file!["../examples/highlight_title.svg": TermSvg];
fn highlight_message() {
let target = "highlight_message";
let expected = snapbox::file!["../examples/highlight_message.svg": TermSvg];
assert_example(target, expected);
}

Expand Down
8 changes: 5 additions & 3 deletions tests/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ error:
#[test]
fn test_format_footer_title() {
let input = &[Group::with_title(Level::ERROR.title(""))
.element(Level::ERROR.title("This __is__ a title"))];
.element(Level::ERROR.message("This __is__ a title"))];
let expected = str![[r#"
error:
|
Expand Down Expand Up @@ -2258,7 +2258,9 @@ fn main() {
.label("`+` cannot be used to concatenate two `&str` strings"),
),
)
.element(Level::NOTE.title("string concatenation requires an owned `String` on the left")),
.element(
Level::NOTE.message("string concatenation requires an owned `String` on the left"),
),
Group::with_title(Level::HELP.title("create an owned `String` from a string reference"))
.element(
Snippet::source(source)
Expand Down Expand Up @@ -2333,7 +2335,7 @@ fn foo() {

.annotation(AnnotationKind::Primary.span(0..0)),
)
.element(Level::NOTE.title("this error originates in the macro `include` (in Nightly builds, run with -Z macro-backtrace for more info)")),
.element(Level::NOTE.message("this error originates in the macro `include` (in Nightly builds, run with -Z macro-backtrace for more info)")),
];

let expected_ascii = str![[r#"
Expand Down
12 changes: 6 additions & 6 deletions tests/rustc_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1659,8 +1659,8 @@ fn main() {}
.annotation(AnnotationKind::Context.span(878..880).label("not covered"))
.annotation(AnnotationKind::Context.span(890..892).label("not covered"))
)
.element(Level::NOTE.title("the matched value is of type `NonEmptyEnum5`"))
.element(Level::NOTE.title("match arms with guards don't count towards exhaustivity")
.element(Level::NOTE.message("the matched value is of type `NonEmptyEnum5`"))
.element(Level::NOTE.message("match arms with guards don't count towards exhaustivity")
),
Group::with_title(
Level::HELP
Expand Down Expand Up @@ -1749,7 +1749,7 @@ fn main() {
.primary(true)
)
.element(Padding)
.element(Level::NOTE.title("...because it uses `Self` as a type parameter"))
.element(Level::NOTE.message("...because it uses `Self` as a type parameter"))
.element(
Snippet::source(source)
.line_start(1)
Expand Down Expand Up @@ -2795,9 +2795,9 @@ fn main() {
.path("lint_example.rs")
.annotation(AnnotationKind::Primary.span(40..49)),
)
.element(Level::WARNING.title("this changes meaning in Rust 2021"))
.element(Level::NOTE.title(long_title2))
.element(Level::NOTE.title("`#[warn(array_into_iter)]` on by default")),
.element(Level::WARNING.message("this changes meaning in Rust 2021"))
.element(Level::NOTE.message(long_title2))
.element(Level::NOTE.message("`#[warn(array_into_iter)]` on by default")),
Group::with_title(
Level::HELP.title("use `.iter()` instead of `.into_iter()` to avoid ambiguity"),
)
Expand Down
Loading