use std::fmt::Write;
use crate::types::{DocIndex, IndexItem, ItemKind, TraitImplInfo};
use super::{feature_gate_suffix, strip_markdown, trim_trailing_newlines};
const MAX_SHOWN: usize = 10;
pub(crate) fn render_ambiguous(index: &DocIndex, indices: &[usize], query: &str) -> String {
let items: Vec<&IndexItem> = indices.iter().map(|&i| index.get(i)).collect();
let count = items.len();
if count == 0 {
return String::new();
}
if count == 2 {
let kinds: Vec<ItemKind> = items.iter().map(|i| i.kind).collect();
let has_trait = kinds
.iter()
.any(|k| matches!(k, ItemKind::Trait | ItemKind::TraitAlias));
let has_macro = kinds
.iter()
.any(|k| matches!(k, ItemKind::Macro | ItemKind::ProcMacro));
if has_trait && has_macro {
return render_macro_trait_ambiguity(&items, query);
}
}
if count <= 5 {
render_few_matches(&items, query)
} else {
render_many_matches(&items, query)
}
}
pub(crate) fn render_impls_type(
item: &IndexItem,
trait_impls: &[TraitImplInfo],
trait_filter: Option<&str>,
) -> String {
let mut out = String::new();
let gate = feature_gate_suffix(item.feature_gate.as_ref());
let _ = writeln!(out, "{} {}{gate}", item.kind.short_name(), item.path);
out.push('\n');
out.push_str(&item.signature);
out.push('\n');
let filtered: Vec<&TraitImplInfo> = if let Some(filter) = trait_filter {
let filter_lower = filter.to_lowercase();
trait_impls
.iter()
.filter(|ti| ti.trait_path.to_lowercase().contains(&filter_lower))
.collect()
} else {
trait_impls.iter().collect()
};
if let Some(filter) = trait_filter {
if filtered.is_empty() {
out.push('\n');
let _ = write!(out, "No implementation of '{filter}' found.");
trim_trailing_newlines(&mut out);
return out;
}
}
if filtered.is_empty() {
out.push('\n');
out.push_str("No trait implementations.");
} else {
let mut sorted_impls = filtered;
sorted_impls.sort_by(|a, b| {
a.is_synthetic
.cmp(&b.is_synthetic)
.then_with(|| a.trait_path.cmp(&b.trait_path))
});
out.push('\n');
out.push_str("trait implementations:\n");
for ti in &sorted_impls {
if ti.is_synthetic {
let _ = writeln!(out, " impl {} (synthetic)", ti.trait_path);
} else {
let _ = writeln!(out, " impl {}", ti.trait_path);
}
}
}
trim_trailing_newlines(&mut out);
out
}
pub(crate) fn render_impls_trait(item: &IndexItem, implementors: &[String]) -> String {
let mut out = String::new();
let gate = feature_gate_suffix(item.feature_gate.as_ref());
let _ = writeln!(out, "trait {}{gate}", item.path);
out.push('\n');
out.push_str(&item.signature);
out.push('\n');
if implementors.is_empty() {
out.push('\n');
out.push_str("No known implementors.");
} else {
out.push('\n');
out.push_str("implementors:\n");
for imp in implementors {
let _ = writeln!(out, " {imp}");
}
}
trim_trailing_newlines(&mut out);
out
}
pub(crate) fn render_impls_other(item: &IndexItem) -> String {
format!(
"{} {} has no trait implementations.",
item.kind.short_name(),
item.path
)
}
pub(crate) fn render_source(
item: &IndexItem,
source_content: Option<&str>,
include_docs: bool,
) -> String {
let span = &item.span;
if span.file.is_empty() || (span.line_start == 0 && span.line_end == 0) {
return "// source not available (macro-generated or built-in)".to_string();
}
match source_content {
Some(content) => {
let mut out = String::new();
let kind_label = if item.kind == ItemKind::Module && !item.path.contains("::") {
"crate"
} else {
item.kind.short_name()
};
let gate = feature_gate_suffix(item.feature_gate.as_ref());
if span.line_start == span.line_end {
let _ = writeln!(
out,
"{kind_label} {}{gate} {}:{}",
item.path, span.file, span.line_start
);
} else {
let _ = writeln!(
out,
"{kind_label} {}{gate} {}:{}-{}",
item.path, span.file, span.line_start, span.line_end
);
}
if include_docs && !item.docs.is_empty() {
out.push('\n');
let stripped = strip_markdown(&item.docs);
for line in stripped.lines() {
out.push_str(line);
out.push('\n');
}
}
out.push_str(content);
trim_trailing_newlines(&mut out);
out
}
None => {
format!("// source not available (Could not read {})", span.file)
}
}
}
pub(crate) fn render_source_ambiguous(
items_with_source: &[(&IndexItem, Option<&str>)],
include_docs: bool,
) -> String {
let mut out = String::new();
for (i, (_, source)) in items_with_source.iter().enumerate() {
let item = items_with_source[i].0;
if i > 0 {
out.push('\n');
}
let _ = writeln!(out, "--- {} ---", item.path);
out.push_str(&render_source(item, *source, include_docs));
out.push('\n');
}
trim_trailing_newlines(&mut out);
out
}
fn render_macro_trait_ambiguity(items: &[&IndexItem], query: &str) -> String {
let mut out = String::new();
let _ = writeln!(out, "\"{query}\" matches 2 items:");
out.push('\n');
for item in items {
let kind = item.kind.short_name();
let name = &item.name;
let summary = &item.summary;
if summary.is_empty() {
let _ = writeln!(out, " {kind:<7} {name:<20}");
} else {
let _ = writeln!(out, " {kind:<7} {name:<20} {summary}");
}
}
out.push('\n');
out.push_str("Use --kind trait or --kind macro to select.");
out
}
fn render_few_matches(items: &[&IndexItem], query: &str) -> String {
let mut out = String::new();
let count = items.len();
let _ = writeln!(out, "Found {count} items matching \"{query}\":");
let mut primary_items: Vec<&IndexItem> = Vec::new();
let mut nested_items: Vec<&IndexItem> = Vec::new();
for &item in items {
let segment_count = item.path.split("::").count();
if segment_count >= 5 || item.kind == ItemKind::Variant {
nested_items.push(item);
} else {
primary_items.push(item);
}
}
for item in &primary_items {
out.push('\n');
let _ = writeln!(out, "--- {} ---", item.path);
if !item.signature.is_empty() {
let _ = writeln!(out, "{}", item.signature);
}
let first_para = extract_first_paragraph(&item.docs);
if !first_para.is_empty() {
let stripped = strip_markdown(&first_para);
let _ = writeln!(out, "{stripped}");
}
}
if !nested_items.is_empty() && !primary_items.is_empty() {
out.push('\n');
out.push_str("Also found in nested contexts:\n");
for item in &nested_items {
let kind = item.kind.short_name();
let path = &item.path;
let summary = &item.summary;
if summary.is_empty() {
let _ = writeln!(out, " {kind:<7} {path:<38}");
} else {
let _ = writeln!(out, " {kind:<7} {path:<38} {summary}");
}
}
} else if nested_items.is_empty() {
} else {
for item in &nested_items {
out.push('\n');
let _ = writeln!(out, "--- {} ---", item.path);
if !item.signature.is_empty() {
let _ = writeln!(out, "{}", item.signature);
}
let first_para = extract_first_paragraph(&item.docs);
if !first_para.is_empty() {
let stripped = strip_markdown(&first_para);
let _ = writeln!(out, "{stripped}");
}
}
}
trim_trailing_newlines(&mut out);
out
}
fn render_many_matches(items: &[&IndexItem], query: &str) -> String {
let mut out = String::new();
let total = items.len();
let _ = writeln!(out, "Found {total} items matching \"{query}\":");
out.push('\n');
let shown = total.min(MAX_SHOWN);
for item in items.iter().take(shown) {
let kind = item.kind.short_name();
let path = &item.path;
let summary = &item.summary;
if summary.is_empty() {
let _ = writeln!(out, "{kind:<7} {path:<38}");
} else {
let _ = writeln!(out, "{kind:<7} {path:<38} {summary}");
}
}
if total > MAX_SHOWN {
let remaining = total - MAX_SHOWN;
let _ = write!(out, "... ({remaining} more, use a more specific path)");
}
trim_trailing_newlines(&mut out);
out
}
fn extract_first_paragraph(docs: &str) -> String {
if docs.is_empty() {
return String::new();
}
if let Some(para) = take_until_blank_line(docs) {
return para;
}
if let Some(sentences) = take_first_sentences(docs, 3) {
return sentences;
}
truncate_at_boundary(docs, 200)
}
fn take_until_blank_line(docs: &str) -> Option<String> {
docs.find("\n\n").map(|pos| docs[..pos].to_string())
}
fn take_first_sentences(docs: &str, max: usize) -> Option<String> {
let bytes = docs.as_bytes();
let mut count = 0;
for i in 0..bytes.len().saturating_sub(1) {
if is_sentence_end(bytes[i]) && bytes[i + 1] == b' ' {
count += 1;
if count >= max {
return Some(docs[..=i].to_string());
}
}
}
if bytes.last().is_some_and(|&b| is_sentence_end(b)) {
count += 1;
if count <= max {
return Some(docs.to_string());
}
}
None
}
fn is_sentence_end(b: u8) -> bool {
b == b'.' || b == b'!' || b == b'?'
}
fn truncate_at_boundary(docs: &str, limit: usize) -> String {
if docs.len() <= limit {
return docs.to_string();
}
if let Some(nl) = docs.find('\n') {
if nl <= limit {
return docs[..nl].to_string();
}
}
let search = &docs[..limit];
if let Some(pos) = search.rfind(' ') {
format!("{}...", &docs[..pos])
} else {
format!("{}...", &docs[..limit])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::make_item_full;
use crate::types::{DocIndex, IndexItem, ItemKind, SourceSpan, TraitImplInfo};
fn with_span(mut item: IndexItem, file: &str, line_start: u32, line_end: u32) -> IndexItem {
item.span = SourceSpan {
file: file.to_string(),
line_start,
line_end,
};
item
}
#[test]
fn render_ambiguous_two_matches_brief() {
let mut index = DocIndex::new("mycrate".to_string(), "0.1.0".to_string());
let item1 = make_item_full(
"Error",
"mycrate::de::Error",
ItemKind::Trait,
"pub trait Error: Sized",
"When a Deserialize implementation encounters an error.",
"When a Deserialize implementation encounters an error.",
);
let item2 = make_item_full(
"Error",
"mycrate::ser::Error",
ItemKind::Trait,
"pub trait Error: Sized",
"When a Serialize implementation encounters an error.",
"When a Serialize implementation encounters an error.",
);
index.add_item(item1);
index.add_item(item2);
let output = render_ambiguous(&index, &[0, 1], "Error");
insta::assert_snapshot!(output);
}
#[test]
fn render_ambiguous_six_matches_condensed() {
let mut index = DocIndex::new("mycrate".to_string(), "0.1.0".to_string());
let items = [
(
"Error",
"mycrate::de::Error",
ItemKind::Struct,
"When deserialization encounters an error.",
),
(
"Error",
"mycrate::ser::Error",
ItemKind::Struct,
"When serialization encounters an error.",
),
(
"Error",
"mycrate::de::value::Error",
ItemKind::Struct,
"A minimal deserialization error.",
),
(
"Error",
"mycrate::json::Error",
ItemKind::Struct,
"An error that occurred during parsing.",
),
(
"classify",
"mycrate::json::Error::classify",
ItemKind::Function,
"Categorizes the cause of this error.",
),
(
"column",
"mycrate::json::Error::column",
ItemKind::Function,
"One-indexed column number of the error.",
),
(
"line",
"mycrate::json::Error::line",
ItemKind::Function,
"One-indexed line number of the error.",
),
];
for (name, path, kind, summary) in &items {
index.add_item(make_item_full(name, path, *kind, "", "", summary));
}
let indices: Vec<usize> = (0..7).collect();
let output = render_ambiguous(&index, &indices, "Error");
insta::assert_snapshot!(output);
}
#[test]
fn render_ambiguous_macro_trait_special() {
let mut index = DocIndex::new("mycrate".to_string(), "0.1.0".to_string());
let trait_item = make_item_full(
"Serialize",
"mycrate::Serialize",
ItemKind::Trait,
"pub trait Serialize",
"A data structure that can be serialized.",
"A data structure that can be serialized.",
);
let macro_item = make_item_full(
"Serialize",
"mycrate::Serialize",
ItemKind::Macro,
"#[derive(Serialize)]",
"Derive macro for the Serialize trait.",
"Derive macro for the Serialize trait.",
);
index.add_item(trait_item);
index.add_item(macro_item);
let output = render_ambiguous(&index, &[0, 1], "Serialize");
insta::assert_snapshot!(output);
}
#[test]
fn render_impls_type_non_synthetic_first_then_synthetic() {
let item = make_item_full(
"Mutex",
"mycrate::Mutex",
ItemKind::Struct,
"pub struct Mutex<T: ?Sized>",
"An asynchronous Mutex-like type.",
"An asynchronous Mutex-like type.",
);
let trait_impls = vec![
TraitImplInfo {
trait_path: "Send".to_string(),
is_synthetic: true,
},
TraitImplInfo {
trait_path: "Sync".to_string(),
is_synthetic: true,
},
TraitImplInfo {
trait_path: "Debug".to_string(),
is_synthetic: false,
},
TraitImplInfo {
trait_path: "Clone".to_string(),
is_synthetic: false,
},
TraitImplInfo {
trait_path: "Default".to_string(),
is_synthetic: false,
},
];
let output = render_impls_type(&item, &trait_impls, None);
insta::assert_snapshot!(output);
}
#[test]
fn render_impls_type_no_implementations() {
let item = make_item_full(
"Empty",
"mycrate::Empty",
ItemKind::Struct,
"pub struct Empty",
"",
"",
);
let output = render_impls_type(&item, &[], None);
insta::assert_snapshot!(output);
}
#[test]
fn render_impls_trait_with_implementors() {
let item = make_item_full(
"MyTrait",
"mycrate::MyTrait",
ItemKind::Trait,
"pub trait MyTrait",
"A trait.",
"A trait.",
);
let implementors = vec![
"mycrate::MyStruct".to_string(),
"mycrate::OtherStruct".to_string(),
];
let output = render_impls_trait(&item, &implementors);
insta::assert_snapshot!(output);
}
#[test]
fn render_impls_trait_no_implementors() {
let item = make_item_full(
"MyTrait",
"mycrate::MyTrait",
ItemKind::Trait,
"pub trait MyTrait",
"A trait.",
"A trait.",
);
let output = render_impls_trait(&item, &[]);
insta::assert_snapshot!(output);
}
#[test]
fn render_impls_type_filtered_shows_matching_only() {
let item = make_item_full(
"Mutex",
"mycrate::Mutex",
ItemKind::Struct,
"pub struct Mutex<T: ?Sized>",
"",
"",
);
let trait_impls = vec![
TraitImplInfo {
trait_path: "Debug".to_string(),
is_synthetic: false,
},
TraitImplInfo {
trait_path: "Clone".to_string(),
is_synthetic: false,
},
TraitImplInfo {
trait_path: "Send".to_string(),
is_synthetic: true,
},
];
let output = render_impls_type(&item, &trait_impls, Some("Clone"));
assert!(output.contains("Clone"), "should show Clone: {output}");
assert!(!output.contains("Debug"), "should not show Debug: {output}");
assert!(!output.contains("Send"), "should not show Send: {output}");
}
#[test]
fn render_impls_type_filter_case_insensitive() {
let item = make_item_full(
"Foo",
"mycrate::Foo",
ItemKind::Struct,
"pub struct Foo",
"",
"",
);
let trait_impls = vec![TraitImplInfo {
trait_path: "Debug".to_string(),
is_synthetic: false,
}];
let output = render_impls_type(&item, &trait_impls, Some("debug"));
assert!(output.contains("Debug"), "case-insensitive match: {output}");
}
#[test]
fn render_impls_type_filter_no_match() {
let item = make_item_full(
"Foo",
"mycrate::Foo",
ItemKind::Struct,
"pub struct Foo",
"",
"",
);
let trait_impls = vec![TraitImplInfo {
trait_path: "Debug".to_string(),
is_synthetic: false,
}];
let output = render_impls_type(&item, &trait_impls, Some("Clone"));
assert!(
output.contains("No implementation of 'Clone' found"),
"should show no-match message: {output}"
);
}
#[test]
fn render_impls_other_item() {
let item = make_item_full(
"utils",
"mycrate::utils",
ItemKind::Module,
"",
"Utility helpers.",
"Utility helpers.",
);
let output = render_impls_other(&item);
assert_eq!(output, "mod mycrate::utils has no trait implementations.");
}
#[test]
fn render_source_with_range() {
let item = with_span(
make_item_full(
"Mutex",
"mycrate::Mutex",
ItemKind::Struct,
"pub struct Mutex<T: ?Sized>",
"An asynchronous Mutex-like type.\n\nProvides mutual exclusion.",
"An asynchronous Mutex-like type.",
),
"src/sync/mutex.rs",
42,
147,
);
let source =
"pub struct Mutex<T: ?Sized> {\n s: semaphore::Semaphore,\n c: UnsafeCell<T>,\n}";
let output = render_source(&item, Some(source), false);
insta::assert_snapshot!(output);
}
#[test]
fn render_source_single_line() {
let item = with_span(
make_item_full(
"MAX_SIZE",
"mycrate::MAX_SIZE",
ItemKind::Constant,
"pub const MAX_SIZE: usize = 1024",
"",
"",
),
"src/lib.rs",
42,
42,
);
let source = "pub const MAX_SIZE: usize = 1024;";
let output = render_source(&item, Some(source), false);
insta::assert_snapshot!(output);
}
#[test]
fn render_source_unavailable() {
let item = make_item_full(
"macro_generated",
"mycrate::macro_generated",
ItemKind::Function,
"pub fn macro_generated()",
"",
"",
);
let output = render_source(&item, None, false);
assert_eq!(
output,
"// source not available (macro-generated or built-in)"
);
}
#[test]
fn render_source_file_not_found() {
let item = with_span(
make_item_full(
"missing",
"mycrate::missing",
ItemKind::Function,
"pub fn missing()",
"",
"",
),
"src/foo.rs",
1,
10,
);
let output = render_source(&item, None, false);
assert_eq!(
output,
"// source not available (Could not read src/foo.rs)"
);
}
#[test]
fn render_source_ambiguous_format() {
let item1 = with_span(
make_item_full(
"Mutex",
"mycrate::sync::Mutex",
ItemKind::Struct,
"pub struct Mutex<T>",
"A mutual exclusion primitive.",
"A mutual exclusion primitive.",
),
"src/sync/mutex.rs",
42,
100,
);
let item2 = with_span(
make_item_full(
"MutexGuard",
"mycrate::sync::MutexGuard",
ItemKind::Struct,
"pub struct MutexGuard<'a, T>",
"An RAII guard for a locked Mutex.",
"An RAII guard for a locked Mutex.",
),
"src/sync/mutex.rs",
150,
180,
);
let source1 = "pub struct Mutex<T> {\n inner: T,\n}";
let source2 = "pub struct MutexGuard<'a, T> {\n lock: &'a Mutex<T>,\n}";
let items_with_source: Vec<(&IndexItem, Option<&str>)> =
vec![(&item1, Some(source1)), (&item2, Some(source2))];
let output = render_source_ambiguous(&items_with_source, false);
insta::assert_snapshot!(output);
}
#[test]
fn extract_first_paragraph_blank_line() {
let docs = "First paragraph.\n\nSecond paragraph.";
assert_eq!(extract_first_paragraph(docs), "First paragraph.");
}
#[test]
fn extract_first_paragraph_no_blank_line_short() {
let docs = "A single short doc comment.";
assert_eq!(extract_first_paragraph(docs), "A single short doc comment.");
}
#[test]
fn extract_first_paragraph_long_text_truncated() {
let long = "word ".repeat(100); let result = extract_first_paragraph(&long);
assert!(
result.len() <= 210,
"result should be truncated: {}",
result.len()
);
assert!(result.ends_with("..."));
}
#[test]
fn take_until_blank_line_returns_first_paragraph() {
assert_eq!(
take_until_blank_line("Hello world.\n\nMore text."),
Some("Hello world.".to_string())
);
}
#[test]
fn take_until_blank_line_returns_none_when_no_blank() {
assert_eq!(take_until_blank_line("Single paragraph only."), None);
}
#[test]
fn take_first_sentences_returns_up_to_max() {
let text = "First. Second. Third. Fourth.";
assert_eq!(
take_first_sentences(text, 3),
Some("First. Second. Third.".to_string())
);
}
#[test]
fn take_first_sentences_returns_all_when_under_max() {
let text = "One sentence.";
assert_eq!(
take_first_sentences(text, 3),
Some("One sentence.".to_string())
);
}
#[test]
fn take_first_sentences_returns_none_when_no_sentences() {
assert_eq!(take_first_sentences("no punctuation here", 3), None);
}
#[test]
fn truncate_at_boundary_returns_short_text_unchanged() {
assert_eq!(truncate_at_boundary("short text", 200), "short text");
}
#[test]
fn truncate_at_boundary_breaks_at_newline_within_limit() {
let text = format!("First line\n{}", "x".repeat(50));
assert_eq!(truncate_at_boundary(&text, 50), "First line");
}
#[test]
fn truncate_at_boundary_breaks_at_word_with_ellipsis() {
let long = "word ".repeat(100);
let result = truncate_at_boundary(&long, 200);
assert!(result.ends_with("..."));
assert!(result.len() <= 204);
}
}