///|
// Flatten inline presentation only. Destinations, raw HTML and image alt text
// cannot introduce requirement words. Code spans in a sentence remain text.
fn paragraph_text(node : @mdc.Node, code_marks~ : Bool = false) -> String {
let out = StringBuilder()
@mdc.walk(node, node => {
if node is Inline(view) {
match view.kind() {
Text(text~) => out.write_string(text)
CodeSpan(code~) =>
// Block parsing keeps the backticks, so that a full stop in code
// does not end a sentence.
if code_marks {
out.write_string("`" + code + "`")
} else {
out.write_string(code)
}
Break => out.write_string(" ")
Autolink =>
if view.to_cmark() is Autolink({ v, .. }) {
out.write_string(v.link.v)
}
ExtMathSpan(tex~) => out.write_string(tex)
Image | RawHtml => return SkipChildren
_ => ()
}
}
Continue
})
out.to_string()
}
///|
fn is_candidate(text : String, vocabulary : Vocabulary) -> Bool {
for keyword in keywords {
if starts_word(text, keyword.word()) {
return true
}
}
let starts = vocabulary.starts_subject(text) ||
lead_in_at_start(text, vocabulary) is Some(_)
starts &&
text
.split(" ")
.any(word => {
let word = word.to_owned().to_lower()
word == "shall" ||
word == "should" ||
word == "must" ||
vocabulary.modals.contains(word)
})
}
///|
/// The source range of a block view, when the document has locations.
fn view_span(
document : @mdc.Document,
view : @mdc.BlockView,
) -> @mdc.SourceRange? {
document.source_range(view.location())
}
///|
/// A requirement block that the Markdown walk is filling.
priv struct BlockBuilder {
name : String
heading : @mdc.SourceRange?
sentences : Sentences
}
///|
/// Extract requirements from an MDC document with the vocabulary of
/// `dialects`.
///
/// Without heading rules, extract one sentence per paragraph, including
/// paragraphs in lists and blockquotes. Wrapped lines and inline formatting
/// form a single sentence. Skip headings, code, HTML, tables, footnotes and
/// frontmatter. Non-EARS prose is ignored; malformed candidates become
/// diagnostics.
///
/// With heading rules, each heading that matches a rule starts a requirement
/// block, named by the rest of the heading text. The paragraphs after it, up
/// to the next heading of any level, are split into sentences, and a pronoun
/// subject refers to the system of the nearest earlier sentence of the block.
/// Each sentence has the source span of its paragraph. No reparsing occurs.
pub fn from_mdc(
document : @mdc.Document,
dialects~ : ArrayView[&Dialect] = [],
) -> Specification {
let vocabulary = Vocabulary::of(dialects)
if !vocabulary.headings.is_empty() {
return blocks_of(document, vocabulary)
}
let requirements = []
let diagnostics = []
@mdc.walk_cursor(document.root(), cursor => {
if cursor.node() is Block(view) {
match view.kind() {
Paragraph => {
let text = normalize(paragraph_text(cursor.node()))
if is_candidate(text, vocabulary) {
collect_candidate(
text,
Markdown(path=cursor.path(), span=view_span(document, view)),
requirements,
diagnostics,
vocabulary,
)
}
return SkipChildren
}
Heading(_) | CodeBlock | HtmlBlock | ExtTable | ExtFootnoteDefinition =>
return SkipChildren
_ => ()
}
}
Continue
})
{
requirements: ReadOnlyArray::from_array(requirements[:]),
diagnostics: ReadOnlyArray::from_array(diagnostics[:]),
blocks: [],
document: Some(document),
}
}
///|
/// The requirement blocks of a document, by the heading rules of the
/// vocabulary.
fn blocks_of(
document : @mdc.Document,
vocabulary : Vocabulary,
) -> Specification {
let blocks : Array[RequirementBlock] = []
let mut current : BlockBuilder? = None
let close = () => {
match current {
Some(b) =>
blocks.push({
name: b.name,
heading: b.heading,
requirements: ReadOnlyArray::from_array(b.sentences.requirements),
diagnostics: ReadOnlyArray::from_array(b.sentences.diagnostics),
})
None => ()
}
current = None
}
@mdc.walk_cursor(document.root(), cursor => {
if cursor.node() is Block(view) {
match view.kind() {
Heading(level~) => {
close()
let text = normalize(paragraph_text(cursor.node()))
for rule in vocabulary.headings {
match rule.name_of(level, text) {
Some(name) => {
current = Some({
name,
heading: document.source_range(view.location()),
sentences: Sentences::new(),
})
break
}
None => ()
}
}
return SkipChildren
}
Paragraph => {
match current {
Some(b) => {
let text = normalize(
paragraph_text(cursor.node(), code_marks=true),
)
let origin = Markdown(
path=cursor.path(),
span=view_span(document, view),
)
b.sentences.add(text, vocabulary, (_, _) => origin)
}
None => ()
}
return SkipChildren
}
CodeBlock | HtmlBlock | ExtTable | ExtFootnoteDefinition =>
return SkipChildren
_ => ()
}
}
Continue
})
close()
let requirements = []
let diagnostics = []
for b in blocks {
for r in b.requirements {
requirements.push(r)
}
for d in b.diagnostics {
diagnostics.push(d)
}
}
{
requirements: ReadOnlyArray::from_array(requirements),
diagnostics: ReadOnlyArray::from_array(diagnostics),
blocks: ReadOnlyArray::from_array(blocks),
document: Some(document),
}
}
///|
/// Parse Markdown with optional YAML frontmatter, then extract EARS sentences
/// with the vocabulary of `dialects`.
/// Invalid frontmatter raises MDC's ParseError. EARS errors are diagnostics.
pub fn parse_markdown(
source : String,
strict~ : Bool = true,
file~ : String = "",
dialects~ : ArrayView[&Dialect] = [],
) -> Specification raise @mdc.ParseError {
from_mdc(@mdc.Document::Document(source, strict~, file~), dialects~)
}