use std::borrow::Cow;
use crate::hover::Hover;
use crate::net_policy::sanitize_invisible;
use crate::position::Range;
use super::diagnostics::{
MAX_DIAGNOSTIC_NAME_CHARS, MAX_DIAGNOSTIC_PROSE_CHARS, MAX_VERSION_DIAGNOSTIC_CHARS,
};
use super::{escape_markdown, markdown_code_span, strip_markdown_unsafe_chars};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FieldKind {
Name,
Version,
Prose,
}
impl FieldKind {
const fn max_chars(self) -> usize {
match self {
Self::Name => MAX_DIAGNOSTIC_NAME_CHARS,
Self::Version => MAX_VERSION_DIAGNOSTIC_CHARS,
Self::Prose => MAX_DIAGNOSTIC_PROSE_CHARS,
}
}
const fn sweeps_invisible(self) -> bool {
match self {
Self::Name | Self::Version => true,
Self::Prose => false,
}
}
}
#[derive(Debug, Default)]
pub struct HoverMarkdown(String);
impl HoverMarkdown {
#[must_use]
pub fn new() -> Self {
Self(String::with_capacity(512))
}
pub fn push_static(&mut self, literal: &'static str) -> &mut Self {
self.0.push_str(literal);
self
}
pub fn push_text(&mut self, untrusted: &str, kind: FieldKind) -> &mut Self {
let swept = sweep_if(untrusted, kind);
let truncated = super::diagnostics::truncate_for_diagnostic(&swept, kind.max_chars());
self.0.push_str(&escape_markdown(&truncated));
self
}
pub fn push_code(&mut self, untrusted: &str, kind: FieldKind) -> &mut Self {
let swept = sweep_if(untrusted, kind);
let truncated = super::diagnostics::truncate_for_diagnostic(&swept, kind.max_chars());
self.0.push_str(&markdown_code_span(&truncated));
self
}
pub fn push_label(&mut self, untrusted: &str, kind: FieldKind) -> &mut Self {
self.0.push_str(&capped_escaped_label(untrusted, kind));
self
}
pub fn push_link(&mut self, label: &str, kind: FieldKind, url: &str) -> &mut Self {
use std::fmt::Write as _;
let capped_label = capped_escaped_label(label, kind);
let _ = write!(
self.0,
"[{capped_label}]({})",
strip_markdown_unsafe_chars(url)
);
self
}
pub(crate) fn push_trusted(&mut self, value: impl AsRef<str>) -> &mut Self {
self.0.push_str(value.as_ref());
self
}
pub fn push_number(&mut self, value: impl SafeNumber) -> &mut Self {
use std::fmt::Write as _;
let _ = write!(self.0, "{value}");
self
}
pub fn push_relative_age(&mut self, age_secs: u64) -> &mut Self {
self.0.push_str(&crate::format_relative_age(age_secs));
self
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn finish(self, range: Option<Range>) -> Hover {
Hover::new(self.0, range)
}
}
mod sealed {
pub trait Sealed {}
}
pub trait SafeNumber: sealed::Sealed + std::fmt::Display {}
macro_rules! impl_safe_number {
($($ty:ty),+ $(,)?) => {
$(
impl sealed::Sealed for $ty {}
impl SafeNumber for $ty {}
)+
};
}
impl_safe_number!(usize, u64, i64, f64);
impl std::fmt::Display for HoverMarkdown {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn capped_escaped_label(untrusted: &str, kind: FieldKind) -> Cow<'static, str> {
let escaped = escape_markdown(&sweep_if(untrusted, kind));
match super::diagnostics::truncate_for_diagnostic(&escaped, kind.max_chars()) {
Cow::Borrowed(_) => Cow::Owned(escaped),
Cow::Owned(truncated) => Cow::Owned(truncated),
}
}
fn sweep_if(untrusted: &str, kind: FieldKind) -> Cow<'_, str> {
if kind.sweeps_invisible() {
sanitize_invisible(untrusted)
} else {
Cow::Borrowed(untrusted)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn push_static_appends_verbatim() {
let mut markdown = HoverMarkdown::new();
markdown.push_static("# hello");
assert_eq!(markdown.as_str(), "# hello");
}
#[test]
fn push_text_truncates_raw_value_then_escapes() {
let long = "a-b.".repeat(200);
let mut markdown = HoverMarkdown::new();
markdown.push_text(&long, FieldKind::Prose);
let rendered = markdown.as_str();
assert!(rendered.contains('…'));
assert!(rendered.starts_with("a\\-b\\."));
}
#[test]
fn push_code_wraps_in_a_code_span_and_truncates() {
let long = "9".repeat(5000);
let mut markdown = HoverMarkdown::new();
markdown.push_code(&long, FieldKind::Version);
let rendered = markdown.as_str();
assert!(rendered.starts_with('`'));
assert!(rendered.contains('…'));
assert!(rendered.len() < long.len());
}
#[test]
fn push_label_bounds_the_rendered_escaped_length() {
let long = "-".repeat(200);
let mut markdown = HoverMarkdown::new();
markdown.push_label(&long, FieldKind::Name);
let rendered = markdown.as_str();
assert!(
rendered.chars().count() <= FieldKind::Name.max_chars() + 1,
"rendered label must be bounded by the cap (plus the ellipsis marker); got {} chars",
rendered.chars().count()
);
}
#[test]
fn push_code_boundary_at_and_over_cap_for_every_field_kind() {
for kind in [FieldKind::Name, FieldKind::Version, FieldKind::Prose] {
let cap = kind.max_chars();
let at_cap = "9".repeat(cap);
let mut markdown = HoverMarkdown::new();
markdown.push_code(&at_cap, kind);
assert_eq!(
markdown.as_str(),
format!("`{at_cap}`"),
"kind={kind:?}: a value exactly at the cap must render whole"
);
let under_cap = "9".repeat(cap - 1);
let mut markdown = HoverMarkdown::new();
markdown.push_code(&under_cap, kind);
assert_eq!(markdown.as_str(), format!("`{under_cap}`"), "kind={kind:?}");
let over_cap = "9".repeat(cap + 1);
let mut markdown = HoverMarkdown::new();
markdown.push_code(&over_cap, kind);
assert_eq!(
markdown.as_str(),
format!("`{}…`", "9".repeat(cap)),
"kind={kind:?}: a value one over the cap must truncate to exactly `cap` \
chars plus the ellipsis marker"
);
}
}
#[test]
fn push_text_boundary_at_and_over_cap_for_every_field_kind() {
for kind in [FieldKind::Name, FieldKind::Version, FieldKind::Prose] {
let cap = kind.max_chars();
let at_cap = "9".repeat(cap);
let mut markdown = HoverMarkdown::new();
markdown.push_text(&at_cap, kind);
assert_eq!(markdown.as_str(), at_cap, "kind={kind:?}");
let over_cap = "9".repeat(cap + 1);
let mut markdown = HoverMarkdown::new();
markdown.push_text(&over_cap, kind);
assert_eq!(
markdown.as_str(),
format!("{}…", "9".repeat(cap)),
"kind={kind:?}"
);
}
}
#[test]
fn push_label_stays_whole_when_escaped_length_is_exactly_at_the_cap() {
let half = FieldKind::Name.max_chars() / 2;
let at_cap = "-".repeat(half);
let mut markdown = HoverMarkdown::new();
markdown.push_label(&at_cap, FieldKind::Name);
let rendered = markdown.as_str();
assert_eq!(rendered.chars().count(), FieldKind::Name.max_chars());
assert!(!rendered.contains('…'));
}
#[test]
fn push_link_caps_label_but_not_url() {
let long_url = format!("https://example.com/{}", "a".repeat(5000));
let mut markdown = HoverMarkdown::new();
markdown.push_link("serde", FieldKind::Name, &long_url);
let rendered = markdown.as_str();
assert!(rendered.starts_with("[serde]("));
assert!(
rendered.contains(&long_url),
"url must not be truncated; got: {rendered}"
);
}
#[test]
fn push_code_strips_sanitize_invisible_only_codepoints_for_name_and_version_kinds() {
for kind in [FieldKind::Name, FieldKind::Version] {
let mut markdown = HoverMarkdown::new();
markdown.push_code(&format!("1.0{}0", '\u{0600}'), kind);
assert!(
!markdown.as_str().contains('\u{0600}'),
"kind={kind:?}: U+0600 must be stripped; got: {markdown}"
);
}
}
#[test]
fn push_text_does_not_sweep_prose_preserving_zwj_and_rtl_marks_and_u0600() {
let zwj_emoji = "👨\u{200d}👩\u{200d}👧"; let mut markdown = HoverMarkdown::new();
markdown.push_text(zwj_emoji, FieldKind::Prose);
assert_eq!(
markdown.as_str(),
zwj_emoji,
"a ZWJ-joined emoji sequence must survive Prose unchanged"
);
let rtl_mark = format!("note{}: right-to-left", '\u{200f}');
let mut markdown = HoverMarkdown::new();
markdown.push_text(&rtl_mark, FieldKind::Prose);
assert!(
markdown.as_str().contains('\u{200f}'),
"an RTL mark must survive Prose unchanged; got: {markdown}"
);
let mut markdown = HoverMarkdown::new();
markdown.push_text(&format!("note{}", '\u{0600}'), FieldKind::Prose);
assert!(
markdown.as_str().contains('\u{0600}'),
"U+0600 survives in Prose by design (weaker sanitization tier); got: {markdown}"
);
}
#[test]
fn push_trusted_appends_verbatim_with_no_cap() {
let long = "9".repeat(5000);
let mut markdown = HoverMarkdown::new();
markdown.push_trusted(&long);
assert_eq!(markdown.as_str(), long);
}
#[test]
fn push_number_appends_display_output_verbatim() {
let mut markdown = HoverMarkdown::new();
markdown.push_number(42_usize);
assert_eq!(markdown.as_str(), "42");
let mut markdown = HoverMarkdown::new();
markdown.push_number(8.5_f64);
assert_eq!(markdown.as_str(), "8.5");
}
#[test]
fn push_relative_age_matches_format_relative_age() {
let mut markdown = HoverMarkdown::new();
markdown.push_relative_age(3600);
assert_eq!(markdown.as_str(), crate::format_relative_age(3600));
}
#[test]
fn finish_hands_off_to_hover_and_still_sanitizes_bidi_overrides() {
let mut markdown = HoverMarkdown::new();
markdown.push_static("safe");
markdown.push_trusted(format!("{}text", '\u{202e}'));
let hover = markdown.finish(None);
assert!(!hover.markdown().contains('\u{202e}'));
}
}