const MAX_SECTIONS: usize = 6;
const MAX_CHARS: usize = 6000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
pub heading: String,
pub body: String,
}
impl Section {
pub fn render(&self) -> String {
if self.heading.is_empty() {
self.body.clone()
} else if self.body.trim().is_empty() {
self.heading.clone()
} else {
format!("{}\n{}", self.heading, self.body)
}
}
fn words(&self) -> std::collections::HashSet<String> {
format!("{} {}", self.heading, self.body)
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_string)
.collect()
}
}
pub fn split_sections(content: &str) -> Vec<Section> {
let mut sections: Vec<Section> = Vec::new();
let mut heading = String::new();
let mut body = String::new();
let mut in_fence = false;
for line in content.lines() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
}
let is_heading = !in_fence && line.trim_start().starts_with('#');
if is_heading {
if !heading.is_empty() || !body.trim().is_empty() {
sections.push(Section {
heading: std::mem::take(&mut heading),
body: std::mem::take(&mut body),
});
}
heading = line.to_string();
} else {
body.push_str(line);
body.push('\n');
}
}
if !heading.is_empty() || !body.trim().is_empty() {
sections.push(Section { heading, body });
}
sections
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Matches {
pub sections: Vec<Section>,
pub omitted: usize,
}
impl Matches {
pub fn render(&self, file: &str, query: &str) -> String {
if self.sections.is_empty() {
return format!(
"No section of {file} matched \"{query}\". The file may use different wording; \
load it without a query to read it in full."
);
}
let mut out = format!(
"{} section(s) of {file} matched \"{query}\":\n\n",
self.sections.len()
);
out.push_str(
&self
.sections
.iter()
.map(Section::render)
.collect::<Vec<_>>()
.join("\n\n"),
);
if self.omitted > 0 {
out.push_str(&format!(
"\n\n[{} further matching section(s) omitted for length. Narrow the query, or \
load {file} without a query to read it in full.]",
self.omitted
));
}
out
}
}
pub fn find_sections(content: &str, query: &str) -> Matches {
find_sections_with(content, query, MAX_SECTIONS, MAX_CHARS, 1)
}
pub fn find_sections_with(
content: &str,
query: &str,
max_sections: usize,
max_chars: usize,
min_hits: usize,
) -> Matches {
let terms: Vec<String> = query
.split_whitespace()
.map(|t| {
t.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase()
})
.filter(|t| t.len() > 2)
.collect();
if terms.is_empty() {
return Matches {
sections: Vec::new(),
omitted: 0,
};
}
let mut scored: Vec<(usize, usize, Section)> = split_sections(content)
.into_iter()
.enumerate()
.filter_map(|(idx, section)| {
let hay = section.words();
let hits = terms.iter().filter(|t| hay.contains(t.as_str())).count();
(hits >= min_hits.max(1)).then_some((hits, idx, section))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
let total = scored.len();
let mut sections = Vec::new();
let mut chars = 0usize;
for (_, _, section) in scored {
if sections.len() >= max_sections {
break;
}
let len = section.render().chars().count();
if chars + len > max_chars && !sections.is_empty() {
break;
}
chars += len;
sections.push(section);
}
let omitted = total - sections.len();
Matches { sections, omitted }
}