Skip to main content

nbspec/
grammar.rs

1//! OpenSpec grammar parsing: requirement, scenario, and delta structures.
2//!
3//! Follows the OpenSpec 1.x parser rules (upstream `requirement-blocks.ts`
4//! and `markdown-parser.ts`): requirement headers `### Requirement: <name>`,
5//! scenario headers `#### Scenario: <name>`, and delta sections
6//! `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`, all matched
7//! case-insensitively. Parsed items carry 1-indexed line numbers within the
8//! source text to support note-level diagnostics.
9
10/// A parsed `#### Scenario:` block within a requirement.
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Scenario {
13    /// Scenario name from the header line.
14    pub name: String,
15    /// Content lines following the header, up to the next header.
16    pub body: String,
17    /// 1-indexed line number of the scenario header.
18    pub line: usize,
19}
20
21/// A parsed `### Requirement:` block.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Requirement {
24    /// Requirement name from the header line.
25    pub name: String,
26    /// Normative text: the first non-empty line before any nested header.
27    pub text: Option<String>,
28    /// Scenario blocks nested under the requirement.
29    pub scenarios: Vec<Scenario>,
30    /// Full block including the header line, trailing whitespace trimmed.
31    pub raw: String,
32    /// 1-indexed line number of the requirement header.
33    pub line: usize,
34}
35
36/// A `FROM:`/`TO:` pair from a `## RENAMED Requirements` section.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Rename {
39    /// Previous requirement name.
40    pub from: String,
41    /// New requirement name.
42    pub to: String,
43}
44
45/// Presence of each delta section, independent of section content.
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub struct SectionPresence {
48    pub added: bool,
49    pub modified: bool,
50    pub removed: bool,
51    pub renamed: bool,
52}
53
54/// A parsed delta specification (the content of one delta-spec note).
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct DeltaSpecification {
57    /// Requirements under `## ADDED Requirements`.
58    pub added: Vec<Requirement>,
59    /// Requirements under `## MODIFIED Requirements`.
60    pub modified: Vec<Requirement>,
61    /// Requirement names under `## REMOVED Requirements`.
62    pub removed: Vec<String>,
63    /// Rename pairs under `## RENAMED Requirements`.
64    pub renamed: Vec<Rename>,
65    /// Which delta sections appear in the source.
66    pub presence: SectionPresence,
67}
68
69/// Parses a delta specification from note content.
70///
71/// Unrecognized lines are skipped rather than rejected, matching the
72/// upstream parser; structural validation is a separate concern.
73pub fn parse_delta_specification(content: &str) -> DeltaSpecification {
74    let normalized = normalize_line_endings(content);
75    let lines: Vec<&str> = normalized.split('\n').collect();
76    let sections = split_top_level_sections(&lines);
77
78    let added_section = find_section(&sections, "added requirements");
79    let modified_section = find_section(&sections, "modified requirements");
80    let removed_section = find_section(&sections, "removed requirements");
81    let renamed_section = find_section(&sections, "renamed requirements");
82
83    DeltaSpecification {
84        added: added_section
85            .map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
86            .unwrap_or_default(),
87        modified: modified_section
88            .map(|section| parse_requirement_blocks(&lines, section.body_start, section.body_end))
89            .unwrap_or_default(),
90        removed: removed_section
91            .map(|section| parse_removed_names(&lines[section.body_start..section.body_end]))
92            .unwrap_or_default(),
93        renamed: renamed_section
94            .map(|section| parse_renamed_pairs(&lines[section.body_start..section.body_end]))
95            .unwrap_or_default(),
96        presence: SectionPresence {
97            added: added_section.is_some(),
98            modified: modified_section.is_some(),
99            removed: removed_section.is_some(),
100            renamed: renamed_section.is_some(),
101        },
102    }
103}
104
105/// A `## <title>` section located within a line sequence.
106struct SectionSpan {
107    title_lowercase: String,
108    body_start: usize,
109    body_end: usize,
110}
111
112fn normalize_line_endings(content: &str) -> String {
113    content.replace("\r\n", "\n").replace('\r', "\n")
114}
115
116/// Returns the title of a level-2 section header (`## <title>`), requiring
117/// at least one whitespace character after the marker.
118fn section_title(line: &str) -> Option<&str> {
119    let rest = line.strip_prefix("##")?;
120    if rest.starts_with('#') || !rest.starts_with(char::is_whitespace) {
121        return None;
122    }
123    let title = rest.trim();
124    if title.is_empty() { None } else { Some(title) }
125}
126
127/// Returns the name of a requirement header (`### Requirement: <name>`),
128/// matched case-insensitively; whitespace after the marker is optional.
129fn requirement_header_name(line: &str) -> Option<String> {
130    header_name(line, "###", "requirement:")
131}
132
133/// Returns the name of a scenario header (`#### Scenario: <name>`),
134/// matched case-insensitively; whitespace after the marker is optional.
135fn scenario_header_name(line: &str) -> Option<String> {
136    header_name(line, "####", "scenario:")
137}
138
139fn header_name(line: &str, marker: &str, keyword: &str) -> Option<String> {
140    let rest = line.strip_prefix(marker)?;
141    if rest.starts_with('#') {
142        return None;
143    }
144    let rest = rest.trim_start();
145    let rest = strip_prefix_ignore_ascii_case(rest, keyword)?;
146    if rest.is_empty() {
147        return None;
148    }
149    Some(rest.trim().to_string())
150}
151
152fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
153    let head = text.get(..prefix.len())?;
154    if head.eq_ignore_ascii_case(prefix) {
155        Some(&text[prefix.len()..])
156    } else {
157        None
158    }
159}
160
161fn split_top_level_sections(lines: &[&str]) -> Vec<SectionSpan> {
162    let mut headers: Vec<(usize, String)> = Vec::new();
163    for (index, line) in lines.iter().enumerate() {
164        if let Some(title) = section_title(line) {
165            headers.push((index, title.to_ascii_lowercase()));
166        }
167    }
168    let mut sections = Vec::with_capacity(headers.len());
169    for (position, (index, title_lowercase)) in headers.iter().enumerate() {
170        let body_end = headers
171            .get(position + 1)
172            .map_or(lines.len(), |(next_index, _)| *next_index);
173        sections.push(SectionSpan {
174            title_lowercase: title_lowercase.clone(),
175            body_start: index + 1,
176            body_end,
177        });
178    }
179    sections
180}
181
182fn find_section<'a>(sections: &'a [SectionSpan], title_lowercase: &str) -> Option<&'a SectionSpan> {
183    sections
184        .iter()
185        .find(|section| section.title_lowercase == title_lowercase)
186}
187
188/// Parses requirement blocks from `lines[start..end]`, reporting 1-indexed
189/// line numbers relative to the full line sequence.
190fn parse_requirement_blocks(lines: &[&str], start: usize, end: usize) -> Vec<Requirement> {
191    let mut requirements = Vec::new();
192    let mut cursor = start;
193    while cursor < end {
194        let Some(name) = requirement_header_name(lines[cursor]) else {
195            cursor += 1;
196            continue;
197        };
198        let header_index = cursor;
199        cursor += 1;
200        let body_start = cursor;
201        while cursor < end
202            && requirement_header_name(lines[cursor]).is_none()
203            && section_title(lines[cursor]).is_none()
204        {
205            cursor += 1;
206        }
207        let body = &lines[body_start..cursor];
208        let raw = std::iter::once(lines[header_index])
209            .chain(body.iter().copied())
210            .collect::<Vec<_>>()
211            .join("\n")
212            .trim_end()
213            .to_string();
214        requirements.push(Requirement {
215            name,
216            text: requirement_text(body),
217            scenarios: parse_scenarios(body, body_start),
218            raw,
219            line: header_index + 1,
220        });
221    }
222    requirements
223}
224
225/// Extracts the requirement's normative text: the first non-empty line
226/// before any nested header within the block body.
227fn requirement_text(body: &[&str]) -> Option<String> {
228    for line in body {
229        if line.trim_start().starts_with('#') {
230            return None;
231        }
232        let trimmed = line.trim();
233        if !trimmed.is_empty() {
234            return Some(trimmed.to_string());
235        }
236    }
237    None
238}
239
240/// Parses scenario blocks from a requirement body, reporting 1-indexed line
241/// numbers relative to the full line sequence (`body_start` is 0-indexed).
242fn parse_scenarios(body: &[&str], body_start: usize) -> Vec<Scenario> {
243    let mut scenarios = Vec::new();
244    let mut cursor = 0;
245    while cursor < body.len() {
246        let Some(name) = scenario_header_name(body[cursor]) else {
247            cursor += 1;
248            continue;
249        };
250        let header_index = cursor;
251        cursor += 1;
252        let content_start = cursor;
253        while cursor < body.len() && !ends_scenario_body(body[cursor]) {
254            cursor += 1;
255        }
256        let content = body[content_start..cursor].join("\n").trim().to_string();
257        scenarios.push(Scenario {
258            name,
259            body: content,
260            line: body_start + header_index + 1,
261        });
262    }
263    scenarios
264}
265
266/// Reports whether a line terminates a scenario body: another scenario
267/// header, or a markdown heading of level four or less. Deeper headings
268/// (`#####` and beyond) remain inside the scenario body, matching the
269/// upstream section parser, which only closes a section at a heading of
270/// the same or higher level.
271fn ends_scenario_body(line: &str) -> bool {
272    if scenario_header_name(line).is_some() {
273        return true;
274    }
275    let hashes = line.bytes().take_while(|&byte| byte == b'#').count();
276    (1..=4).contains(&hashes) && line[hashes..].starts_with(char::is_whitespace)
277}
278
279/// Parses removed-requirement names: requirement headers, or bullet items of
280/// the form ``- `### Requirement: <name>` `` (backticks optional).
281fn parse_removed_names(lines: &[&str]) -> Vec<String> {
282    let mut names = Vec::new();
283    for line in lines {
284        if let Some(name) = requirement_header_name(line) {
285            names.push(name);
286            continue;
287        }
288        if let Some(name) = bullet_requirement_name(line) {
289            names.push(name);
290        }
291    }
292    names
293}
294
295fn bullet_requirement_name(line: &str) -> Option<String> {
296    let rest = line.trim_start().strip_prefix('-')?.trim_start();
297    let rest = rest.strip_prefix('`').unwrap_or(rest);
298    let name = requirement_header_name(rest.trim_end())?;
299    Some(name.trim_end_matches('`').trim().to_string())
300}
301
302/// Parses rename pairs from `FROM:`/`TO:` lines (leading bullet markers and
303/// backticks around the requirement header are optional). Labels are
304/// case-sensitive, matching the upstream parser.
305fn parse_renamed_pairs(lines: &[&str]) -> Vec<Rename> {
306    let mut pairs = Vec::new();
307    let mut pending_from: Option<String> = None;
308    for line in lines {
309        if let Some(name) = labeled_requirement_name(line, "FROM:") {
310            pending_from = Some(name);
311        } else if let Some(name) = labeled_requirement_name(line, "TO:")
312            && let Some(from) = pending_from.take()
313        {
314            pairs.push(Rename { from, to: name });
315        }
316    }
317    pairs
318}
319
320fn labeled_requirement_name(line: &str, label: &str) -> Option<String> {
321    let rest = line.trim_start();
322    let rest = rest.strip_prefix('-').map_or(rest, str::trim_start);
323    let rest = rest.strip_prefix(label)?.trim_start();
324    let rest = rest.strip_prefix('`').unwrap_or(rest);
325    let name = requirement_header_name(rest.trim_end())?;
326    Some(name.trim_end_matches('`').trim().to_string())
327}