use std::borrow::Borrow;
use std::ops::Range;
#[derive(Debug)]
pub(super) struct Source {
lines: Vec<String>,
fenced: Vec<bool>,
pub front_matter: Option<usize>,
start: usize,
}
#[derive(Debug, Clone)]
struct Heading {
index: usize,
level: usize,
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Part {
pub name: String,
pub line: usize,
pub text: Range<usize>,
pub children: Vec<Self>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Requirement {
pub name: String,
pub line: usize,
pub text: Range<usize>,
pub children: Vec<Child>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Child {
pub scenario: Option<String>,
pub part: Part,
}
#[derive(Debug, Default)]
pub(super) struct Spec {
pub title: Option<(String, usize)>,
pub introduction: Option<Part>,
pub purpose: Option<Part>,
pub sections: Vec<(Part, bool)>,
pub stray: Vec<Part>,
pub requirements: Vec<Requirement>,
}
const DELTA: [&str; 4] = ["added", "modified", "removed", "renamed"];
impl Source {
pub fn new(text: &str) -> Self {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let lines: Vec<String> = text.split('\n').map(str::to_owned).collect();
let fenced = fence_mask(&lines);
let front_matter = (lines.first().map(|line| line.trim_end()) == Some("---"))
.then(|| {
lines
.iter()
.skip(1)
.position(|line| line.trim_end() == "---")
})
.flatten()
.map(|position| position + 2);
Self {
start: front_matter.unwrap_or(0),
lines,
fenced,
front_matter,
}
}
pub fn verbatim(&self, range: Range<usize>) -> Option<String> {
verbatim(&self.lines[range])
}
pub fn whole(&self) -> Range<usize> {
0..self.lines.len()
}
pub fn first_line(&self, range: Range<usize>) -> Option<usize> {
let start = range.start;
self.lines[range]
.iter()
.position(|line| !line.trim().is_empty())
.map(|position| start + position + 1)
}
pub fn lines(&self, range: Range<usize>) -> impl Iterator<Item = (&str, bool)> {
range.map(|index| (self.lines[index].as_str(), self.fenced[index]))
}
fn headings(&self) -> Vec<Heading> {
(self.start..self.lines.len())
.filter(|&index| !self.fenced[index])
.filter_map(|index| {
heading(&self.lines[index]).map(|(level, text)| Heading { index, level, text })
})
.collect()
}
pub fn read(&self) -> Spec {
let headings = self.headings();
let end = self.lines.len();
let mut spec = Spec::default();
let title = headings
.iter()
.find(|heading| heading.level <= 2)
.filter(|heading| heading.level == 1);
spec.title = title.map(|heading| (heading.text.clone(), heading.index + 1));
let top: Vec<&Heading> = headings
.iter()
.filter(|heading| heading.level <= 2)
.filter(|heading| title.is_none_or(|title| heading.index != title.index))
.collect();
let lead = title
.filter(|title| self.first_line(self.start..title.index).is_none())
.map_or(self.start, |title| title.index + 1);
let lead_end = top.first().map_or(end, |heading| heading.index);
let inner: Vec<Heading> = headings
.iter()
.filter(|heading| (lead..lead_end).contains(&heading.index))
.cloned()
.collect();
let text_end = inner.first().map_or(lead_end, |first| first.index);
spec.introduction = self.first_line(lead..lead_end).map(|line| Part {
name: "Introduction".to_owned(),
line,
text: lead..text_end,
children: tree(&inner, lead_end),
});
let mut requirements_seen = false;
for (position, heading) in top.iter().enumerate() {
let segment_end = top.get(position + 1).map_or(end, |next| next.index);
let inner = between(&headings, heading.index, segment_end);
let name = heading.text.to_lowercase();
if heading.level == 2 && name == "purpose" && spec.purpose.is_none() {
let text = heading.index + 1..segment_end;
spec.purpose = Some(part(heading, text, Vec::new()));
} else if heading.level == 2 && name == "requirements" && !requirements_seen {
requirements_seen = true;
self.requirements(&mut spec, heading.index + 1..segment_end, inner);
} else {
let delta = heading.level == 2 && is_delta(&name);
let text_end = inner.first().map_or(segment_end, |first| first.index);
let text = heading.index + 1..text_end;
spec.sections
.push((part(heading, text, tree(inner, segment_end)), delta));
}
}
spec
}
fn requirements(&self, spec: &mut Spec, range: Range<usize>, inner: &[Heading]) {
let threes: Vec<&Heading> = inner.iter().filter(|heading| heading.level <= 3).collect();
let preamble_end = threes.first().map_or(range.end, |first| first.index);
if let Some(line) = self.first_line(range.start..preamble_end) {
spec.stray.push(Part {
name: "Requirements".to_owned(),
line,
text: range.start..preamble_end,
children: Vec::new(),
});
}
for (position, heading) in threes.iter().enumerate() {
let block_end = threes
.get(position + 1)
.map_or(range.end, |next| next.index);
let nested = between(inner, heading.index, block_end);
let text_end = nested.first().map_or(block_end, |first| first.index);
if let Some(name) = requirement_name(&heading.text) {
spec.requirements
.push(requirement(name, heading, nested, block_end));
} else {
let children = tree(nested, block_end);
spec.stray
.push(part(heading, heading.index + 1..text_end, children));
}
}
}
}
fn between(headings: &[Heading], after: usize, before: usize) -> &[Heading] {
let start = headings.partition_point(|heading| heading.index <= after);
let end = headings.partition_point(|heading| heading.index < before);
&headings[start..end.max(start)]
}
pub(super) fn verbatim<S: Borrow<str>>(lines: &[S]) -> Option<String> {
let blank = |line: &S| line.borrow().trim().is_empty();
let first = lines.iter().position(|line| !blank(line))?;
let last = lines.iter().rposition(|line| !blank(line))?;
Some(lines[first..=last].join("\n"))
}
fn part(heading: &Heading, text: Range<usize>, children: Vec<Part>) -> Part {
Part {
name: heading.text.clone(),
line: heading.index + 1,
text,
children,
}
}
fn tree(headings: &[Heading], end: usize) -> Vec<Part> {
let mut parts = Vec::new();
let mut position = 0;
while let Some(heading) = headings.get(position) {
let inner_end = headings[position + 1..]
.iter()
.position(|next| next.level <= heading.level)
.map_or(headings.len(), |offset| position + 1 + offset);
let block_end = headings.get(inner_end).map_or(end, |next| next.index);
let text_end = headings
.get(position + 1)
.map_or(block_end, |next| next.index);
let children = tree(&headings[position + 1..inner_end], block_end);
parts.push(part(heading, heading.index + 1..text_end, children));
position = inner_end;
}
parts
}
fn requirement(name: String, heading: &Heading, nested: &[Heading], end: usize) -> Requirement {
let fours: Vec<&Heading> = nested.iter().filter(|other| other.level <= 4).collect();
let text_end = fours.first().map_or(end, |first| first.index);
let children = fours
.iter()
.enumerate()
.map(|(position, child)| {
let child_end = fours.get(position + 1).map_or(end, |next| next.index);
let scenario = scenario_name(&child.text);
let inner = between(nested, child.index, child_end);
let part = match (&scenario, inner.first()) {
(None, Some(first)) => {
part(child, child.index + 1..first.index, tree(inner, child_end))
}
_ => part(child, child.index + 1..child_end, Vec::new()),
};
Child { scenario, part }
})
.collect();
Requirement {
name,
line: heading.index + 1,
text: heading.index + 1..text_end,
children,
}
}
pub(crate) fn fence_mask(lines: &[String]) -> Vec<bool> {
let mut mask = vec![false; lines.len()];
let mut open: Option<(char, usize)> = None;
for (index, line) in lines.iter().enumerate() {
let run = fence_run(line);
match open {
None => {
if let Some((marker, length, _)) = run {
open = Some((marker, length));
mask[index] = true;
}
}
Some((marker, length)) => {
mask[index] = true;
if run.is_some_and(|(found, found_length, alone)| {
found == marker && found_length >= length && alone
}) {
open = None;
}
}
}
}
mask
}
fn fence_run(line: &str) -> Option<(char, usize, bool)> {
let trimmed = line.trim_start();
let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~')?;
let length = trimmed.chars().take_while(|c| *c == marker).count();
(length >= 3).then(|| {
let rest = &trimmed[length * marker.len_utf8()..];
(marker, length, rest.trim().is_empty())
})
}
pub(crate) fn heading_level(line: &str) -> Option<usize> {
let level = line.chars().take_while(|c| *c == '#').count();
((1..=6).contains(&level) && line[level..].starts_with(char::is_whitespace)).then_some(level)
}
fn heading(line: &str) -> Option<(usize, String)> {
let level = heading_level(line)?;
let text = without_closing_run(&line[level..]).trim().to_owned();
(!text.is_empty()).then_some((level, text))
}
pub(crate) fn without_closing_run(text: &str) -> &str {
let trimmed = text.trim_end_matches([' ', '\t']);
let without = trimmed.trim_end_matches('#');
if without.len() < trimmed.len() && without.ends_with([' ', '\t']) {
without
} else {
text
}
}
fn requirement_name(text: &str) -> Option<String> {
let rest = strip_prefix_ignoring_case(text, "requirement:")?;
let name = rest.trim();
(!name.is_empty()).then(|| name.to_owned())
}
fn scenario_name(text: &str) -> Option<String> {
strip_prefix_ignoring_case(text, "scenario:").map(|rest| rest.trim().to_owned())
}
fn strip_prefix_ignoring_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
text.get(..prefix.len())
.filter(|head| head.eq_ignore_ascii_case(prefix))
.map(|_| &text[prefix.len()..])
}
fn is_delta(name: &str) -> bool {
name.split_once(char::is_whitespace)
.is_some_and(|(operation, rest)| {
DELTA.contains(&operation) && rest.trim() == "requirements"
})
}