Sign in

    moonbase-ears

    Immutable EARS requirements and extraction from text and MDC documents

    ears
    requirements
    markdown
    Download zip
    Author
    Version
    0.0.1
    License
    Apache-2.0
    Last updated
    4 hours ago
    Downloads
    1

    #EARS

    moonrockz/moonbase-ears parses and represents requirements written in EARS, the Easy Approach to Requirements Syntax. It supports ubiquitous, state driven, event driven, optional feature, unwanted behaviour and complex requirements.

    Import moonrockz/moonbase-ears as @ears in moon.pkg. To work with an existing MDC document, also import moonrockz/moonbase-mdc as @mdc.

    #One requirement

    let requirement = @ears.Requirement::Requirement(
    "While enabled, when polled, the sensor shall report temperature.",
    )
    // requirement.pattern() == @ears.Complex
    // requirement.preconditions() == ["enabled"]
    // requirement.trigger() == Some(@ears.Event("polled"))
    // requirement.system() == "sensor"
    // requirement.response() == "report temperature."

    parse(source) is an alias for the constructor. Both raise a typed ParseError on invalid syntax. Requirement is opaque and immutable. Accessors expose an optional feature, a read-only sequence of preconditions, an optional event or unwanted trigger, the system name and its response. pattern() derives the classification from those clauses. Multiple clauses produce Complex.

    The accepted grammar is:

    [Where <feature>,] [While <state>, ...] [When <event>, | If <unwanted condition>, then] the <system> shall <response>

    Where comes first, followed by zero or more While clauses, then at most one When or If clause. If requires then; the comma immediately before then is optional. Conditional clauses otherwise need comma separators. Keywords are case-insensitive. ASCII whitespace folds to a single space; the case and punctuation of free text remain intact. A final period is optional and remains part of response(). to_string() renders canonical keyword case and spacing and can be parsed back to an equal requirement.

    Clauses, the system name and the response must be nonempty. Clause contents are free text: the parser does not split English conjunctions into separate states or responses, infer actor identities, or judge whether a requirement is testable. A comma followed by an EARS keyword starts a new clause. In unwanted conditions, then is structural only when followed by the. Avoid other clause-opening keyword sequences inside a clause's free text. Requirement IDs, labels and sentence splitting are outside this grammar.

    #Plain-text specifications

    let spec = @ears.parse_text(
    "The sensor shall measure.\nWhen polled, the sensor shall reply.\n",
    )
    let requirement = spec.requirements()[0].requirement()

    Plain text contains one requirement per nonblank line. Every nonblank line is checked. Invalid lines become Diagnostic values and do not discard valid requirements on other lines. LF, CRLF, CR and a leading BOM are supported. Blank input yields an empty specification. Use the single-requirement constructor for a standalone sentence wrapped across lines.

    #Markdown and MDC

    let spec = @ears.parse_markdown(
    "---\ntitle: Sensor\n---\n- When **polled**, the sensor shall reply.\n",
    )
    // spec.document() retains the immutable MDC document and its frontmatter.

    let document = @mdc.Document::Document("The sensor shall measure.")
    let extracted = @ears.from_mdc(document)

    parse_markdown uses MDC's parser, including its optional YAML mapping header. strict and file pass through to MDC. Invalid frontmatter raises MDC's ParseError. EARS syntax failures appear in spec.diagnostics(). from_mdc uses an existing document without reparsing it.

    Write one requirement per paragraph or list-item paragraph. Paragraphs in blockquotes also count. Wrapped lines join with spaces. Emphasis and link text, inline code, autolinks and inline math supply sentence text. Headings, fenced and indented code blocks, HTML blocks, image alt text, tables, footnotes and frontmatter do not supply requirements.

    Extraction considers paragraphs starting with Where, While, When or If, or starting with The and containing the word shall, should or must. Those candidates either produce requirements or diagnostics. Other prose is ignored. This rule lets documents mix requirements with explanatory text; prose beginning with a conditional keyword can also produce a diagnostic.

    #Source context

    requirements() and diagnostics() return read-only arrays in source order. Each occurrence contains a validated requirement and its Origin. Each diagnostic contains its candidate text, typed error and origin.

    Origin::Text carries a SourceSpan excluding indentation and newline characters. Origin::Markdown carries a path that resolves through spec.document().unwrap().root().at(path), and an optional span covering the original paragraph, including inline markup and line wrapping.

    Spans use half-open UTF-16 offsets in the original input and one-based start lines and columns. Frontmatter and a leading BOM contribute to offsets. Columns count UTF-16 code units, including the BOM on the first line. For MDC documents parsed with locs=false, Markdown spans are absent and paths remain available.

    #Tests

    Focused regressions live beside the parser. Public behaviour has Gherkin scenarios in tests/ears-acceptance/ and reproducible generated laws in tests/ears-laws/.

    mise exec -- moon test pkgs/ears tests/ears-acceptance tests/ears-laws --target js

    ParseError

    pub(all) suberror ParseError {
    EmptyRequirement
    ExpectedSystem
    MissingShall
    MissingResponse
    EmptyClause(keyword~ : String)
    MissingSeparator
    MissingThen
    InvalidClauseOrder
    } derive(Eq,
    Debug
    )

    ParseError::equal

    fn ParseError::equal(ParseError, ParseError) -> Bool

    ParseError::not_equal

    fn ParseError::not_equal(x : ParseError, y : ParseError) -> Bool

    Diagnostic

    type Diagnostic derive(Eq,
    Debug
    )

    Diagnostic::equal

    fn Diagnostic::equal(Diagnostic, Diagnostic) -> Bool

    Diagnostic::error

    fn Diagnostic::error(self : Diagnostic) -> ParseError

    Diagnostic::not_equal

    fn Diagnostic::not_equal(x : Diagnostic, y : Diagnostic) -> Bool

    Diagnostic::origin

    fn Diagnostic::origin(self : Diagnostic) -> Origin

    Diagnostic::text

    fn Diagnostic::text(self : Diagnostic) -> String

    Occurrence

    type Occurrence derive(Eq,
    Debug
    )

    Occurrence::equal

    fn Occurrence::equal(Occurrence, Occurrence) -> Bool

    Occurrence::not_equal

    fn Occurrence::not_equal(x : Occurrence, y : Occurrence) -> Bool

    Occurrence::origin

    fn Occurrence::origin(self : Occurrence) -> Origin

    Occurrence::requirement

    fn Occurrence::requirement(self : Occurrence) -> Requirement

    Origin

    pub enum Origin {
    Text(SourceSpan)
    Markdown(path~ :
    NodePath
    , span~ : SourceSpan?)
    } derive(Eq,
    Debug
    )

    Markdown paths resolve against the retained MDC document. Locations are absent if that document was parsed with locs=false.

    Origin::equal

    fn Origin::equal(Origin, Origin) -> Bool

    Origin::not_equal

    fn Origin::not_equal(x : Origin, y : Origin) -> Bool

    Origin::to_repr

    Pattern

    pub(all) enum Pattern {
    Ubiquitous
    StateDriven
    EventDriven
    OptionalFeature
    UnwantedBehaviour
    Complex
    } derive(Eq,
    Debug
    )

    Pattern::equal

    fn Pattern::equal(Pattern, Pattern) -> Bool

    Pattern::not_equal

    fn Pattern::not_equal(x : Pattern, y : Pattern) -> Bool

    Pattern::to_repr

    Requirement

    type Requirement derive(Eq,
    Debug
    )

    A validated EARS sentence. Only the parser can construct a requirement. Free-text clauses retain their case and punctuation, with whitespace folded.

    Requirement::Requirement

    fn Requirement::Requirement(source : String) -> Requirement raise ParseError

    Parse one sentence. Keywords are case-insensitive; clauses use comma separators. IF requires THEN, with an optional comma before THEN. Order: optional WHERE, zero or more WHILE, optional WHEN or IF, THE ... SHALL.

    Requirement::equal

    fn Requirement::equal(Requirement, Requirement) -> Bool

    Requirement::feature

    fn Requirement::feature(self : Requirement) -> String?

    Requirement::not_equal

    fn Requirement::not_equal(x : Requirement, y : Requirement) -> Bool

    Requirement::pattern

    fn Requirement::pattern(self : Requirement) -> Pattern

    Requirement::preconditions

    fn Requirement::preconditions(self : Requirement) -> ReadOnlyArray[String]

    Requirement::response

    fn Requirement::response(self : Requirement) -> String

    Requirement::system

    fn Requirement::system(self : Requirement) -> String

    Requirement::to_string

    fn Requirement::to_string(self : Requirement) -> String

    Render canonical keyword case and spacing. Response punctuation is retained.

    Requirement::trigger

    fn Requirement::trigger(self : Requirement) -> Trigger?

    SourceSpan

    type SourceSpan derive(Eq,
    Debug
    )

    A half-open UTF-16 range in the original input, with a one-based start line and column. Markdown ranges cover the paragraph, including markup.

    SourceSpan::column

    fn SourceSpan::column(self : SourceSpan) -> Int

    SourceSpan::end

    fn SourceSpan::end(self : SourceSpan) -> Int

    SourceSpan::equal

    fn SourceSpan::equal(SourceSpan, SourceSpan) -> Bool

    SourceSpan::line

    fn SourceSpan::line(self : SourceSpan) -> Int

    SourceSpan::not_equal

    fn SourceSpan::not_equal(x : SourceSpan, y : SourceSpan) -> Bool

    SourceSpan::start

    fn SourceSpan::start(self : SourceSpan) -> Int

    Specification

    type Specification derive(
    Debug
    )

    An immutable extraction result. Syntax errors do not discard valid siblings. The Markdown constructor retains its MDC document and frontmatter.

    Specification::diagnostics

    fn Specification::diagnostics(self : Specification) -> ReadOnlyArray[Diagnostic]

    Specification::document

    Specification::requirements

    fn Specification::requirements(self : Specification) -> ReadOnlyArray[Occurrence]

    Trigger

    pub(all) enum Trigger {
    Event(String)
    Unwanted(String)
    } derive(Eq,
    Debug
    )

    Trigger::equal

    fn Trigger::equal(Trigger, Trigger) -> Bool

    Trigger::not_equal

    fn Trigger::not_equal(x : Trigger, y : Trigger) -> Bool

    Trigger::to_repr

    from_mdc

    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. No reparsing occurs.

    parse

    fn parse(source : String) -> Requirement raise ParseError

    parse_markdown

    fn parse_markdown(source : String, strict? : Bool, file? : String) -> Specification raise
    ParseError

    Parse Markdown with optional YAML frontmatter, then extract EARS sentences. Invalid frontmatter raises MDC's ParseError. EARS errors are diagnostics.

    parse_text

    fn parse_text(source : String) -> Specification

    Parse one requirement per nonblank line. Every nonblank line is checked. Accept LF, CRLF, CR and a leading BOM. Spans exclude indentation/newlines.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io