1use std::collections::{BTreeMap, BTreeSet};
8
9use speccoder_core::snapshot::{Diagnostic, Severity};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Operation {
14 Added,
16 Modified,
18 Removed,
20 Renamed,
22}
23
24impl Operation {
25 #[must_use]
27 pub const fn name(self) -> &'static str {
28 match self {
29 Self::Added => "added",
30 Self::Modified => "modified",
31 Self::Removed => "removed",
32 Self::Renamed => "renamed",
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Scenario {
40 pub name: String,
42 pub given: Vec<String>,
44 pub when: Vec<String>,
46 pub then: Vec<String>,
48 pub raw: String,
50 pub line: u32,
52 pub range: [usize; 2],
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Requirement {
59 pub operation: Operation,
61 pub name: String,
63 pub text: String,
66 pub scenarios: Vec<Scenario>,
68 pub reason: bool,
70 pub migration: bool,
72 pub line: u32,
74 pub range: [usize; 2],
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Rename {
81 pub from: String,
83 pub to: String,
85 pub line: u32,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Delta {
92 pub capability: String,
94 pub path: String,
96 pub text: String,
98 pub purpose: bool,
100 pub requirements: Vec<Requirement>,
102 pub renames: Vec<Rename>,
104}
105
106#[must_use]
108pub fn normalise(name: &str) -> String {
109 name.split_whitespace()
110 .collect::<Vec<_>>()
111 .join(" ")
112 .to_lowercase()
113}
114
115fn line_no(n: usize) -> u32 {
116 u32::try_from(n + 1).unwrap_or(u32::MAX)
117}
118
119fn section(title: &str) -> Option<Operation> {
121 let lower = normalise(title);
122 let op = lower.strip_suffix(" requirements")?;
123 match op {
124 "added" => Some(Operation::Added),
125 "modified" => Some(Operation::Modified),
126 "removed" => Some(Operation::Removed),
127 "renamed" => Some(Operation::Renamed),
128 _ => None,
129 }
130}
131
132fn requirement_name(rest: &str) -> Option<&str> {
134 let head = rest.get(..12)?;
135 head.eq_ignore_ascii_case("requirement:")
136 .then(|| rest[12..].trim())
137}
138
139fn step(line: &str) -> Option<(String, String)> {
141 let item = line.trim_start().strip_prefix(['-', '*'])?.trim_start();
142 let item = item.strip_prefix("**").unwrap_or(item);
143 let word: String = item.chars().take_while(char::is_ascii_alphabetic).collect();
144 let kind = word.to_lowercase();
145 if !matches!(kind.as_str(), "given" | "when" | "then" | "and") {
146 return None;
147 }
148 let rest = item[word.len()..].trim_start_matches('*').trim();
149 Some((kind, rest.to_owned()))
150}
151
152fn rename_line(line: &str) -> Option<(&'static str, String)> {
154 let item = line
155 .trim_start()
156 .strip_prefix(['-', '*'])
157 .unwrap_or(line)
158 .trim();
159 let (key, rest) = item.split_once(':')?;
160 let key = match key.trim().trim_matches('*').to_lowercase().as_str() {
161 "from" => "from",
162 "to" => "to",
163 _ => return None,
164 };
165 let rest = rest.trim().trim_matches('`').trim();
166 let name = rest
167 .strip_prefix("###")
168 .map(str::trim)
169 .and_then(requirement_name)
170 .unwrap_or(rest);
171 Some((key, name.to_owned()))
172}
173
174#[must_use]
177pub fn parse(capability: &str, path: &str, text: &str) -> (Delta, Vec<Diagnostic>) {
178 let mut diags = Vec::new();
179 let mut lines = Vec::new();
180 let mut at = 0;
181 for l in text.split_inclusive('\n') {
182 lines.push((at, l.trim_end_matches(['\n', '\r'])));
183 at += l.len();
184 }
185 let mut delta = Delta {
186 capability: capability.to_owned(),
187 path: path.to_owned(),
188 text: text.to_owned(),
189 purpose: false,
190 requirements: Vec::new(),
191 renames: Vec::new(),
192 };
193 let mut op: Option<Operation> = None;
194 let mut pending_from: Option<(String, u32)> = None;
195 let mut req_start: Option<usize> = None;
197 let mut sc_start: Option<usize> = None;
198 let mut last_kind = String::new();
199
200 let close_scenario = |delta: &mut Delta, from: usize, to: usize| {
201 let (start, _) = lines[from];
202 let end = lines.get(to).map_or(text.len(), |(s, _)| *s);
203 if let Some(sc) = delta
204 .requirements
205 .last_mut()
206 .and_then(|r| r.scenarios.last_mut())
207 {
208 sc.raw = text[start..end].trim_end().to_owned();
209 sc.range = [start, start + sc.raw.len()];
210 }
211 };
212 let close_requirement = |delta: &mut Delta, from: usize, to: usize| {
213 let (start, _) = lines[from];
214 let end = lines.get(to).map_or(text.len(), |(s, _)| *s);
215 if let Some(r) = delta.requirements.last_mut() {
216 r.text = text[start..end].trim_end().to_owned();
217 r.range = [start, start + r.text.len()];
218 }
219 };
220
221 for (i, (_, line)) in lines.iter().enumerate() {
222 let hashes = line.bytes().take_while(|b| *b == b'#').count();
223 let heading = hashes > 0 && line[hashes..].starts_with(' ');
224 let rest = if heading { line[hashes..].trim() } else { "" };
225 if heading
226 && hashes <= 4
227 && let Some(s) = sc_start.take()
228 {
229 close_scenario(&mut delta, s, i);
230 }
231 if heading
232 && hashes <= 3
233 && let Some(s) = req_start.take()
234 {
235 close_requirement(&mut delta, s, i);
236 }
237 if heading && hashes <= 2 {
238 op = section(rest);
239 if normalise(rest) == "purpose" {
240 delta.purpose = true;
241 }
242 continue;
243 }
244 if heading && hashes == 3 {
245 if let Some(name) = requirement_name(rest) {
246 match op {
247 Some(Operation::Renamed) | None => {}
248 Some(operation) => {
249 delta.requirements.push(Requirement {
250 operation,
251 name: name.to_owned(),
252 text: String::new(),
253 scenarios: Vec::new(),
254 reason: false,
255 migration: false,
256 line: line_no(i),
257 range: [0, 0],
258 });
259 req_start = Some(i);
260 }
261 }
262 } else if rest.to_lowercase().starts_with("scenario:") {
263 diags.push(
264 Diagnostic::new(
265 Severity::Error,
266 "scenario_wrong_level",
267 "a scenario heading needs four hashes (`#### Scenario:`)",
268 )
269 .at(path)
270 .line(line_no(i))
271 .hint("write `#### Scenario: …`; OpenSpec ignores it otherwise"),
272 );
273 }
274 continue;
275 }
276 if heading && hashes == 4 {
277 if let Some(name) = rest.strip_prefix("Scenario:")
278 && let Some(r) = delta
279 .requirements
280 .last_mut()
281 .filter(|_| req_start.is_some())
282 {
283 r.scenarios.push(Scenario {
284 name: name.trim().to_owned(),
285 given: Vec::new(),
286 when: Vec::new(),
287 then: Vec::new(),
288 raw: String::new(),
289 line: line_no(i),
290 range: [0, 0],
291 });
292 sc_start = Some(i);
293 last_kind.clear();
294 }
295 continue;
296 }
297 if op == Some(Operation::Renamed) {
298 match rename_line(line) {
299 Some(("from", name)) => pending_from = Some((name, line_no(i))),
300 Some((_, to)) => {
301 if let Some((from, line)) = pending_from.take() {
302 delta.renames.push(Rename { from, to, line });
303 }
304 }
305 None => {}
306 }
307 continue;
308 }
309 if req_start.is_some()
310 && let Some(r) = delta.requirements.last_mut()
311 {
312 let lower = line.to_lowercase();
313 if lower.contains("**reason**") {
314 r.reason = true;
315 }
316 if lower.contains("**migration**") {
317 r.migration = true;
318 }
319 }
320 if sc_start.is_some()
321 && let Some((kind, text)) = step(line)
322 {
323 let kind = if kind == "and" {
324 last_kind.clone()
325 } else {
326 kind
327 };
328 if let Some(sc) = delta
329 .requirements
330 .last_mut()
331 .and_then(|r| r.scenarios.last_mut())
332 {
333 match kind.as_str() {
334 "given" => sc.given.push(text),
335 "when" => sc.when.push(text),
336 "then" => sc.then.push(text),
337 _ => {}
338 }
339 }
340 last_kind = kind;
341 }
342 }
343 if let Some(s) = sc_start {
344 close_scenario(&mut delta, s, lines.len());
345 }
346 if let Some(s) = req_start {
347 close_requirement(&mut delta, s, lines.len());
348 }
349 (delta, diags)
350}
351
352#[must_use]
355pub fn main_requirements(files: &[(String, String)]) -> BTreeMap<String, BTreeSet<String>> {
356 let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
357 for (capability, text) in files {
358 let names = out.entry(capability.clone()).or_default();
359 for line in text.lines() {
360 if let Some(rest) = line.strip_prefix("### ")
361 && let Some(name) = requirement_name(rest.trim())
362 {
363 names.insert(normalise(name));
364 }
365 }
366 }
367 out
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn steps_and_renames() {
376 assert_eq!(
377 step("- **GIVEN** `X=1`"),
378 Some(("given".to_owned(), "`X=1`".to_owned()))
379 );
380 assert_eq!(
381 step("- WHEN it runs"),
382 Some(("when".to_owned(), "it runs".to_owned()))
383 );
384 assert_eq!(step("- something else"), None);
385 assert_eq!(
386 rename_line("- FROM: `### Requirement: Greet a named person`"),
387 Some(("from", "Greet a named person".to_owned()))
388 );
389 assert_eq!(normalise(" Greet By name "), "greet by name");
390 }
391}