diff --git a/doc/changes/changes_4.11.0.md b/doc/changes/changes_4.11.0.md index b332a9cd7..a9f8783ea 100644 --- a/doc/changes/changes_4.11.0.md +++ b/doc/changes/changes_4.11.0.md @@ -19,7 +19,8 @@ The user guide documentation now has automatic lateral navigation. ## Bugfixes -- #574: Fixed invalid HTML when inline code contains underscores +* #423: Fixed tag parsing inconsistency in Markdown and reStructuredText importers +* #574: Fixed invalid HTML when inline code contains underscores ## Documentation diff --git a/doc/spec/design.md b/doc/spec/design.md index 96847401b..3a3c819a2 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -1134,6 +1134,23 @@ Covers: Needs: impl, utest +#### Markdown Tag Format +`dsn~md.tags-format~1` + +The Markdown and reStructuredText Importers support defining tags for specification items using either a single-line comma-separated list or an itemized bullet list. During import, each declared tag is validated; invalid tags are rejected while all valid tags are imported. + + tag = (ALPHA / DIGIT) *(ALPHA / DIGIT / "_") + + tags-single-line = "Tags:" *WSP tag *("," *WSP tag) + + tags-list = "Tags:" *(LINEBREAK *WSP ("*" / "+" / "-") *WSP tag) + +Covers: + +* `req~tag-format~1` + +Needs: impl, utest + ### Coverage Tag Format #### Full Coverage Tag Format diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 223638097..92e6371cf 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -332,6 +332,22 @@ Covers: Needs: dsn +##### Tag Format +`req~tag-format~1` + +Tags assigned to specification items in lightweight markup documents must have one or more characters. The first character of a tag must be an ASCII letter or digit. Subsequent characters may additionally contain underscores (`_`). When importing tag declarations (single-line comma-separated lists or itemized lists), invalid tags are rejected while valid tags are imported. + +Rationale: + +The tag format is easy to parse and robust. It is also in line with what most other programs would consider a valid tag, which is important for interoperability. + +Covers: + +* [feat~markdown-import~1](#markdown-import) +* [feat~rst-import~1](#restructured-text-rst-import) + +Needs: dsn + #### Markdown Markdown is a simple ASCII-based markup format that is designed to be human-readable in the source. While it can be rendered into HTML, it is perfectly eye-friendly even before rendering. diff --git a/doc/user_guide/use_cases/distributing_the_detailing_work.md b/doc/user_guide/use_cases/distributing_the_detailing_work.md index 702c791ad..fe1cc8493 100644 --- a/doc/user_guide/use_cases/distributing_the_detailing_work.md +++ b/doc/user_guide/use_cases/distributing_the_detailing_work.md @@ -35,4 +35,14 @@ This tells OFT to read all known specification files from the directory "import/ If you want to also import specification items that do not have any tags, add a single underscore "_" as the first entry in the comma-separated list of tags: - oft convert -t _,AuthenticationProvider,ServiceDiscovery,MapProvider import/arch/ > arch_filtered_by_web_services.xml \ No newline at end of file + oft convert -t _,AuthenticationProvider,ServiceDiscovery,MapProvider import/arch/ > arch_filtered_by_web_services.xml + +#### Tag Syntax and Formats + +Tags must start with an ASCII letter or digit and may contain letters, digits, and underscores (`_`). Tags can be defined either as a comma-separated list on a single line (`Tags: tag1, tag2`) or as an itemized bullet list: + + Tags: + - AuthenticationProvider + - ServiceDiscovery + +When parsing tag declarations, individual invalid tags are ignored while valid tags are imported. For full syntax details, see [Writing a Specification](writing_a_specification.md#tags). \ No newline at end of file diff --git a/doc/user_guide/use_cases/writing_a_specification.md b/doc/user_guide/use_cases/writing_a_specification.md index 6f0b88d15..da5a36149 100644 --- a/doc/user_guide/use_cases/writing_a_specification.md +++ b/doc/user_guide/use_cases/writing_a_specification.md @@ -175,7 +175,24 @@ is functionally equivalent to ##### `Tags` -Tags are described in detail later in this document, see section [Distributing the Detailing Work](distributing_the_detailing_work.md#distributing-the-detailing-work). +The `Tags` keyword assigns one or more tags to a specification item. Tags can be used to filter specification items during import and processing (see [Distributing the Detailing Work](distributing_the_detailing_work.md#distributing-the-detailing-work) and [Import options](../reference/oft_command_line.md#import-options)). + +Tags must consist of one or more characters. The first character must be an ASCII letter (`a-z`, `A-Z`) or digit (`0-9`), and subsequent characters may additionally contain underscores (`_`). + +`Tags` can be declared in two formats: + +**Variant a) Single-line comma-separated list** + + Tags: AuthenticationProvider, ServiceDiscovery, MapProvider + +**Variant b) Itemized list** + + Tags: + - AuthenticationProvider + - ServiceDiscovery + - MapProvider + +Bullet characters `*`, `+`, or `-` can be used for the itemized list. When importing tag declarations, any invalid or malformed tag entries are ignored, while all valid tags are imported. #### Notation in Multiline Text Blocks diff --git a/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/AbstractLightWeightMarkupImporter.java b/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/AbstractLightWeightMarkupImporter.java index 68bcbcbb3..2a947683b 100644 --- a/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/AbstractLightWeightMarkupImporter.java +++ b/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/AbstractLightWeightMarkupImporter.java @@ -1,5 +1,7 @@ package org.itsallcode.openfasttrace.importer.lightweightmarkup; +import java.util.regex.Pattern; + import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.api.importer.Importer; @@ -14,6 +16,8 @@ */ public abstract class AbstractLightWeightMarkupImporter implements Importer, LineReaderCallback { + private static final Pattern TAG_PATTERN = Pattern.compile(PatternConstants.TAG_PATTERN); + /** File to be imported */ protected final InputFile file; /** Listener for import events */ @@ -288,15 +292,21 @@ private SourceRange range(final int start, final int end) return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); } + /** * Add one or more tags. */ + // [impl->dsn~md.tags-format~1] protected void addTag() { final String tags = this.stateMachine.getLastToken(); for (final String tag : tags.split(",")) { - this.listener.addTag(tag.trim()); + final String trimmed = tag.trim(); + if (TAG_PATTERN.matcher(trimmed).matches()) + { + this.listener.addTag(trimmed); + } } } diff --git a/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstants.java b/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstants.java new file mode 100644 index 000000000..d0720d6e0 --- /dev/null +++ b/importer/lightweightmarkup/src/main/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstants.java @@ -0,0 +1,34 @@ +package org.itsallcode.openfasttrace.importer.lightweightmarkup; + +import org.itsallcode.openfasttrace.api.core.SpecificationItemId; + +/** + * Common regular expression constants used across lightweight markup formats. + */ +public final class PatternConstants +{ + /** Matches valid artifact types (e.g., "req", "test"). */ + public static final String ARTIFACT_TYPE = "\\p{Alpha}+"; + + /** Matches valid bullet points ("+", "*", "-"). */ + public static final String BULLETS = "[+*-]"; + + // [impl->dsn~md.tags-format~1] + /** Matches valid tag names. */ + public static final String TAG_PATTERN = "\\p{Alnum}\\w*"; + + /** Matches zero to three whitespace characters. */ + public static final String UP_TO_3_WHITESPACES = "\\s{0,3}"; + + // [impl->dsn~md.requirement-references~1] + /** Matches a requirement reference after a bullet. Capture group 1 holds the ID. */ + public static final String REFERENCE_AFTER_BULLET = UP_TO_3_WHITESPACES + + PatternConstants.BULLETS + "(?:.*\\W)?" + + "(" + SpecificationItemId.ID_PATTERN + ")" + + "(?:\\W.*)?"; + + private PatternConstants() + { + // not instantiable + } +} diff --git a/importer/lightweightmarkup/src/test/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstantsTest.java b/importer/lightweightmarkup/src/test/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstantsTest.java new file mode 100644 index 000000000..9816bb79e --- /dev/null +++ b/importer/lightweightmarkup/src/test/java/org/itsallcode/openfasttrace/importer/lightweightmarkup/PatternConstantsTest.java @@ -0,0 +1,90 @@ +package org.itsallcode.openfasttrace.importer.lightweightmarkup; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.itsallcode.openfasttrace.testutil.matcher.PatternMatcher; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PatternConstantsTest +{ + @ParameterizedTest + @ValueSource(strings = { "req", "feat", "DSN", "Artifact" }) + void testArtifactTypeMatchesValid(final String input) + { + assertThat(PatternConstants.ARTIFACT_TYPE, PatternMatcher.patternMatches(input)); + } + + @ParameterizedTest + @ValueSource(strings = { "123", "req_1", "feat-item", "" }) + void testArtifactTypeRejectsInvalid(final String input) + { + assertThat(PatternConstants.ARTIFACT_TYPE, not(PatternMatcher.patternMatches(input))); + } + + @ParameterizedTest + @ValueSource(strings = { "+", "*", "-" }) + void testBulletsMatchesValid(final String input) + { + assertThat(PatternConstants.BULLETS, PatternMatcher.patternMatches(input)); + } + + @ParameterizedTest + @ValueSource(strings = { "/", "#", "=", "++", "" }) + void testBulletsRejectsInvalid(final String input) + { + assertThat(PatternConstants.BULLETS, not(PatternMatcher.patternMatches(input))); + } + + // [utest->dsn~md.tags-format~1] + @ParameterizedTest + @ValueSource(strings = { "tag", "TAG_1", "0_valid", "a" }) + void testTagPatternMatchesValid(final String input) + { + assertThat(PatternConstants.TAG_PATTERN, PatternMatcher.patternMatches(input)); + } + + // [utest->dsn~md.tags-format~1] + @ParameterizedTest + @ValueSource(strings = { "_invalid", "-invalid", "tag with spaces", "" }) + void testTagPatternRejectsInvalid(final String input) + { + assertThat(PatternConstants.TAG_PATTERN, not(PatternMatcher.patternMatches(input))); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", " ", " " }) + void testUpTo3WhitespacesMatchesValid(final String input) + { + assertThat(PatternConstants.UP_TO_3_WHITESPACES, PatternMatcher.patternMatches(input)); + } + + @ParameterizedTest + @ValueSource(strings = { " ", "\t\t\t\t" }) + void testUpTo3WhitespacesRejectsMoreThan3(final String input) + { + assertThat(PatternConstants.UP_TO_3_WHITESPACES, not(PatternMatcher.patternMatches(input))); + } + + @ParameterizedTest + @ValueSource(strings = { "* req~name~1", " * req~name~1", " * req~name~1", "+ req~name~1", "- req~name~1", + "* covers: req~name~1", "* req~name~1 extra" }) + void testReferenceAfterBulletMatchesValid(final String input) + { + final Matcher matcher = Pattern.compile(PatternConstants.REFERENCE_AFTER_BULLET).matcher(input); + assertAll(() -> assertThat(matcher.matches(), is(true)), + () -> assertThat(matcher.group(1), equalTo("req~name~1"))); + } + + @ParameterizedTest + @ValueSource(strings = { " * req~name~1", "req~name~1", "*", " * " }) + void testReferenceAfterBulletRejectsInvalid(final String input) + { + assertThat(PatternConstants.REFERENCE_AFTER_BULLET, not(PatternMatcher.patternMatches(input))); + } +} diff --git a/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MarkdownImporter.java b/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MarkdownImporter.java index a75e7aacc..b1087b558 100644 --- a/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MarkdownImporter.java +++ b/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MarkdownImporter.java @@ -159,6 +159,7 @@ protected Transition[] configureTransitions() transition(NEEDS_LIST , TAGS , MdPattern.TAGS , () -> {} ), transition(NEEDS_LIST , START , MdPattern.FORWARD , () -> {endItem(); forward();} ), + // [impl->dsn~md.tags-format~1] transition(TAGS , TAGS , MdPattern.TAG_ENTRY , this::addTag ), transition(TAGS , SPEC_ITEM , MdPattern.ID , this::beginItem ), transition(TAGS , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}), diff --git a/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MdPattern.java b/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MdPattern.java index 023837ace..391507d16 100644 --- a/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MdPattern.java +++ b/importer/markdown/src/main/java/org/itsallcode/openfasttrace/importer/markdown/MdPattern.java @@ -2,6 +2,7 @@ import org.itsallcode.openfasttrace.api.core.SpecificationItemId; import org.itsallcode.openfasttrace.importer.lightweightmarkup.ForwardingSpecificationItem; +import org.itsallcode.openfasttrace.importer.lightweightmarkup.PatternConstants; import org.itsallcode.openfasttrace.importer.lightweightmarkup.statemachine.LinePattern; import org.itsallcode.openfasttrace.importer.lightweightmarkup.statemachine.SimpleLinePattern; @@ -13,6 +14,7 @@ enum MdPattern { // [impl->dsn~md.specification-item-title~1] // [impl->dsn~md.artifact-forwarding-notation~1] + // [impl->dsn~md.tags-format~1] // @formatter:off CODE_BEGIN(" *[`~]{3,30}\\w*\\s*"), @@ -49,11 +51,11 @@ enum MdPattern NOT_EMPTY("([^\n\r]+)"), RATIONALE("Rationale:\\s*"), STATUS("Status:\\s*(approved|proposed|draft|rejected)\\s*"), - TAGS_INT("Tags:(\\s*\\w+\\s*(?:,\\s*\\w+\\s*)*)"), + TAGS_INT("Tags:\\s*(\\S.*)"), TAGS("Tags:\\s*"), TAG_ENTRY(PatternConstants.UP_TO_3_WHITESPACES + PatternConstants.BULLETS - + "\\s*" // - + "(.*)"), + + "\\s*(" // + + PatternConstants.TAG_PATTERN + ")\\s*"), TITLE("#+\\s*(.*)"), UNDERLINE("([=-]{3,})\\s*"); // @formatter:on @@ -74,21 +76,4 @@ public LinePattern getPattern() { return this.pattern; } - - private static final class PatternConstants - { - public static final String ARTIFACT_TYPE = "[a-zA-Z]+"; - public static final String BULLETS = "[+*-]"; - private static final String UP_TO_3_WHITESPACES = "\\s{0,3}"; - // [impl->dsn~md.requirement-references~1] - public static final String REFERENCE_AFTER_BULLET = UP_TO_3_WHITESPACES - + PatternConstants.BULLETS + "(?:.*\\W)?" // - + "(" + SpecificationItemId.ID_PATTERN + ")" // - + "(?:\\W.*)?"; - - private PatternConstants() - { - // not instantiable - } - } } diff --git a/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownImporter.java b/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownImporter.java index e47a4e7fb..7793879e7 100644 --- a/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownImporter.java +++ b/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownImporter.java @@ -86,10 +86,10 @@ void testIdentifyTags(final String text) MarkdownAsserts.assertMatch(MdPattern.TAGS_INT, text); } + // Note that this test does not evaluate the validity of the tags themselves. Only if the tags-marker is present. @ParameterizedTest @CsvSource( - { "Tags:", "#Needs: abc", "' Needs: abc'", "Needs: änderung", "Tags: -leadingDash", "Tags: trailingDash-", - "Tags: tag with spaces", "Tags: Täg" }) + { "Tags:", "#Needs: abc", "' Needs: abc'", "Needs: änderung", }) void testIdentifyNonTags(final String text) { MarkdownAsserts.assertMismatch(MdPattern.TAGS_INT, text); diff --git a/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownMarkupImporter.java b/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownMarkupImporter.java index e13ea2c47..5b3fcefa6 100644 --- a/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownMarkupImporter.java +++ b/importer/markdown/src/test/java/org/itsallcode/openfasttrace/importer/markdown/TestMarkdownMarkupImporter.java @@ -342,4 +342,107 @@ void testDisablingMarkdownParsingForATextBlock() .location("disable_parsing.md", 1) .build())); } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsFormat() + { + assertImport("tags_single.md", """ + req~single-line-tags~1 + Tags: feature_1, Release_2_0, 42_tag + """, contains(item() + .id("req", "single-line-tags", 1) + .addTag("feature_1") + .addTag("Release_2_0") + .addTag("42_tag") + .location("tags_single.md", 1) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsMalformedMixedList() + { + assertImport("tags_single_mixed.md", """ + req~single-mixed-tags~1 + Tags: valid_1, _invalid, valid_2, -bad, valid_3 + """, contains(item() + .id("req", "single-mixed-tags", 1) + .addTag("valid_1") + .addTag("valid_2") + .addTag("valid_3") + .location("tags_single_mixed.md", 1) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsAllMalformedProducesEmptyTags() + { + assertImport("tags_single_invalid.md", """ + req~single-invalid-tags~1 + Tags: _invalid, -bad, täg + """, contains(item() + .id("req", "single-invalid-tags", 1) + .location("tags_single_invalid.md", 1) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testBulletedTagsFormat() + { + assertImport("tags_list.md", """ + req~bulleted-tags~1 + Tags: + * feature_1 + * Release_2_0 + * 42_tag + """, contains(item() + .id("req", "bulleted-tags", 1) + .addTag("feature_1") + .addTag("Release_2_0") + .addTag("42_tag") + .location("tags_list.md", 1) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testBulletedTagsMalformedMixedList() + { + assertImport("tags_mixed.md", """ + req~mixed-tags~1 + Tags: + * valid_1 + * _invalid + * valid_2 + """, contains(item() + .id("req", "mixed-tags", 1) + .addTag("valid_1") + .addTag("valid_2") + .location("tags_mixed.md", 1) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testInlineAndBulletedTags() + { + assertImport("tags_inline_and_bulleted.md", """ + req~inline-and-bulleted-tags~1 + Tags: feature_1, feature_2 + * 1st_bullet + + 2nd_bullet + - 3rd_bullet + """, contains(item() + .id("req", "inline-and-bulleted-tags", 1) + .addTag("feature_1") + .addTag("feature_2") + .addTag("1st_bullet") + .addTag("2nd_bullet") + .addTag("3rd_bullet") + .location("tags_inline_and_bulleted.md", 1) + .build())); + } } diff --git a/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RestructuredTextImporter.java b/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RestructuredTextImporter.java index 8835f901d..c7c7416c1 100644 --- a/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RestructuredTextImporter.java +++ b/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RestructuredTextImporter.java @@ -170,6 +170,7 @@ protected Transition[] configureTransitions() transition(NEEDS_LINE , TAGS , RstPattern.TAGS , () -> {} ), transition(NEEDS_LINE , START , RstPattern.FORWARD , () -> {endItem(); forward();} ), + // [impl->dsn~md.tags-format~1] transition(TAGS , TAGS , RstPattern.TAG_ENTRY , this::addTag ), transition(TAGS , TITLE , SECTION_TITLE , () -> {endItem(); rememberTitle();}), transition(TAGS , SPEC_ITEM , RstPattern.ID , this::beginItem ), diff --git a/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RstPattern.java b/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RstPattern.java index 0daeb47f2..cbc84e347 100644 --- a/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RstPattern.java +++ b/importer/restructuredtext/src/main/java/org/itsallcode/openfasttrace/importer/restructuredtext/RstPattern.java @@ -2,6 +2,7 @@ import org.itsallcode.openfasttrace.api.core.SpecificationItemId; import org.itsallcode.openfasttrace.importer.lightweightmarkup.ForwardingSpecificationItem; +import org.itsallcode.openfasttrace.importer.lightweightmarkup.PatternConstants; import org.itsallcode.openfasttrace.importer.lightweightmarkup.statemachine.LinePattern; import org.itsallcode.openfasttrace.importer.lightweightmarkup.statemachine.SimpleLinePattern; @@ -13,6 +14,7 @@ enum RstPattern { // [impl->dsn~md.specification-item-title~1] // [impl->dsn~md.artifact-forwarding-notation~1] + // [impl->dsn~md.tags-format~1] // @formatter:off COMMENT("Comment:\\s*"), @@ -47,11 +49,11 @@ enum RstPattern NOT_EMPTY("([^\n\r]+)"), RATIONALE("Rationale:\\s*"), STATUS("Status:\\s*(approved|proposed|draft|rejected)\\s*"), - TAGS_INT("Tags:(\\s*\\w+\\s*(?:,\\s*\\w+\\s*)*)"), + TAGS_INT("Tags:\\s*(\\S.*)"), TAGS("Tags:\\s*"), TAG_ENTRY(PatternConstants.UP_TO_3_WHITESPACES + PatternConstants.BULLETS - + "\\s*" // - + "(.*)"), + + "\\s*(" // + + PatternConstants.TAG_PATTERN + ")\\s*"), UNDERLINE("([-=`:.'\"~^_*+#<>]{3,})\\s*"); // @formatter:on @@ -71,21 +73,4 @@ public LinePattern getPattern() { return this.pattern; } - - private static final class PatternConstants - { - public static final String ARTIFACT_TYPE = "[a-zA-Z]+"; - public static final String BULLETS = "[+*-]"; - private static final String UP_TO_3_WHITESPACES = "\\s{0,3}"; - // [impl->dsn~md.requirement-references~1] - public static final String REFERENCE_AFTER_BULLET = UP_TO_3_WHITESPACES - + PatternConstants.BULLETS + "(?:.*\\W)?" // - + "(" + SpecificationItemId.ID_PATTERN + ")" // - + "(?:\\W.*)?"; - - private PatternConstants() - { - // not instantiable - } - } } diff --git a/importer/restructuredtext/src/test/java/org/itsallcode/openfasttrace/importer/restructuredtext/TestRestructuredTextImporter.java b/importer/restructuredtext/src/test/java/org/itsallcode/openfasttrace/importer/restructuredtext/TestRestructuredTextImporter.java index f861a534b..690da2da8 100644 --- a/importer/restructuredtext/src/test/java/org/itsallcode/openfasttrace/importer/restructuredtext/TestRestructuredTextImporter.java +++ b/importer/restructuredtext/src/test/java/org/itsallcode/openfasttrace/importer/restructuredtext/TestRestructuredTextImporter.java @@ -250,4 +250,101 @@ void testDisablingRstParsingForATextBlock() .location("disable_parsing.rst", 1) .build())); } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsFormat() + { + assertImport("tags_single.rst", """ + Single Line Tags + ================ + req~single-line-tags~1 + Tags: feature_1, Release_2_0, 42_tag + """, contains(item() + .title("Single Line Tags") + .id("req", "single-line-tags", 1) + .addTag("feature_1") + .addTag("Release_2_0") + .addTag("42_tag") + .location("tags_single.rst", 3) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsMalformedMixedList() + { + assertImport("tags_single_mixed.rst", """ + Single Line Mixed Tags + ====================== + req~single-mixed-tags~1 + Tags: valid_1, _invalid, valid_2, -bad, valid_3 + """, contains(item() + .title("Single Line Mixed Tags") + .id("req", "single-mixed-tags", 1) + .addTag("valid_1") + .addTag("valid_2") + .addTag("valid_3") + .location("tags_single_mixed.rst", 3) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testSingleLineTagsAllMalformedProducesEmptyTags() + { + assertImport("tags_single_invalid.rst", """ + Single Line Invalid Tags + ======================== + req~single-invalid-tags~1 + Tags: _invalid, -bad, täg + """, contains(item() + .title("Single Line Invalid Tags") + .id("req", "single-invalid-tags", 1) + .location("tags_single_invalid.rst", 3) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testBulletedTagsFormat() + { + assertImport("tags_list.rst", """ + Bulleted Tags + ============= + req~bulleted-tags~1 + Tags: + * feature_1 + * Release_2_0 + * 42_tag + """, contains(item() + .title("Bulleted Tags") + .id("req", "bulleted-tags", 1) + .addTag("feature_1") + .addTag("Release_2_0") + .addTag("42_tag") + .location("tags_list.rst", 3) + .build())); + } + + // [utest->dsn~md.tags-format~1] + @Test + void testBulletedTagsMalformedMixedList() + { + assertImport("tags_mixed.rst", """ + Mixed Tags + ========== + req~mixed-tags~1 + Tags: + * valid_1 + * _invalid + * valid_2 + """, contains(item() + .title("Mixed Tags") + .id("req", "mixed-tags", 1) + .addTag("valid_1") + .addTag("valid_2") + .location("tags_mixed.rst", 3) + .build())); + } } diff --git a/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/importer/lightweightmarkup/AbstractLightWeightMarkupImporterTest.java b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/importer/lightweightmarkup/AbstractLightWeightMarkupImporterTest.java index 6bcafea07..2aa6da41c 100644 --- a/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/importer/lightweightmarkup/AbstractLightWeightMarkupImporterTest.java +++ b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/importer/lightweightmarkup/AbstractLightWeightMarkupImporterTest.java @@ -218,22 +218,40 @@ static Stream tags() Arguments.of("Tags: req,dsn", List.of("req", "dsn")), Arguments.of("Tags: req,\tdsn\n", List.of("req", "dsn")), Arguments.of("Tags:req,dsn", List.of("req", "dsn")), + Arguments.of("Tags: 1_req, 2", List.of("1_req", "2")), + Arguments.of("Tags: a", List.of("a")), + Arguments.of("Tags: 1", List.of("1")), Arguments.of("Tags:\n* req\n* dsn", List.of("req", "dsn")), Arguments.of("Tags:\n * req\n * dsn\n", List.of("req", "dsn")), Arguments.of("Tags:\n* req \n\t* dsn ", List.of("req", "dsn")), Arguments.of("Tags:\n* req\n* dsn", List.of("req", "dsn")), Arguments.of("Tags:\n* req_1\n* dsn_2", List.of("req_1", "dsn_2")), - - // Inconsistent behavior, see - // https://github.com/itsallcode/openfasttrace/issues/423 - Arguments.of("Tags: _ ", List.of("_")), - Arguments.of("Tags: täg1, taeg2", List.of()), - Arguments.of("Tags: _tag1, taeg2", List.of("_tag1", "taeg2")), - Arguments.of("Tags: -dash, tag", List.of()), - Arguments.of("Tags:\n* tag1\n* täg2", List.of("tag1", "täg2")), - Arguments.of("Tags:\n* -tag1\n* täg2", List.of("-tag1", "täg2"))); + Arguments.of("Tags:\n* 1_req\n* 2", List.of("1_req", "2")), + Arguments.of("Tags:\n* a\n* 1", List.of("a", "1")), + Arguments.of("Tags:\n+ req\n+ dsn", List.of("req", "dsn")), + Arguments.of("Tags:\n- req\n- dsn", List.of("req", "dsn")), + + // Regression test for #423: + // In both declaration variants, individual invalid tags are rejected while valid tags are imported. + Arguments.of("Tags: _ ", List.of()), + Arguments.of("Tags: täg1, taeg2", List.of("taeg2")), + Arguments.of("Tags: _tag1, taeg2", List.of("taeg2")), + Arguments.of("Tags: tag1, -tag2", List.of("tag1")), + Arguments.of("Tags: -dash, tag", List.of("tag")), + Arguments.of("Tags: tag1, tag-2", List.of("tag1")), + Arguments.of("Tags: tag1, tag#2", List.of("tag1")), + Arguments.of("Tags: tag1, tag 2", List.of("tag1")), + Arguments.of("Tags:\n* tag1\n* täg2", List.of("tag1")), + Arguments.of("Tags:\n* -tag1\n* täg2", List.of()), + Arguments.of("Tags:\n* _tag1\n* tag2", List.of("tag2")), + Arguments.of("Tags:\n* tag1\n* _tag2", List.of("tag1")), + Arguments.of("Tags:\n* tag1\n* -tag2", List.of("tag1")), + Arguments.of("Tags:\n* _", List.of()), + Arguments.of("Tags:\n* tag#1", List.of()), + Arguments.of("Tags:\n* tag 1", List.of())); } + // [utest -> dsn~md.tags-format~1] @ParameterizedTest @MethodSource("tags") void testTags(final String mdContent, final List expected) diff --git a/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/matcher/PatternMatcher.java b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/matcher/PatternMatcher.java new file mode 100644 index 000000000..c5e367492 --- /dev/null +++ b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/matcher/PatternMatcher.java @@ -0,0 +1,45 @@ +package org.itsallcode.openfasttrace.testutil.matcher; + +import org.hamcrest.Description; +import org.hamcrest.TypeSafeDiagnosingMatcher; + +/** + * Matcher that tests a given pattern string against a string value. + *

+ * The purpose is to test regular expression patterns against known-to-match + * strings. If there is no match, the pattern contains an error. Deriving from + * {@link TypeSafeDiagnosingMatcher} allows providing a better readable error + * message. This is important since the logic is inverted to what a user would + * normally expect. + *

+ */ +public final class PatternMatcher extends TypeSafeDiagnosingMatcher { + private final String expected; + + private PatternMatcher(final String expected) + { + super(); + this.expected = expected; + } + + @Override + public void describeTo(final Description description) { + description.appendText("matches string: " + this.expected); + } + + @Override + protected boolean matchesSafely(final String item, final Description mismatchDescription) { + mismatchDescription.appendText("pattern '" + item + "' did not match string"); + return this.expected.matches(item); + } + + /** + * Create a new matcher that tests a given pattern against a string. + * @param expected string that the pattern should match + * @return matcher that can be used to test a given pattern against a string + */ + public static PatternMatcher patternMatches(final String expected) + { + return new PatternMatcher(expected); + } +}