Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions doc/spec/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,23 @@ Covers:

Needs: impl, utest

#### Markdown "Covers" Single Line
`dsn~md.covers-single-line~1`

The Markdown and reStructuredText Importers support defining covered specification items inline on the same line as the "Covers:" keyword.

covers-single-line = "Covers:" *WSP reference *("," *WSP reference)

Rationale:

When a specification item covers only one requirement, writing an unordered bullet list requires multiple lines and markdownlint requires blank lines around lists. Allowing an inline notation is more compact and convenient.

Covers:

* `req~markdown-standard-syntax~1`

Needs: impl, utest

#### Markdown "Depends" List
`dsn~md.depends-list~1`

Expand Down
18 changes: 17 additions & 1 deletion doc/user_guide/use_cases/writing_a_specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,23 @@ The `Status` keyword takes a single value from `draft`, `proposed`, `approved`,

##### `Covers`

The `Covers` keyword states which items are covered by the current specification item. It is followed by a list of items that are covered, each one written on a new line starting with a bullet character (`+`, `*`, or `-`) followed by the referenced specification item id.
The `Covers` keyword states which items are covered by the current specification item. It comes in two flavors: as one-liner or as list.

**Variant a) one-line `covers`**

Covers: feat~rubber-ducky~1

The referenced IDs can optionally be enclosed in backticks:

Covers: `feat~rubber-ducky~1`

Or with multiple comma-separated items (with or without backticks):

Covers: `feat~rubber-ducky~1`, `feat~bathtub~1`

**Variant b) as List**

It is followed by a list of items that are covered, each one written on a new line starting with a bullet character (`+`, `*`, or `-`) followed by the referenced specification item id.

Given the Feature `feat~rubber-ducky~1` exists and needs a `req`. A requirement that covers that feature could be written as

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.itsallcode.openfasttrace.importer.lightweightmarkup;

import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.itsallcode.openfasttrace.api.core.*;
Expand Down Expand Up @@ -274,10 +275,35 @@ protected void addCoverage()
this.listener.addCoveredId(locatedId(idText, SpecificationItemId.parseId(idText)));
}

/**
* Add covered specification item IDs found on an inline "Covers:" line.
*/
// [impl->dsn~md.covers-single-line~1]
protected void addInlineCoverage()
{
final String line = this.currentContext.currentLine();
final Matcher matcher = SpecificationItemId.ID_PATTERN.matcher(line);
final int colonIndex = line.indexOf(':');
if (colonIndex >= 0)
{
matcher.region(colonIndex + 1, line.length());
}
while (matcher.find())
{
final String idText = matcher.group();
final SpecificationItemId id = SpecificationItemId.parseId(idText);
this.listener.addCoveredId(locatedId(idText, id, matcher.start()));
}
}

private LocatedSpecificationItemId locatedId(final String idText, final SpecificationItemId id)
{
return locatedId(idText, id, this.currentContext.currentLine().indexOf(idText));
}

private LocatedSpecificationItemId locatedId(final String idText, final SpecificationItemId id, final int column)
{
// [impl->dsn~located-specification-item-id-text-ranges~1]
final int column = this.currentContext.currentLine().indexOf(idText);
final SourceRange range = new SourceRange(new SourcePosition(this.currentContext.lineNumber() - 1, column),
new SourcePosition(this.currentContext.lineNumber() - 1, column + idText.length()));
final int artifactTypeEnd = idText.indexOf(SpecificationItemId.ARTIFACT_TYPE_SEPARATOR);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public final class PatternConstants
+ "(" + SpecificationItemId.ID_PATTERN + ")"
+ "(?:\\W.*)?";

// [impl->dsn~md.covers-single-line~1]
/** Matches an inline requirement reference. Capture group 1 holds the first ID. */
public static final String REFERENCE_INLINE = "\\s*.*?("
+ SpecificationItemId.ID_PATTERN + ").*";

private PatternConstants()
{
// not instantiable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,23 @@ void testReferenceAfterBulletRejectsInvalid(final String input)
{
assertThat(PatternConstants.REFERENCE_AFTER_BULLET, not(PatternMatcher.patternMatches(input)));
}

// [utest->dsn~md.covers-single-line~1]
@ParameterizedTest
@ValueSource(strings = { " req~name~1", " `req~name~1`", "[link](#req~name~1)",
" req~name~1, req~name~2", " `req~name~1`, `req~name~2`" })
void testReferenceInlineMatchesValid(final String input)
{
final Matcher matcher = Pattern.compile(PatternConstants.REFERENCE_INLINE).matcher(input);
assertAll(() -> assertThat(matcher.matches(), is(true)),
() -> assertThat(matcher.group(1), equalTo("req~name~1")));
}

// [utest->dsn~md.covers-single-line~1]
@ParameterizedTest
@ValueSource(strings = { "", " ", "invalid", "not~an~id" })
void testReferenceInlineRejectsInvalid(final String input)
{
assertThat(PatternConstants.REFERENCE_INLINE, not(PatternMatcher.patternMatches(input)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ protected Transition[] configureTransitions()
transition(SPEC_ITEM , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}),
transition(SPEC_ITEM , RATIONALE , MdPattern.RATIONALE , this::beginRationale ),
transition(SPEC_ITEM , COMMENT , MdPattern.COMMENT , this::beginComment ),
transition(SPEC_ITEM , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(SPEC_ITEM , COVERS , MdPattern.COVERS , () -> {} ),
transition(SPEC_ITEM , DEPENDS , MdPattern.DEPENDS , () -> {} ),
transition(SPEC_ITEM , SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
Expand All @@ -85,6 +86,7 @@ protected Transition[] configureTransitions()
transition(DESCRIPTION, TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}),
transition(DESCRIPTION, RATIONALE , MdPattern.RATIONALE , this::beginRationale ),
transition(DESCRIPTION, COMMENT , MdPattern.COMMENT , this::beginComment ),
transition(DESCRIPTION, COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(DESCRIPTION, COVERS , MdPattern.COVERS , () -> {} ),
transition(DESCRIPTION, DEPENDS , MdPattern.DEPENDS , () -> {} ),
transition(DESCRIPTION, SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
Expand All @@ -97,6 +99,7 @@ protected Transition[] configureTransitions()
transition(RATIONALE , SPEC_ITEM , MdPattern.ID , this::beginItem ),
transition(RATIONALE , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}),
transition(RATIONALE , COMMENT , MdPattern.COMMENT , this::beginComment ),
transition(RATIONALE , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(RATIONALE , COVERS , MdPattern.COVERS , () -> {} ),
transition(RATIONALE , DEPENDS , MdPattern.DEPENDS , () -> {} ),
transition(RATIONALE , SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
Expand All @@ -107,6 +110,7 @@ protected Transition[] configureTransitions()

transition(COMMENT , SPEC_ITEM , MdPattern.ID , this::beginItem ),
transition(COMMENT , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}),
transition(COMMENT , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(COMMENT , COVERS , MdPattern.COVERS , () -> {} ),
transition(COMMENT , DEPENDS , MdPattern.DEPENDS , () -> {} ),
transition(COMMENT , SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
Expand All @@ -116,9 +120,11 @@ protected Transition[] configureTransitions()
transition(COMMENT , TAGS , MdPattern.TAGS , () -> {} ),
transition(COMMENT , COMMENT , MdPattern.EVERYTHING , this::appendComment ),

// [impl->dsn~md.covers-single-line~1]
// [impl->dsn~md.covers-list~1]
transition(COVERS , SPEC_ITEM , MdPattern.ID , this::beginItem ),
transition(COVERS , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}),
transition(COVERS , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(COVERS , COVERS , MdPattern.COVERS_REF , this::addCoverage ),
transition(COVERS , RATIONALE , MdPattern.RATIONALE , this::beginRationale ),
transition(COVERS , COMMENT , MdPattern.COMMENT , this::beginComment ),
Expand All @@ -140,6 +146,7 @@ protected Transition[] configureTransitions()
transition(DEPENDS , SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
transition(DEPENDS , NEEDS_LIST , MdPattern.NEEDS , () -> {} ),
transition(DEPENDS , DEPENDS , MdPattern.EMPTY , () -> {} ),
transition(DEPENDS , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(DEPENDS , COVERS , MdPattern.COVERS , () -> {} ),
transition(DEPENDS , TAGS , MdPattern.TAGS_INT , this::addTag ),
transition(DEPENDS , TAGS , MdPattern.TAGS , () -> {} ),
Expand All @@ -154,6 +161,7 @@ protected Transition[] configureTransitions()
transition(NEEDS_LIST , DEPENDS , MdPattern.DEPENDS , () -> {} ),
transition(NEEDS_LIST , NEEDS_LIST , MdPattern.NEEDS_REF , this::addNeeds ),
transition(NEEDS_LIST , DESCRIPTION, MdPattern.EMPTY , () -> {} ),
transition(NEEDS_LIST , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(NEEDS_LIST , COVERS , MdPattern.COVERS , () -> {} ),
transition(NEEDS_LIST , TAGS , MdPattern.TAGS_INT , this::addTag ),
transition(NEEDS_LIST , TAGS , MdPattern.TAGS , () -> {} ),
Expand All @@ -169,6 +177,7 @@ protected Transition[] configureTransitions()
transition(TAGS , SPEC_ITEM , MdPattern.NEEDS_INT , this::addNeeds ),
transition(TAGS , NEEDS_LIST , MdPattern.NEEDS , () -> {} ),
transition(TAGS , SPEC_ITEM , MdPattern.EMPTY , () -> {} ),
transition(TAGS , COVERS , MdPattern.COVERS_INT , this::addInlineCoverage ),
transition(TAGS , COVERS , MdPattern.COVERS , () -> {} ),
transition(TAGS , TAGS , MdPattern.TAGS , () -> {} ),
transition(TAGS , TAGS , MdPattern.TAGS_INT , this::addTag ),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ enum MdPattern
// [impl->dsn~md.specification-item-title~1]
// [impl->dsn~md.artifact-forwarding-notation~1]
// [impl->dsn~md.tags-format~1]
// [impl->dsn~md.covers-single-line~1]

// @formatter:off
CODE_BEGIN(" *[`~]{3,30}\\w*\\s*"),
CODE_END(" *[`~]{3,30}\\s*"),
COMMENT("Comment:\\s*"),
COVERS_INT("Covers:" + PatternConstants.REFERENCE_INLINE),
COVERS("Covers:\\s*"),
COVERS_REF(PatternConstants.REFERENCE_AFTER_BULLET),
DEPENDS("Depends:\\s*"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,25 @@ void testIdentifyNonTags(final String text)
MarkdownAsserts.assertMismatch(MdPattern.TAGS_INT, text);
}

// [utest->dsn~md.covers-single-line~1]
@ParameterizedTest
@CsvSource(
{ "Covers: req~foo~1", "Covers: `req~foo~1`", "Covers:req~foo~1", "'Covers: \treq~foo~1 '",
"Covers: req~foo~1, req~bar~2" })
void testIdentifyCoversInline(final String text)
{
MarkdownAsserts.assertMatch(MdPattern.COVERS_INT, text);
}

// [utest->dsn~md.covers-single-line~1]
@ParameterizedTest
@CsvSource(
{ "Covers:", "#Covers: req~foo~1", "' Covers: req~foo~1'", "Covers: abc" })
void testIdentifyNonCoversInline(final String text)
{
MarkdownAsserts.assertMismatch(MdPattern.COVERS_INT, text);
}

@Test
void testRunImportHandlesIOException(@Mock final InputFile fileMock, @Mock final ImportEventListener listenerMock,
@Mock final BufferedReader readerMock) throws IOException
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ void testImportsLocatedDeclarationCoverageAndDependencyIds()
() -> assertThat(item.getLocatedDependOnIds().get(0).getRange(), is(range(4, 2, 18))));
}

// [utest->dsn~located-specification-item-id-text-ranges~1]
@Test
void testImportsLocatedDeclarationCoverageAndDependencyIdsInline()
{
final SpecificationItem item = importText("""
req~subject~1
Covers: req~covered~2
Depends:
* req~dependency~3
""").get(0);

assertAll(
() -> assertThat(item.getLocatedId().getRange(), is(range(0, 0, 13))),
() -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(1, 8, 21))),
() -> assertThat(item.getLocatedDependOnIds().get(0).getRange(), is(range(3, 2, 18))));
}

private static List<SpecificationItem> importText(final String source)
{
return ImportAssertions.runImporterOnText(Path.of("located.md"), source, importerFactory);
Expand Down
Loading
Loading