diff --git a/AGENTS.md b/AGENTS.md index 94d7c2c3d..8ce506485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ Needs: impl, utest ### Code Style & Conventions - **Clean Code**: Meaningful names, small functiKons, single responsibility. +- **Type References**: Use plain class names with imports instead of fully qualified names such as `java.util.List`. - **Formatting**: Use the project's Eclipse formatter (`doc/itsallcode_formatter.xml`). - **Logging**: Use `java.util.logging`. Test config: `core/src/test/resources/logging.properties`. diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItem.java b/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItem.java index 6e7b9727e..755a6a654 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItem.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItem.java @@ -48,6 +48,16 @@ public SpecificationItemId getId() return this.id.getId(); } + /** + * Get the declared ID together with its source occurrence. + * + * @return declared located ID + */ + public LocatedSpecificationItemId getLocatedId() + { + return this.id; + } + /** * Get the artifact type of the specification item * @@ -134,6 +144,16 @@ public List getCoveredIds() return this.coveredIds.stream().map(LocatedSpecificationItemId::getId).toList(); } + /** + * Get covered IDs together with their source occurrences. + * + * @return located covered IDs + */ + public List getLocatedCoveredIds() + { + return Collections.unmodifiableList(this.coveredIds); + } + /** * Add a covered {@link SpecificationItemId} to the list of covered IDs. *

@@ -167,6 +187,16 @@ public List getDependOnIds() return this.dependOnIds.stream().map(LocatedSpecificationItemId::getId).toList(); } + /** + * Get dependency IDs together with their source occurrences. + * + * @return located dependency IDs + */ + public List getLocatedDependOnIds() + { + return this.dependOnIds; + } + /** * Get the list of artifact types this specification item need to be covered * in @@ -274,6 +304,28 @@ public static Builder builder() return new Builder(); } + /** + * Create a builder pre-populated with this item's values. + * + * @return builder initialized from this item + */ + public Builder toBuilder() + { + final Builder builder = builder().id(this.id) + .title(this.title) + .description(this.description) + .rationale(this.rationale) + .comment(this.comment) + .status(this.status) + .location(this.location) + .forwards(this.forwards); + this.coveredIds.forEach(builder::addCoveredId); + this.dependOnIds.forEach(builder::addDependOnId); + this.needsArtifactTypes.forEach(builder::addNeedsArtifactType); + this.tags.forEach(builder::addTag); + return builder; + } + /** * Builder for objects of type {@link SpecificationItem} */ @@ -443,6 +495,20 @@ public Builder addCoveredId(final SpecificationItemId coveredId) return this.addCoveredId(locatedId(coveredId)); } + /** + * Replace the IDs of specification items covered by the item to build. + * + * @param coveredIds + * the covered IDs + * @return this builder instance + */ + public Builder coveredIds(final Collection coveredIds) + { + this.coveredIds.clear(); + coveredIds.forEach(this::addCoveredId); + return this; + } + /** * Add the ID of a specification item covered by the item to build * @@ -486,6 +552,20 @@ public Builder addDependOnId(final LocatedSpecificationItemId dependOnId) return this; } + /** + * Replace the IDs of specification items the item to build depends on. + * + * @param dependOnIds + * the dependency IDs + * @return this builder instance + */ + public Builder dependOnIds(final Collection dependOnIds) + { + this.dependOnIds.clear(); + dependOnIds.forEach(this::addDependOnId); + return this; + } + /** * Add the ID of a specification item the item to be build depends on * diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItemId.java b/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItemId.java index 1b45ff2d7..1a83a889e 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItemId.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/core/SpecificationItemId.java @@ -92,8 +92,10 @@ public String getArtifactType() } @Override - public boolean equals(final Object o) { - if (!(o instanceof final SpecificationItemId that)) { + public boolean equals(final Object o) + { + if (!(o instanceof final SpecificationItemId that)) + { return false; } return revision == that.revision && Objects.equals(name, that.name) @@ -101,19 +103,20 @@ public boolean equals(final Object o) { } @Override - public int hashCode() { + public int hashCode() + { return Objects.hash(name, revision, artifactType); } @Override public String toString() { - return this.artifactType + ARTIFACT_TYPE_SEPARATOR + this.name + REVISION_SEPARATOR + this.revision; + return this.artifactType + ARTIFACT_TYPE_SEPARATOR + this.name + REVISION_SEPARATOR + this.revision; } /** * Get this item with a wildcard as revision. - * + * * @return a copy of this ID with a wildcard as revision. */ public SpecificationItemId toRevisionWildcard() @@ -145,8 +148,7 @@ public static SpecificationItemId parseId(final String idText) * the revision * @return the specification item ID */ - public static SpecificationItemId createId(final String artifactType, final String name, - final int revision) + public static SpecificationItemId createId(final String artifactType, final String name, final int revision) { return new SpecificationItemId.Builder().artifactType(artifactType).name(name) .revision(revision).build(); diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/ImportEventListener.java b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/ImportEventListener.java index 8589d2f3d..03a3c14f6 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/ImportEventListener.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/ImportEventListener.java @@ -13,7 +13,7 @@ public interface ImportEventListener /** * The importer found a new specification item. The * {@link SpecificationItemId} must be defined using - * {@link #setId(SpecificationItemId)}. + * {@link #setId(LocatedSpecificationItemId)}. */ void beginSpecificationItem(); @@ -22,7 +22,9 @@ public interface ImportEventListener * * @param id * the ID of the new item + * @deprecated Use {@link #setId(LocatedSpecificationItemId)} instead. */ + @Deprecated(since = "4.9.0", forRemoval = true) void setId(final SpecificationItemId id); /** @@ -82,7 +84,9 @@ default void setId(final LocatedSpecificationItemId id) * * @param id * the ID of the item that is covered + * @deprecated Use {@link #addCoveredId(LocatedSpecificationItemId)} instead. */ + @Deprecated(since = "4.9.0", forRemoval = true) void addCoveredId(final SpecificationItemId id); /** @@ -101,7 +105,9 @@ default void addCoveredId(final LocatedSpecificationItemId id) * * @param id * the ID of the item depends on + * @deprecated Use {@link #addDependsOnId(LocatedSpecificationItemId)} instead. */ + @Deprecated(since = "4.9.0", forRemoval = true) void addDependsOnId(final SpecificationItemId id); /** diff --git a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java index f3bc5d5db..de4a11051 100644 --- a/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java +++ b/api/src/main/java/org/itsallcode/openfasttrace/api/importer/SpecificationListBuilder.java @@ -15,7 +15,7 @@ public final class SpecificationListBuilder implements ImportEventListener private final FilterSettings filterSettings; private final List items = new LinkedList<>(); private SpecificationItem.Builder itemBuilder; - private SpecificationItemId id; + private LocatedSpecificationItemId id; private StringBuilder description = new StringBuilder(); private StringBuilder rationale = new StringBuilder(); private StringBuilder comment = new StringBuilder(); @@ -66,8 +66,16 @@ private void resetState() } @Override + @SuppressWarnings("removal") // Need to implement method from interface for backward compatibility public void setId(final SpecificationItemId id) { + this.setId(LocatedSpecificationItemId.builder().id(id).build()); + } + + @Override + public void setId(final LocatedSpecificationItemId id) + { + // [impl->dsn~located-specification-item-id-storage~1] this.id = id; } @@ -78,10 +86,18 @@ public void setStatus(final ItemStatus status) } @Override + @SuppressWarnings("removal") // Need to implement method from interface for backward compatibility public void addCoveredId(final SpecificationItemId id) + { + this.addCoveredId(LocatedSpecificationItemId.builder().id(id).build()); + } + + @Override + public void addCoveredId(final LocatedSpecificationItemId id) { // [impl->dsn~filtering-by-artifact-types-during-import~1] - if (isAcceptedArtifactType(id.getArtifactType())) + // [impl->dsn~located-specification-item-id-storage~1] + if (isAcceptedArtifactType(id.getId().getArtifactType())) { this.itemBuilder.addCoveredId(id); } @@ -106,10 +122,18 @@ public void appendComment(final String fragment) } @Override + @SuppressWarnings("removal") // Need to implement method from interface for backward compatibility public void addDependsOnId(final SpecificationItemId id) + { + this.addDependsOnId(LocatedSpecificationItemId.builder().id(id).build()); + } + + @Override + public void addDependsOnId(final LocatedSpecificationItemId id) { // [impl->dsn~filtering-by-artifact-types-during-import~1] - if (isAcceptedArtifactType(id.getArtifactType())) + // [impl->dsn~located-specification-item-id-storage~1] + if (isAcceptedArtifactType(id.getId().getArtifactType())) { this.itemBuilder.addDependOnId(id); } diff --git a/api/src/test/java/org/itsallcode/openfasttrace/api/core/TestSpecificationItem.java b/api/src/test/java/org/itsallcode/openfasttrace/api/core/TestSpecificationItem.java index fd0e0e8ea..2ef7b7fe3 100644 --- a/api/src/test/java/org/itsallcode/openfasttrace/api/core/TestSpecificationItem.java +++ b/api/src/test/java/org/itsallcode/openfasttrace/api/core/TestSpecificationItem.java @@ -1,9 +1,11 @@ package org.itsallcode.openfasttrace.api.core; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; import org.junit.jupiter.api.Test; @@ -41,6 +43,77 @@ void testPreservesCompatibilityForUnlocatedIds() () -> assertThat(item.getDependOnIds(), contains(DEPEND_ON_ID))); } + // [utest->dsn~specification-item~3] + @Test + void testToBuilderPreservesAllValues() + { + final SpecificationItem item = SpecificationItem.builder().id(locatedId(ID)).title("Title") + .description("Description").rationale("Rationale").comment("Comment").status(ItemStatus.DRAFT) + .location(Location.create("file.md", 7)).addCoveredId(locatedId(COVERED_ID)) + .addDependOnId(locatedId(DEPEND_ON_ID)).addNeedsArtifactType("impl").addTag("important") + .forwards(true).build(); + + assertThat(item.toBuilder().build(), equalTo(item)); + } + + // [utest->dsn~specification-item~3] + @Test + void testToBuilderDoesNotModifyOriginalItem() + { + final SpecificationItem item = SpecificationItem.builder().id(ID).addCoveredId(COVERED_ID).build(); + + final SpecificationItem copy = item.toBuilder().addCoveredId(DEPEND_ON_ID).build(); + + assertAll( + () -> assertThat(item.getCoveredIds(), contains(COVERED_ID)), + () -> assertThat(copy, not(equalTo(item))), + () -> assertThat(copy.getCoveredIds(), contains(COVERED_ID, DEPEND_ON_ID))); + } + + // [utest->dsn~specification-item~3] + @Test + void testBuilderReplacesCoveredIds() + { + final SpecificationItem item = SpecificationItem.builder().id(ID).addCoveredId(DEPEND_ON_ID) + .coveredIds(List.of(COVERED_ID)).build(); + + assertThat(item.getCoveredIds(), contains(COVERED_ID)); + } + + // [utest->dsn~specification-item~3] + @Test + void testBuilderReplacesDependencyIds() + { + final SpecificationItem item = SpecificationItem.builder().id(ID).addDependOnId(COVERED_ID) + .dependOnIds(List.of(DEPEND_ON_ID)).build(); + + assertThat(item.getDependOnIds(), contains(DEPEND_ON_ID)); + } + + // [utest->dsn~located-specification-item-id-storage~1] + @Test + void testLocatedCoveredIdsAreImmutable() + { + final SpecificationItem item = SpecificationItem.builder().id(locatedId(ID)) + .addCoveredId(locatedId(COVERED_ID)).addDependOnId(locatedId(DEPEND_ON_ID)).build(); + + final List immutableList = item.getLocatedCoveredIds(); + final LocatedSpecificationItemId locatedId = locatedId(ID); + assertThrows(UnsupportedOperationException.class, () -> immutableList.add(locatedId)); + } + + // [utest->dsn~located-specification-item-id-storage~1] + @Test + void testLocatedDependOnIdsAreImmutable() + { + final SpecificationItem item = SpecificationItem.builder().id(locatedId(ID)) + .addCoveredId(locatedId(COVERED_ID)).addDependOnId(locatedId(DEPEND_ON_ID)).build(); + + final List immutableList = item.getLocatedDependOnIds(); + final LocatedSpecificationItemId locatedId = locatedId(ID); + assertThrows(UnsupportedOperationException.class, () -> immutableList.add(locatedId)); + } + private static LocatedSpecificationItemId locatedId(final SpecificationItemId id) { return LocatedSpecificationItemId.builder().id(id).range( diff --git a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java index 30d8231fd..c61565862 100644 --- a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java +++ b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/TestSpecificationListBuilder.java @@ -16,6 +16,51 @@ class TestSpecificationListBuilder private static final String TITLE = "title"; private static final SpecificationItemId ID = SpecificationItemId.parseId("feat~id~1"); + // [utest->dsn~located-specification-item-id-storage~1] + @Test + void testPreservesLocatedIdOccurrences() + { + final SpecificationItemId coveredId = SpecificationItemId.parseId("req~covered~1"); + final SpecificationItemId dependencyId = SpecificationItemId.parseId("req~dependency~1"); + final LocatedSpecificationItemId locatedId = locatedId(ID, 0); + final LocatedSpecificationItemId firstCoveredId = locatedId(coveredId, 5); + final LocatedSpecificationItemId secondCoveredId = locatedId(coveredId, 20); + final LocatedSpecificationItemId locatedDependencyId = locatedId(dependencyId, 3); + final SpecificationListBuilder builder = SpecificationListBuilder.create(); + builder.beginSpecificationItem(); + builder.setId(locatedId); + builder.addCoveredId(firstCoveredId); + builder.addCoveredId(secondCoveredId); + builder.addDependsOnId(locatedDependencyId); + + final SpecificationItem item = builder.build().get(0); + + assertAll( + () -> assertThat(item.getLocatedId(), equalTo(locatedId)), + () -> assertThat(item.getLocatedCoveredIds(), contains(firstCoveredId, secondCoveredId)), + () -> assertThat(item.getLocatedDependOnIds(), contains(locatedDependencyId)), + () -> assertThat(item.getCoveredIds(), contains(coveredId, coveredId)), + () -> assertThat(item.getDependOnIds(), contains(dependencyId))); + } + + private static LocatedSpecificationItemId locatedId(final String artifactType, final String name, + final int revision) + { + return locatedId(SpecificationItemId.createId(artifactType, name, revision)); + } + + private static LocatedSpecificationItemId locatedId(final SpecificationItemId id) + { + return LocatedSpecificationItemId.builder().id(id).build(); + } + + private static LocatedSpecificationItemId locatedId(final SpecificationItemId id, final int column) + { + final SourceRange range = new SourceRange(new SourcePosition(0, column), + new SourcePosition(0, column + id.toString().length())); + return LocatedSpecificationItemId.builder().id(id).range(range).build(); + } + @Test void testBuildBasicItem() { @@ -33,7 +78,7 @@ private SpecificationListBuilder createBasicListBuilder() { final SpecificationListBuilder builder = SpecificationListBuilder.create(); builder.beginSpecificationItem(); - builder.setId(ID); + builder.setId(locatedId(ID)); return builder; } @@ -71,11 +116,11 @@ void testFilterArtifactOfType() { final SpecificationListBuilder builder = createListBuilderFilteringByArtifactTypes("dsn"); builder.beginSpecificationItem(); - builder.setId(SpecificationItemId.createId("impl", "ignore", 1)); + builder.setId(locatedId("impl", "ignore", 1)); builder.endSpecificationItem(); builder.beginSpecificationItem(); final SpecificationItemId importedId = SpecificationItemId.createId("dsn", "import", 1); - builder.setId(importedId); + builder.setId(locatedId(importedId)); builder.endSpecificationItem(); final List items = builder.build(); assertThat(items.size(), equalTo(1)); @@ -99,7 +144,7 @@ void testFilterNeededArtifactType() "utest", "itest"); builder.beginSpecificationItem(); final SpecificationItemId id = SpecificationItemId.createId("dsn", "import", 1); - builder.setId(id); + builder.setId(locatedId(id)); builder.addNeededArtifactType("impl"); builder.addNeededArtifactType("utest"); builder.addNeededArtifactType("itest"); @@ -118,9 +163,9 @@ void testFilterCoversLinkWithArtifactType() "dsn"); builder.beginSpecificationItem(); final SpecificationItemId importedId = SpecificationItemId.createId("dsn", "import", 1); - builder.setId(importedId); - builder.addCoveredId(acceptedId); - builder.addCoveredId(rejectedId); + builder.setId(locatedId(importedId)); + builder.addCoveredId(locatedId(acceptedId)); + builder.addCoveredId(locatedId(rejectedId)); builder.endSpecificationItem(); final List items = builder.build(); assertThat(items.get(0).getCoveredIds(), containsInAnyOrder(acceptedId)); @@ -136,9 +181,9 @@ void testFilterDependsLinkWithArtifactType() "dsn"); builder.beginSpecificationItem(); final SpecificationItemId importedId = SpecificationItemId.createId("dsn", "import", 1); - builder.setId(importedId); - builder.addDependsOnId(acceptedId); - builder.addDependsOnId(rejectedId); + builder.setId(locatedId(importedId)); + builder.addDependsOnId(locatedId(acceptedId)); + builder.addDependsOnId(locatedId(rejectedId)); builder.endSpecificationItem(); final List items = builder.build(); assertThat(items.get(0).getDependOnIds(), containsInAnyOrder(acceptedId)); @@ -149,10 +194,10 @@ void testDuplicateIdNotIgnored() { final SpecificationListBuilder builder = SpecificationListBuilder.create(); builder.beginSpecificationItem(); - builder.setId(ID); + builder.setId(locatedId(ID)); builder.endSpecificationItem(); builder.beginSpecificationItem(); - builder.setId(ID); + builder.setId(locatedId(ID)); builder.endSpecificationItem(); assertThat(builder.getItemCount(), equalTo(2)); } @@ -182,7 +227,7 @@ private void addItemWithStatus(final SpecificationListBuilder builder, final Str { builder.beginSpecificationItem(); final SpecificationItemId id = SpecificationItemId.createId("dsn", name, 1); - builder.setId(id); + builder.setId(locatedId(id)); if (status != null) { builder.setStatus(status); @@ -217,7 +262,7 @@ private void addItemWithTags(final SpecificationListBuilder builder, final Strin { builder.beginSpecificationItem(); final SpecificationItemId idA = SpecificationItemId.createId("dsn", name, 1); - builder.setId(idA); + builder.setId(locatedId(idA)); for (final String tag : tags) { builder.addTag(tag); @@ -252,7 +297,7 @@ void testMultilineTextFieldsGetTrimmed() { final SpecificationListBuilder builder = SpecificationListBuilder.create(); builder.beginSpecificationItem(); - builder.setId(SpecificationItemId.createId("foo", "bar", 1)); + builder.setId(locatedId("foo", "bar", 1)); builder.appendComment(" a comment "); builder.appendDescription(" a description\t \t"); builder.appendRationale("\n\na rationale\n \n"); diff --git a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/input/TestRealFileInput.java b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/input/TestRealFileInput.java index 555570f31..a353c160f 100644 --- a/api/src/test/java/org/itsallcode/openfasttrace/api/importer/input/TestRealFileInput.java +++ b/api/src/test/java/org/itsallcode/openfasttrace/api/importer/input/TestRealFileInput.java @@ -104,7 +104,7 @@ void testReadFileWithInvalidEncoding2() throws IOException final Path path = tempDir.resolve("file"); Files.write(path, new byte[] { (byte) 0x9F, (byte) 0x88 }); final InputFile inputFile = RealFileInput.forPath(path, StandardCharsets.UTF_8); - assertThat(readContent(inputFile), equalTo("��")); + assertThat(readContent(inputFile), equalTo("\uFFFD\uFFFD")); } private Path writeTempFile(final String content, final Charset charset) throws IOException diff --git a/doc/changes/changes.md b/doc/changes/changes.md index 97bef72df..a549f6b2b 100644 --- a/doc/changes/changes.md +++ b/doc/changes/changes.md @@ -1,5 +1,6 @@ # Changes +* [4.9.0](changes_4.9.0.md) * [4.8.0](changes_4.8.0.md) * [4.7.0](changes_4.7.0.md) * [4.6.0](changes_4.6.0.md) diff --git a/doc/changes/changes_4.9.0.md b/doc/changes/changes_4.9.0.md new file mode 100644 index 000000000..f35731a25 --- /dev/null +++ b/doc/changes/changes_4.9.0.md @@ -0,0 +1,19 @@ +# OpenFastTrace 4.9.0, released 2026-08-07 + +Code name: Item ID Location + +## Summary + +Importers for long coverage tags, Markdown and reStructuredText now collect the location of coverage IDs in documents and source code. + +The exact location of an item ID is required for IDE plugins in order to support features like syntax highlighting, find occurrences, jump to definition and auto-complete. Adding this feature to OFT helps avoid code duplications and improves reliability. + +**Deprecation Notes:** Users of the OFT API and OFT plugin authors please note the deprecation of the following methods: +* Interface `org.itsallcode.openfasttrace.api.importer.ImportEventListener`: methods + * `void setId(SpecificationItemId id)` + * `void addCoveredId(SpecificationItemId id)` + * `void addDependsOnId(SpecificationItemId id)` + +## New Features + +* #570: Collect source code location of specification item IDs diff --git a/doc/spec/design.md b/doc/spec/design.md index b32803e29..b18a4e7f1 100644 --- a/doc/spec/design.md +++ b/doc/spec/design.md @@ -110,6 +110,47 @@ Importers emit events if they find parts of a [specification item](#specificatio ### Specification List Builder The specification list builder is an import event listener that creates a list of specification items from import events. +#### Located Specification Item ID Storage +`dsn~located-specification-item-id-storage~1` + +`SpecificationItem` and `SpecificationListBuilder` preserve the individual +declared, Covers, and Depends occurrences as `LocatedSpecificationItemId` +values while retaining compatible item ID accessors. Located-ID lists are +immutable when observed through the public API. + +Covers: + +* req~located-specification-item-ids~1 + +Needs: impl, utest + +#### Text Source Ranges +`dsn~located-specification-item-id-text-ranges~1` + +Text importers create zero-based UTF-16, start-inclusive and end-exclusive +ranges for source ID occurrences and their represented components. Equal +item IDs at distinct source occurrences remain separate values. + +Needs: impl, utest + +#### Coverage-tag Source Ranges +`dsn~located-specification-item-id-tag-ranges~1` + +The full coverage-tag importer locates source-backed declared and covered ID +components. It leaves component ranges absent when a tag generates the +corresponding ID component. The short coverage-tag importer creates item +IDs without source ranges. + +Needs: impl, utest + +#### SpecObject ID Occurrences +`dsn~located-specification-item-id-specobject~1` + +The SpecObject importer emits located declared, covered, and dependency IDs +without ranges because its XML event model does not expose character offsets. + +Needs: impl, utest + ## Command Line Interpreter The command line interpreter (CLI) takes parameters given to OFT and parses them. It is responsible for making sense of the parameter contents and issuing help and error messages about the command line syntax. diff --git a/doc/spec/system_requirements.md b/doc/spec/system_requirements.md index 37662d7e7..35974a246 100644 --- a/doc/spec/system_requirements.md +++ b/doc/spec/system_requirements.md @@ -143,6 +143,25 @@ Covers: Needs: dsn +### Located Specification Item IDs +`req~located-specification-item-ids~1` + +For every imported declared, covered, and dependency specification item ID, +OFT shall retain the individual source occurrence. For source text, positions +shall be zero-based UTF-16 offsets with start-inclusive, end-exclusive ranges. +Where an ID component is generated or its source is unavailable, its component +range shall be absent. Equal item IDs at distinct source occurrences shall +remain distinct occurrences. Short coverage tags shall not generate source +ranges for their generated or covered IDs. + +Rationale: + +The exact location of an item ID is required for IDE plugins in order to support +features like syntax highlighting, find occurrences, jump to definition and auto-complete. +Adding this feature to OFT helps avoid code duplications and improves reliability. + +Needs: dsn + #### Validate Gherkin Covers Metadata `req~gherkin-covers-validation~1` diff --git a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java index 4afb707c0..1c0fb7dec 100644 --- a/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java +++ b/importer/gherkin/src/main/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinLineConsumer.java @@ -6,7 +6,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.importer.tag.common.CoverageTagParser; @@ -33,8 +33,8 @@ final class GherkinLineConsumer implements LineConsumer private final InputFile file; private final ImportEventListener listener; private final LineConsumer coverageTagParser; - private SpecificationItemId pendingId; - private Set coveredIds = new LinkedHashSet<>(); + private LocatedSpecificationItemId pendingId; + private Set coveredIds = new LinkedHashSet<>(); private Set neededArtifactTypes = new LinkedHashSet<>(); private boolean hasNeedsDirective; private boolean metadataRegion; @@ -91,13 +91,13 @@ private void readMetadata(final int lineNumber, final String line) { if (line.trim().startsWith("@")) { - readTagRegion(lineNumber, line.trim()); + readTagRegion(lineNumber, line); return; } final Matcher directive = OFT_DIRECTIVE.matcher(line); if (this.metadataRegion && directive.matches()) { - readDirective(lineNumber, directive.group(1), directive.group(2)); + readDirective(lineNumber, line, directive.group(1), directive.group(2)); return; } if (!line.trim().startsWith("#")) @@ -106,7 +106,7 @@ private void readMetadata(final int lineNumber, final String line) } } - private void readTagRegion(final int lineNumber, final String tags) + private void readTagRegion(final int lineNumber, final String line) { if (!this.tagRegion) { @@ -118,6 +118,7 @@ private void readTagRegion(final int lineNumber, final String tags) { return; } + final String tags = line.trim(); final Matcher matcher = ID_TAG.matcher(tags); while (matcher.find()) { @@ -126,7 +127,7 @@ private void readTagRegion(final int lineNumber, final String tags) invalidateMetadata(lineNumber, "multiple @id tags before a scenario"); return; } - this.pendingId = parseId(lineNumber, matcher.group(1)); + this.pendingId = locatedId(lineNumber, line.indexOf(tags) + matcher.start(1), matcher.group(1)); if (this.pendingId == null) { return; @@ -135,7 +136,7 @@ private void readTagRegion(final int lineNumber, final String tags) } } - private void readDirective(final int lineNumber, final String name, final String values) + private void readDirective(final int lineNumber, final String line, final String name, final String values) { this.tagRegion = false; if (this.invalidMetadata) @@ -156,7 +157,7 @@ private void readDirective(final int lineNumber, final String name, final String final String[] entries = splitValues(lineNumber, name, values); if (covers) { - readCoveredIds(lineNumber, entries); + readCoveredIds(lineNumber, line, entries); return; } readNeededArtifactTypes(lineNumber, entries); @@ -173,8 +174,9 @@ private String[] splitValues(final int lineNumber, final String name, final Stri return values.trim().split(",", -1); } - private void readCoveredIds(final int lineNumber, final String[] entries) + private void readCoveredIds(final int lineNumber, final String line, final String[] entries) { + int searchStart = 0; for (final String entry : entries) { final String value = requireValue(lineNumber, "Covers", entry); @@ -182,16 +184,19 @@ private void readCoveredIds(final int lineNumber, final String[] entries) { return; } + final int column = line.indexOf(value, searchStart); + searchStart = column + value.length(); final SpecificationItemId id = parseId(lineNumber, value); if (id == null) { return; } - if (!this.coveredIds.add(id)) + if (this.coveredIds.stream().anyMatch(locatedId -> locatedId.getId().equals(id))) { invalidateMetadata(lineNumber, "Covers directive contains duplicate value '" + id + "'"); return; } + this.coveredIds.add(locatedId(lineNumber, column, value, id)); } } @@ -285,4 +290,29 @@ private void invalidateMetadata(final int lineNumber, final String reason) this.neededArtifactTypes.clear(); this.invalidMetadata = true; } + + private static LocatedSpecificationItemId locatedId(final int lineNumber, final int column, final String value) + { + return locatedId(lineNumber, column, value, SpecificationItemId.parseId(value)); + } + + private static LocatedSpecificationItemId locatedId(final int lineNumber, final int column, final String value, + final SpecificationItemId id) + { + // [impl->dsn~located-specification-item-id-text-ranges~1] + final int line = lineNumber - 1; + final SourceRange range = new SourceRange(new SourcePosition(line, column), + new SourcePosition(line, column + value.length())); + final int artifactEnd = value.indexOf('~'); + final int revisionStart = value.lastIndexOf('~') + 1; + return LocatedSpecificationItemId.builder().id(id).range(range) + .artifactTypeRange(componentRange(line, column, column + artifactEnd)) + .nameRange(componentRange(line, column + artifactEnd + 1, column + revisionStart - 1)) + .revisionRange(componentRange(line, column + revisionStart, column + value.length())).build(); + } + + private static SourceRange componentRange(final int line, final int start, final int end) + { + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); + } } diff --git a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java index 3ee4d60a3..5c46b47d6 100644 --- a/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java +++ b/importer/gherkin/src/test/java/org/itsallcode/openfasttrace/importer/gherkin/GherkinImporterTest.java @@ -13,8 +13,7 @@ import java.util.List; import java.util.stream.Stream; -import org.itsallcode.openfasttrace.api.core.SpecificationItem; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; @@ -94,6 +93,27 @@ void testImportsMultipleCoversDirectives() hasToString("req~login~1"), hasToString("req~security~1"))); } + // [utest->dsn~located-specification-item-id-text-ranges~1] + @Test + void testImportsLocatedScenarioAndCoversIds() + { + final SpecificationItem item = importText(""" + @tag @id:scn~login~1 + # Covers: req~login~1, req~security~2 + Scenario: Login + """).get(0); + + assertAll( + () -> assertThat(item.getLocatedId().getRange(), is(range(0, 9, 20))), + () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(1, 10, 21))), + () -> assertThat(item.getLocatedCoveredIds().get(1).getRange(), is(range(1, 23, 37)))); + } + + private static SourceRange range(final int line, final int start, final int end) + { + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); + } + // [utest->dsn~gherkin.streaming-import~1] // [utest->dsn~gherkin.id-detection~1] // [utest->dsn~gherkin.needs-metadata-validation~1] @@ -315,7 +335,7 @@ void testImportsCommentCoverageTagsWhileScenarioIsOpen() final InOrder events = inOrder(listener); events.verify(listener).beginSpecificationItem(); - events.verify(listener).setId(SpecificationItemId.parseId("scn~ordinary~1")); + events.verify(listener).setId(any(LocatedSpecificationItemId.class)); events.verify(listener).addSpecificationItem(any(SpecificationItem.class)); events.verify(listener).endSpecificationItem(); } 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 415f5b026..68bcbcbb3 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 @@ -138,7 +138,7 @@ protected void informListenerAboutNewItem() final String idText = this.stateMachine.getLastToken(); final SpecificationItemId id = new SpecificationItemId.Builder(idText).build(); this.listener.beginSpecificationItem(); - this.listener.setId(id); + this.listener.setId(locatedId(idText, id)); this.listener.setLocation(this.file.getPath(), this.currentContext.lineNumber()); if (this.lastTitle != null) { @@ -226,7 +226,7 @@ protected void addDependency() { final SpecificationItemId.Builder builder = new SpecificationItemId.Builder( this.stateMachine.getLastToken()); - this.listener.addDependsOnId(builder.build()); + this.listener.addDependsOnId(locatedId(this.stateMachine.getLastToken(), builder.build())); } /** @@ -264,7 +264,28 @@ protected void resetTitle() */ protected void addCoverage() { - this.listener.addCoveredId(SpecificationItemId.parseId(this.stateMachine.getLastToken())); + final String idText = this.stateMachine.getLastToken(); + this.listener.addCoveredId(locatedId(idText, SpecificationItemId.parseId(idText))); + } + + private LocatedSpecificationItemId locatedId(final String idText, final SpecificationItemId id) + { + // [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); + final int revisionStart = idText.lastIndexOf(SpecificationItemId.REVISION_SEPARATOR) + 1; + return LocatedSpecificationItemId.builder().id(id).range(range) + .artifactTypeRange(range(column, column + artifactTypeEnd)) + .nameRange(range(column + artifactTypeEnd + 1, column + revisionStart - 1)) + .revisionRange(range(column + revisionStart, column + idText.length())).build(); + } + + private SourceRange range(final int start, final int end) + { + final int line = this.currentContext.lineNumber() - 1; + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); } /** 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 8edf8fbbc..b28c4eced 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 @@ -1,12 +1,19 @@ package org.itsallcode.openfasttrace.importer.markdown; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.is; import static org.itsallcode.matcher.auto.AutoMatcher.contains; import static org.itsallcode.openfasttrace.api.core.SpecificationItemId.createId; import static org.itsallcode.openfasttrace.testutil.core.ItemBuilderFactory.item; +import static org.junit.jupiter.api.Assertions.assertAll; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import java.nio.file.Path; +import java.util.List; + +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.*; +import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; import org.itsallcode.openfasttrace.testutil.importer.lightweightmarkup.AbstractLightWeightMarkupImporterTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -64,6 +71,34 @@ void testIgnoresCoverageTagsOutsideStandaloneHtmlComments(final String line) assertImport("guide.md", line, emptyIterable()); } + // [utest->dsn~located-specification-item-id-text-ranges~1] + @Test + void testImportsLocatedDeclarationCoverageAndDependencyIds() + { + 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(2, 2, 15))), + () -> assertThat(item.getLocatedDependOnIds().get(0).getRange(), is(range(4, 2, 18)))); + } + + private static List importText(final String source) + { + return ImportAssertions.runImporterOnText(Path.of("located.md"), source, importerFactory); + } + + private static SourceRange range(final int line, final int start, final int end) + { + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); + } + protected String formatTitle(final String title, final int level) { return "#".repeat(level) + " " + title; 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 f6a91f56a..f861a534b 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 @@ -1,12 +1,20 @@ package org.itsallcode.openfasttrace.importer.restructuredtext; +import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.emptyIterable; +import static org.hamcrest.Matchers.is; import static org.itsallcode.matcher.auto.AutoMatcher.contains; import static org.itsallcode.openfasttrace.api.core.SpecificationItemId.createId; import static org.itsallcode.openfasttrace.testutil.core.ItemBuilderFactory.item; +import static org.junit.jupiter.api.Assertions.assertAll; +import java.nio.file.Path; +import java.util.List; + +import org.itsallcode.openfasttrace.api.core.SpecificationItem; import org.itsallcode.openfasttrace.api.core.SpecificationItemId; import org.itsallcode.openfasttrace.api.importer.*; +import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; import org.itsallcode.openfasttrace.testutil.importer.lightweightmarkup.AbstractLightWeightMarkupImporterTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -74,6 +82,29 @@ void testIgnoresCoverageTagsInMultilineComments() """.formatted(FULL_COVERAGE_TAG), emptyIterable()); } + // [utest->dsn~located-specification-item-id-text-ranges~1] + @Test + void testImportsLocatedDeclarationCoverageAndDependencyIds() + { + final SpecificationItem item = importText(""" + req~subject~1 + Covers: + * req~covered~2 + Depends: + * req~dependency~3 + """).get(0); + + assertAll( + () -> assertThat(item.getId(), is(createId("req", "subject", 1))), + () -> assertThat(item.getCoveredIds(), contains(createId("req", "covered", 2))), + () -> assertThat(item.getDependOnIds(), contains(createId("req", "dependency", 3)))); + } + + private static List importText(final String source) + { + return ImportAssertions.runImporterOnText(Path.of("located.rst"), source, importerFactory); + } + protected String formatTitle(final String title, final int level) { return title + "\n" + "=".repeat(title.length()); diff --git a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/DependenciesHandlerBuilder.java b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/DependenciesHandlerBuilder.java index c7e6fe923..d703a517c 100644 --- a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/DependenciesHandlerBuilder.java +++ b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/DependenciesHandlerBuilder.java @@ -1,6 +1,7 @@ package org.itsallcode.openfasttrace.importer.specobject.handler; import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.LocatedSpecificationItemId; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.importer.xmlparser.tree.CallbackContentHandler; import org.itsallcode.openfasttrace.importer.xmlparser.tree.TreeContentHandler; @@ -18,8 +19,10 @@ class DependenciesHandlerBuilder TreeContentHandler build() { + // [impl->dsn~located-specification-item-id-specobject~1] this.handler.addCharacterDataListener("dependson", - data -> this.listener.addDependsOnId(SpecificationItemId.parseId(data))); + data -> this.listener.addDependsOnId(LocatedSpecificationItemId.builder() + .id(SpecificationItemId.parseId(data)).build())); return this.handler; } } diff --git a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/ProvidesCoverageHandlerBuilder.java b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/ProvidesCoverageHandlerBuilder.java index 6e4d79f45..bd650a234 100644 --- a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/ProvidesCoverageHandlerBuilder.java +++ b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/ProvidesCoverageHandlerBuilder.java @@ -1,6 +1,7 @@ package org.itsallcode.openfasttrace.importer.specobject.handler; import org.itsallcode.openfasttrace.api.core.SpecificationItemId.Builder; +import org.itsallcode.openfasttrace.api.core.LocatedSpecificationItemId; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.importer.xmlparser.tree.CallbackContentHandler; import org.itsallcode.openfasttrace.importer.xmlparser.tree.TreeContentHandler; @@ -22,7 +23,9 @@ TreeContentHandler build() this.handler.addElementListener("provcov", elem -> this.providesCoverageIdBuilder = new Builder(), // endElem -> { - this.listener.addCoveredId(this.providesCoverageIdBuilder.build()); + // [impl->dsn~located-specification-item-id-specobject~1] + this.listener.addCoveredId(LocatedSpecificationItemId.builder() + .id(this.providesCoverageIdBuilder.build()).build()); this.providesCoverageIdBuilder = null; }); diff --git a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/SpecObjectsHandlerBuilder.java b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/SpecObjectsHandlerBuilder.java index e96506ded..0a16deda7 100644 --- a/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/SpecObjectsHandlerBuilder.java +++ b/importer/specobject/src/main/java/org/itsallcode/openfasttrace/importer/specobject/handler/SpecObjectsHandlerBuilder.java @@ -1,6 +1,7 @@ package org.itsallcode.openfasttrace.importer.specobject.handler; import org.itsallcode.openfasttrace.api.core.Location; +import org.itsallcode.openfasttrace.api.core.LocatedSpecificationItemId; import org.itsallcode.openfasttrace.api.core.SpecificationItemId; import org.itsallcode.openfasttrace.api.core.SpecificationItemId.Builder; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; @@ -48,7 +49,8 @@ private void handleStartElement(final TreeElement elem) private void handleEndElement() { - this.listener.setId(this.idBuilder.build()); + // [impl->dsn~located-specification-item-id-specobject~1] + this.listener.setId(LocatedSpecificationItemId.builder().id(this.idBuilder.build()).build()); this.listener.setLocation(this.locationBuilder.build()); this.listener.endSpecificationItem(); this.idBuilder = null; diff --git a/importer/specobject/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImporter.java b/importer/specobject/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImporter.java index 5ce21cee0..877f3d973 100644 --- a/importer/specobject/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImporter.java +++ b/importer/specobject/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImporter.java @@ -85,8 +85,8 @@ void testImportSeesTheCorrectId(final String input, final String expectedId) { final ImportEventListener listenerMock = importFromString(input); verify(listenerMock).beginSpecificationItem(); + verify(listenerMock).setId(locatedId(expectedId)); verify(listenerMock).setLocation(TestSpecobjectImporter.STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId(expectedId)); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -103,6 +103,11 @@ private ImportEventListener importFromString(final String text) return listenerMock; } + private static LocatedSpecificationItemId locatedId(final String id) + { + return LocatedSpecificationItemId.builder().id(SpecificationItemId.parseId(id)).build(); + } + @Test void testImportOfComplexSpecObject() { @@ -122,13 +127,13 @@ void testImportOfComplexSpecObject() """); verify(listenerMock).beginSpecificationItem(); - verify(listenerMock).setLocation(STANDARD_LOCATION); verify(listenerMock).setStatus(ItemStatus.DRAFT); - verify(listenerMock).setId(SpecificationItemId.parseId("req~complex~2")); + verify(listenerMock).setId(locatedId("req~complex~2")); verify(listenerMock).setTitle("my short description"); verify(listenerMock).appendDescription("multiline description\none more line"); verify(listenerMock).appendRationale("multiline rationale\nand another line"); verify(listenerMock).appendComment("multiline comment\nyet another line"); + verify(listenerMock).setLocation(STANDARD_LOCATION); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -145,9 +150,9 @@ void testImportOnlyShortDescription() """); verify(listenerMock).beginSpecificationItem(); - verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("req~complex~2")); + verify(listenerMock).setId(locatedId("req~complex~2")); verify(listenerMock).setTitle("My item title"); + verify(listenerMock).setLocation(STANDARD_LOCATION); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -163,8 +168,8 @@ void testStripSuperfluousArtifactPrefixFromName() """); verify(listenerMock).beginSpecificationItem(); + verify(listenerMock).setId(locatedId("impl~strip_duplicate_prefix~0")); verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("impl~strip_duplicate_prefix~0")); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -183,8 +188,8 @@ void testTakeOverLocationFromImportedFile() + " \n" // + ""); verify(listenerMock).beginSpecificationItem(); + verify(listenerMock).setId(locatedId("utest~takeOverLocation~99999999")); verify(listenerMock).setLocation(Location.create(expectedFileName, expectedLine)); - verify(listenerMock).setId(SpecificationItemId.parseId("utest~takeOverLocation~99999999")); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -204,10 +209,10 @@ void testImportWithTags() """); verify(listenerMock).beginSpecificationItem(); - verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("itest~with-tags~1")); + verify(listenerMock).setId(locatedId("itest~with-tags~1")); verify(listenerMock).addTag("tag 1"); verify(listenerMock).addTag("tag 2"); + verify(listenerMock).setLocation(STANDARD_LOCATION); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -227,10 +232,10 @@ void testImportWithNeedsCoverage() """); verify(listenerMock).beginSpecificationItem(); - verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("req~with-needs-coverage~1")); + verify(listenerMock).setId(locatedId("req~with-needs-coverage~1")); verify(listenerMock).addNeededArtifactType("impl"); verify(listenerMock).addNeededArtifactType("utest"); + verify(listenerMock).setLocation(STANDARD_LOCATION); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -250,10 +255,10 @@ void testImportWithDependencies() """); verify(listenerMock).beginSpecificationItem(); + verify(listenerMock).addDependsOnId(locatedId("req~dep-a~1")); + verify(listenerMock).addDependsOnId(locatedId("req~dep-b~2")); + verify(listenerMock).setId(locatedId("req~with-dependencies~1")); verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("req~with-dependencies~1")); - verify(listenerMock).addDependsOnId(SpecificationItemId.parseId("req~dep-a~1")); - verify(listenerMock).addDependsOnId(SpecificationItemId.parseId("req~dep-b~2")); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } @@ -279,10 +284,10 @@ void testImportProvidesCoverage() """); verify(listenerMock).beginSpecificationItem(); + verify(listenerMock).addCoveredId(locatedId("feat~provides-a~1")); + verify(listenerMock).addCoveredId(locatedId("feat~provides-b~2")); + verify(listenerMock).setId(locatedId("req~with-fulfilled-by~1")); verify(listenerMock).setLocation(STANDARD_LOCATION); - verify(listenerMock).setId(SpecificationItemId.parseId("req~with-fulfilled-by~1")); - verify(listenerMock).addCoveredId(SpecificationItemId.parseId("feat~provides-a~1")); - verify(listenerMock).addCoveredId(SpecificationItemId.parseId("feat~provides-b~2")); verify(listenerMock).endSpecificationItem(); verifyNoMoreInteractions(listenerMock); } diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java index 6290593a9..0aeb24bc8 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/LongTagImportingLineConsumer.java @@ -7,8 +7,7 @@ import java.util.logging.Logger; import java.util.regex.Matcher; -import org.itsallcode.openfasttrace.api.core.SpecificationItem; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.api.importer.input.InputFile; @@ -17,9 +16,11 @@ // [impl->dsn~import.full-coverage-tag-multiple-needed-coverage~1] class LongTagImportingLineConsumer extends AbstractRegexLineConsumer { - private static final Logger LOG = Logger - .getLogger(LongTagImportingLineConsumer.class.getName()); - + private static final Logger LOG = Logger.getLogger(LongTagImportingLineConsumer.class.getName()); + private static final String ARTIFACT_TYPE_GROUP = "artifactType"; + private static final String CUSTOM_NAME_GROUP = "customName"; + private static final String REVISION_GROUP = "revision"; + private static final String COVERED_IDS_GROUP = "coveredIds"; private static final String COVERING_ARTIFACT_TYPE_PATTERN = "\\p{Alpha}+"; // [impl->dsn~import.full-coverage-tag-with-revision~1] private static final String OPTIONAL_WHITESPACE = "\\s*"; @@ -32,14 +33,14 @@ class LongTagImportingLineConsumer extends AbstractRegexLineConsumer + "(?\\p{Alpha}+(?:" + OPTIONAL_WHITESPACE + "," + OPTIONAL_WHITESPACE + "\\p{Alpha}+)*)"; private static final String TAG_REGEX = TAG_PREFIX + OPTIONAL_WHITESPACE - + "(?" + COVERING_ARTIFACT_TYPE_PATTERN + ")" + + "(?<" + ARTIFACT_TYPE_GROUP + ">" + COVERING_ARTIFACT_TYPE_PATTERN + ")" + "(?:" + SpecificationItemId.ARTIFACT_TYPE_SEPARATOR // [impl->dsn~import.full-coverage-tag-with-name-and-revision~1] - + "(?" + SpecificationItemId.ITEM_NAME_PATTERN + ")?" + + "(?<" + CUSTOM_NAME_GROUP + ">" + SpecificationItemId.ITEM_NAME_PATTERN + ")?" + SpecificationItemId.REVISION_SEPARATOR - + "(?" + SpecificationItemId.ITEM_REVISION_PATTERN + "))?" + + "(?<" + REVISION_GROUP + ">" + SpecificationItemId.ITEM_REVISION_PATTERN + "))?" + OPTIONAL_WHITESPACE + "->" + OPTIONAL_WHITESPACE - + "(?" + COVERED_IDS + ")" + + "(?<" + COVERED_IDS_GROUP + ">" + COVERED_IDS + ")" + OPTIONAL_WHITESPACE + "(?:" + NEEDS_COVERAGE + OPTIONAL_WHITESPACE + ")?" + TAG_SUFFIX; @@ -56,7 +57,7 @@ class LongTagImportingLineConsumer extends AbstractRegexLineConsumer @Override public void processMatch(final Matcher matcher, final int lineNumber, final int lineMatchCount) { - final List coveredIds = parseCoveredIds(matcher.group("coveredIds")); + final List coveredIds = parseCoveredIds(matcher.group(COVERED_IDS_GROUP)); final List neededArtifactTypes = parseNeededArtifactTypes(matcher.group("neededArtifactTypes")); final List generatedIds = createItemIds(matcher, lineNumber, lineMatchCount, coveredIds, neededArtifactTypes); @@ -66,27 +67,69 @@ public void processMatch(final Matcher matcher, final int lineNumber, final int assert generatedIds.size() == coveredIds.size(); for (int i = 0; i < generatedIds.size(); i++) { - addSpecificationItem(lineNumber, generatedIds.get(i), List.of(coveredIds.get(i)), neededArtifactTypes); + addSpecificationItem(lineNumber, matcher, generatedIds.get(i), List.of(coveredIds.get(i)), + neededArtifactTypes); } } else { - addSpecificationItem(lineNumber, generatedIds.get(0), coveredIds, neededArtifactTypes); + addSpecificationItem(lineNumber, matcher, generatedIds.get(0), coveredIds, neededArtifactTypes); } } - private void addSpecificationItem(final int lineNumber, final SpecificationItemId generatedId, - final List coveredIds, final List neededArtifactTypes) + private void addSpecificationItem(final int lineNumber, final Matcher matcher, + final SpecificationItemId generatedId, final List coveredIds, + final List neededArtifactTypes) { final SpecificationItem.Builder item = SpecificationItem.builder() - .id(generatedId) + .id(locatedGeneratedId(lineNumber, matcher, generatedId)) .location(this.file.getPath(), lineNumber); - coveredIds.forEach(item::addCoveredId); + int searchStart = 0; + for (final SpecificationItemId coveredId : coveredIds) + { + final int start = matcher.group(COVERED_IDS_GROUP).indexOf(coveredId.toString(), searchStart); + searchStart = start + coveredId.toString().length(); + item.addCoveredId(locatedId(lineNumber, matcher.start(COVERED_IDS_GROUP) + start, coveredId)); + } neededArtifactTypes.forEach(item::addNeedsArtifactType); this.listener.addSpecificationItem(item.build()); logItem(lineNumber, coveredIds, neededArtifactTypes, generatedId); } + private static LocatedSpecificationItemId locatedGeneratedId(final int lineNumber, final Matcher matcher, + final SpecificationItemId id) + { + // [impl->dsn~located-specification-item-id-tag-ranges~1] + if (matcher.group(CUSTOM_NAME_GROUP) == null) + { + return LocatedSpecificationItemId.builder().id(id).build(); + } + final int start = matcher.start(ARTIFACT_TYPE_GROUP); + final int end = matcher.end(REVISION_GROUP); + return LocatedSpecificationItemId.builder().id(id).range(sourceRange(lineNumber, start, end)) + .artifactTypeRange(sourceRange(lineNumber, start, matcher.end(ARTIFACT_TYPE_GROUP))) + .nameRange(sourceRange(lineNumber, matcher.start(CUSTOM_NAME_GROUP), matcher.end(CUSTOM_NAME_GROUP))) + .revisionRange(sourceRange(lineNumber, matcher.start(REVISION_GROUP), matcher.end(REVISION_GROUP))) + .build(); + } + + private static LocatedSpecificationItemId locatedId(final int lineNumber, final int start, + final SpecificationItemId id) + { + final String text = id.toString(); + final int typeEnd = text.indexOf('~'); + final int revisionStart = text.lastIndexOf('~') + 1; + return LocatedSpecificationItemId.builder().id(id).range(sourceRange(lineNumber, start, start + text.length())) + .artifactTypeRange(sourceRange(lineNumber, start, start + typeEnd)) + .nameRange(sourceRange(lineNumber, start + typeEnd + 1, start + revisionStart - 1)) + .revisionRange(sourceRange(lineNumber, start + revisionStart, start + text.length())).build(); + } + + private static SourceRange sourceRange(final int lineNumber, final int start, final int end) + { + return new SourceRange(new SourcePosition(lineNumber - 1, start), new SourcePosition(lineNumber - 1, end)); + } + private static List parseCoveredIds(final String input) { if (input == null) @@ -116,9 +159,9 @@ private List createItemIds(final Matcher matcher, final int final int lineMatchCount, final List coveredIds, final List neededArtifactTypes) { - final String artifactType = matcher.group("artifactType"); - final String customName = matcher.group("customName"); - final String revision = matcher.group("revision"); + final String artifactType = matcher.group(ARTIFACT_TYPE_GROUP); + final String customName = matcher.group(CUSTOM_NAME_GROUP); + final String revision = matcher.group(REVISION_GROUP); if (customName != null) { return List.of(SpecificationItemId.createId(artifactType, customName, parseRevision(revision))); diff --git a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/ShortTagImportingLineConsumer.java b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/ShortTagImportingLineConsumer.java index d246dbe09..d3580f070 100644 --- a/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/ShortTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/main/java/org/itsallcode/openfasttrace/importer/tag/common/ShortTagImportingLineConsumer.java @@ -11,7 +11,8 @@ import org.itsallcode.openfasttrace.api.importer.tag.config.PathConfig; // [impl->dsn~import.short-coverage-tag~1] -class ShortTagImportingLineConsumer extends AbstractRegexLineConsumer { +class ShortTagImportingLineConsumer extends AbstractRegexLineConsumer +{ private static final Logger LOG = Logger.getLogger(ShortTagImportingLineConsumer.class.getName()); private static final String TAG_PREFIX = "\\[\\["; @@ -27,7 +28,8 @@ class ShortTagImportingLineConsumer extends AbstractRegexLineConsumer { private final InputFile file; ShortTagImportingLineConsumer(final PathConfig pathConfig, final InputFile file, - final ImportEventListener listener) { + final ImportEventListener listener) + { super(TAG_REGEX); this.pathConfig = pathConfig; this.file = file; @@ -35,7 +37,8 @@ class ShortTagImportingLineConsumer extends AbstractRegexLineConsumer { } @Override - void processMatch(final Matcher matcher, final int lineNumber, final int lineMatchCount) { + void processMatch(final Matcher matcher, final int lineNumber, final int lineMatchCount) + { final String coveredItemName = matcher.group(1); final String coveredItemRevision = matcher.group(2); final SpecificationItemId coveredId = createCoveredItem(coveredItemName, coveredItemRevision); @@ -52,27 +55,34 @@ void processMatch(final Matcher matcher, final int lineNumber, final int lineMat .build()); } - private SpecificationItemId createCoveredItem(final String name, final String revision) { + private SpecificationItemId createCoveredItem(final String name, final String revision) + { final int parsedRevision = parseRevision(name, revision); final String nameWithPrefix = getCoveredItemNamePrefix() + name; return SpecificationItemId.createId(this.pathConfig.getCoveredItemArtifactType(), nameWithPrefix, parsedRevision); } - private static int parseRevision(final String name, final String revision) { - try { + private static int parseRevision(final String name, final String revision) + { + try + { return Integer.parseInt(revision); - } catch (final NumberFormatException exception) { + } + catch (final NumberFormatException exception) + { throw new ImporterException("Error parsing revision '" + revision + "' for item '" + name + "'.", exception); } } - private String getCoveredItemNamePrefix() { + private String getCoveredItemNamePrefix() + { return this.pathConfig.getCoveredItemNamePrefix() != null ? this.pathConfig.getCoveredItemNamePrefix() : ""; } - private String generateName(final SpecificationItemId coveredId, final int lineNumber, final int counter) { + private String generateName(final SpecificationItemId coveredId, final int lineNumber, final int counter) + { final String uniqueName = this.file.toString() + lineNumber + counter + coveredId; final String checksum = Long.toString(ChecksumCalculator.calculateCrc32(uniqueName)); return coveredId.getName() + "-" + checksum; diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestCoverageTagParser.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestCoverageTagParser.java index e0086e9b9..ab7a90337 100644 --- a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestCoverageTagParser.java +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestCoverageTagParser.java @@ -8,8 +8,7 @@ import java.nio.file.Paths; import java.util.List; -import org.itsallcode.openfasttrace.api.core.SpecificationItem; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.api.importer.tag.config.PathConfig; @@ -17,11 +16,13 @@ import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; import org.junit.jupiter.api.Test; -class TestCoverageTagParser { +class TestCoverageTagParser +{ private static final String FILE = "source.file"; @Test - void importsFullTag() { + void importsFullTag() + { final SpecificationListBuilder listener = SpecificationListBuilder.create(); final LineConsumer parser = CoverageTagParser.create(null, inputFile(), listener); @@ -29,11 +30,12 @@ void importsFullTag() { parser.readLine(3, coverageTag); assertThat(listener.build(), equalTo(List.of(item(SpecificationItemId.parseId("impl~name~1"), 3, - List.of("dsn~covered~2"), List.of("utest"))))); + coverageTag, List.of("dsn~covered~2"), List.of("utest"))))); } @Test - void importsConfiguredShortTag() { + void importsConfiguredShortTag() + { final PathConfig config = pathConfig(); final SpecificationListBuilder listener = SpecificationListBuilder.create(); final LineConsumer parser = CoverageTagParser.create(config, inputFile(), listener); @@ -45,7 +47,8 @@ void importsConfiguredShortTag() { 2, List.of("req~prefix.covered~3"), List.of())))); } - private static PathConfig pathConfig() { + private static PathConfig pathConfig() + { return PathConfig.builder() .patternPathMatcher("glob:**") .coveredItemArtifactType("req") @@ -55,7 +58,8 @@ private static PathConfig pathConfig() { } private static SpecificationItem item(final SpecificationItemId id, final int lineNumber, - final List coveredIds, final List neededArtifactTypes) { + final List coveredIds, final List neededArtifactTypes) + { final SpecificationItem.Builder builder = SpecificationItem.builder() .id(id) .location(FILE, lineNumber); @@ -64,7 +68,51 @@ private static SpecificationItem item(final SpecificationItemId id, final int li return builder.build(); } - private static InputFile inputFile() { + private static SpecificationItem item(final SpecificationItemId id, final int lineNumber, + final String tag, final List coveredIds, final List neededArtifactTypes) + { + final SpecificationItem.Builder builder = SpecificationItem.builder() + .location(FILE, lineNumber); + if (tag.contains(id.toString())) + { + builder.id(locatedId(lineNumber, tag.indexOf(id.toString()), id)); + } + else + { + builder.id(id); + } + coveredIds.stream().map(SpecificationItemId::parseId) + .map(coveredId -> locatedCoveredId(lineNumber, tag, coveredId)) + .forEach(builder::addCoveredId); + neededArtifactTypes.forEach(builder::addNeedsArtifactType); + return builder.build(); + } + + private static LocatedSpecificationItemId locatedCoveredId(final int lineNumber, final String tag, + final SpecificationItemId id) + { + return locatedId(lineNumber, tag.indexOf(id.toString()), id); + } + + private static LocatedSpecificationItemId locatedId(final int lineNumber, final int start, + final SpecificationItemId id) + { + final String text = id.toString(); + final int typeEnd = text.indexOf('~'); + final int revisionStart = text.lastIndexOf('~') + 1; + return LocatedSpecificationItemId.builder().id(id).range(range(lineNumber - 1, start, start + text.length())) + .artifactTypeRange(range(lineNumber - 1, start, start + typeEnd)) + .nameRange(range(lineNumber - 1, start + typeEnd + 1, start + revisionStart - 1)) + .revisionRange(range(lineNumber - 1, start + revisionStart, start + text.length())).build(); + } + + private static SourceRange range(final int line, final int start, final int end) + { + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); + } + + private static InputFile inputFile() + { return StreamInput.forReader(Paths.get(FILE), new BufferedReader(new StringReader(""))); } } diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java index a5236ffca..43f259330 100644 --- a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestLongTagImportingLineConsumer.java @@ -1,7 +1,8 @@ package org.itsallcode.openfasttrace.importer.tag.common; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertAll; import java.io.BufferedReader; import java.io.StringReader; @@ -9,59 +10,130 @@ import java.util.List; import java.util.stream.Stream; -import org.itsallcode.openfasttrace.api.core.SpecificationItem; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -class TestLongTagImportingLineConsumer { +class TestLongTagImportingLineConsumer +{ private static final String FILE = "source.file"; - static Stream tagImportingTests() { + static Stream tagImportingTests() + { + final String namedTag = "[impl~tag~1 -> dsn~first~2, dsn~second~3 >> utest, itest" + "]"; + final String generatedNameTag = "[impl -> dsn~covered~2" + "]"; + final String readableNamesTag = "[impl -> dsn~first~2, dsn~second~3 >> utest" + "]"; return Stream.of( // [utest->dsn~import.full-coverage-tag-with-name-and-revision~1] // [utest->dsn~import.full-coverage-tag-with-needed-coverage~1] // [utest->dsn~import.full-coverage-tag-multiple-needed-coverage~1] - testCase(3, "[impl~tag~1 -> dsn~first~2, dsn~second~3 >> utest, itest" + "]", - item("impl~tag~1", 3, List.of("dsn~first~2", "dsn~second~3"), + testCase(3, namedTag, + item("impl~tag~1", 3, namedTag, List.of("dsn~first~2", "dsn~second~3"), List.of("utest", "itest"))), // [utest->dsn~import.full-coverage-tag~1] - testCase(4, "[impl -> dsn~covered~2" + "]", - item("impl~covered-3014110766~0", 4, List.of("dsn~covered~2"), List.of())), + testCase(4, generatedNameTag, + item("impl~covered-3014110766~0", 4, generatedNameTag, List.of("dsn~covered~2"), + List.of())), // [utest->dsn~import.full-coverage-tag-with-needed-coverage-readable-names~1] - testCase(5, "[impl -> dsn~first~2, dsn~second~3 >> utest" + "]", - item("impl~first~0", 5, List.of("dsn~first~2"), List.of("utest")), - item("impl~second~0", 5, List.of("dsn~second~3"), List.of("utest")))); + testCase(5, readableNamesTag, + item("impl~first~0", 5, readableNamesTag, List.of("dsn~first~2"), List.of("utest")), + item("impl~second~0", 5, readableNamesTag, List.of("dsn~second~3"), List.of("utest")))); } - static Arguments testCase(final int lineNumber, final String tag, final SpecificationItem... expectedItems) { + static Arguments testCase(final int lineNumber, final String tag, final SpecificationItem... expectedItems) + { return Arguments.of(lineNumber, tag, List.of(expectedItems)); } @ParameterizedTest @MethodSource("tagImportingTests") - void importsLongTag(final int lineNumber, final String tag, final List expectedItems) { + void importsLongTag(final int lineNumber, final String tag, final List expectedItems) + { final SpecificationListBuilder listener = SpecificationListBuilder.create(); final LongTagImportingLineConsumer consumer = new LongTagImportingLineConsumer(inputFile(), listener); consumer.readLine(lineNumber, tag); assertThat(listener.build(), equalTo(expectedItems)); } - private static SpecificationItem item(final String id, final int lineNumber, final List coveredIds, - final List neededArtifactTypes) { - final SpecificationItem.Builder builder = SpecificationItem.builder() - .id(SpecificationItemId.parseId(id)) - .location(FILE, lineNumber); - coveredIds.stream().map(SpecificationItemId::parseId).forEach(builder::addCoveredId); + // [utest->dsn~located-specification-item-id-tag-ranges~1] + @Test + void importsLocatedLongTagIds() + { + final SpecificationListBuilder listener = SpecificationListBuilder.create(); + new LongTagImportingLineConsumer(inputFile(), listener).readLine(3, + "😀 [impl~tag~1 -> dsn~covered~2" + "]"); + + final SpecificationItem item = listener.build().get(0); + + assertAll( + () -> assertThat(item.getLocatedId().getRange(), is(range(2, 4, 14))), + () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(2, 18, 31)))); + } + + // [utest->dsn~located-specification-item-id-tag-ranges~1] + @Test + void importsLocatedLongTagIdWithGeneratedName() + { + final SpecificationListBuilder listener = SpecificationListBuilder.create(); + new LongTagImportingLineConsumer(inputFile(), listener).readLine(3, + "😀 [impl -> dsn~covered~2" + "]"); + + final SpecificationItem item = listener.build().get(0); + + assertAll( + () -> assertThat(item.getLocatedId().getRange(), is(nullValue())), + () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is(range(2, 12, 25)))); + } + + private static SourceRange range(final int line, final int start, final int end) + { + return new SourceRange(new SourcePosition(line, start), new SourcePosition(line, end)); + } + + private static SpecificationItem item(final String id, final int lineNumber, final String tag, + final List coveredIds, + final List neededArtifactTypes) + { + final SpecificationItemId specificationItemId = SpecificationItemId.parseId(id); + final SpecificationItem.Builder builder = SpecificationItem.builder().location(FILE, lineNumber); + if (tag.contains(id)) + { + builder.id(locatedId(lineNumber, tag.indexOf(id), specificationItemId)); + } + else + { + builder.id(specificationItemId); + } + int searchStart = 0; + for (final String coveredId : coveredIds) + { + final int start = tag.indexOf(coveredId, searchStart); + searchStart = start + coveredId.length(); + builder.addCoveredId(locatedId(lineNumber, start, SpecificationItemId.parseId(coveredId))); + } neededArtifactTypes.forEach(builder::addNeedsArtifactType); return builder.build(); } - private static InputFile inputFile() { + private static LocatedSpecificationItemId locatedId(final int lineNumber, final int start, + final SpecificationItemId id) + { + final String text = id.toString(); + final int typeEnd = text.indexOf('~'); + final int revisionStart = text.lastIndexOf('~') + 1; + return LocatedSpecificationItemId.builder().id(id).range(range(lineNumber - 1, start, start + text.length())) + .artifactTypeRange(range(lineNumber - 1, start, start + typeEnd)) + .nameRange(range(lineNumber - 1, start + typeEnd + 1, start + revisionStart - 1)) + .revisionRange(range(lineNumber - 1, start + revisionStart, start + text.length())).build(); + } + + private static InputFile inputFile() + { return StreamInput.forReader(Paths.get(FILE), new BufferedReader(new StringReader(""))); } } diff --git a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestShortTagImportingLineConsumer.java b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestShortTagImportingLineConsumer.java index b1f155466..0de90fdfa 100644 --- a/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestShortTagImportingLineConsumer.java +++ b/importer/tag-importer-common/src/test/java/org/itsallcode/openfasttrace/importer/tag/common/TestShortTagImportingLineConsumer.java @@ -20,10 +20,12 @@ import org.junit.jupiter.params.provider.MethodSource; // [utest->dsn~import.short-coverage-tag~1] -class TestShortTagImportingLineConsumer { +class TestShortTagImportingLineConsumer +{ private static final String FILE = "source.file"; - static Stream shortTagImportingTests() { + static Stream shortTagImportingTests() + { return Stream.of( testCase(3, "[[covered:2" + "]]", null, item("covered-3798966306", 3, "req~covered~2")), @@ -35,14 +37,16 @@ static Stream shortTagImportingTests() { } private static Arguments testCase(final int lineNumber, final String tag, final String coveredItemNamePrefix, - final SpecificationItem... expectedItems) { + final SpecificationItem... expectedItems) + { return Arguments.of(lineNumber, tag, coveredItemNamePrefix, List.of(expectedItems)); } @ParameterizedTest @MethodSource("shortTagImportingTests") void importsShortTag(final int lineNumber, final String tag, final String coveredItemNamePrefix, - final List expectedItems) { + final List expectedItems) + { final SpecificationListBuilder listener = SpecificationListBuilder.create(); final ShortTagImportingLineConsumer consumer = new ShortTagImportingLineConsumer( pathConfig(coveredItemNamePrefix), inputFile(), listener); @@ -52,7 +56,8 @@ void importsShortTag(final int lineNumber, final String tag, final String covere assertThat(listener.build(), equalTo(expectedItems)); } - private static PathConfig pathConfig(final String coveredItemNamePrefix) { + private static PathConfig pathConfig(final String coveredItemNamePrefix) + { return PathConfig.builder() .patternPathMatcher("glob:**") .coveredItemArtifactType("req") @@ -61,7 +66,8 @@ private static PathConfig pathConfig(final String coveredItemNamePrefix) { .build(); } - private static SpecificationItem item(final String tagItemName, final int lineNumber, final String coveredId) { + private static SpecificationItem item(final String tagItemName, final int lineNumber, final String coveredId) + { return SpecificationItem.builder() .id(SpecificationItemId.createId("utest", tagItemName)) .location(FILE, lineNumber) @@ -69,7 +75,8 @@ private static SpecificationItem item(final String tagItemName, final int lineNu .build(); } - private static InputFile inputFile() { + private static InputFile inputFile() + { return StreamInput.forReader(Paths.get(FILE), new BufferedReader(new StringReader(""))); } } diff --git a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java index c41c3413e..3cff01811 100644 --- a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java +++ b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporter.java @@ -18,6 +18,7 @@ import org.itsallcode.openfasttrace.api.importer.ImporterContext; import org.itsallcode.openfasttrace.api.importer.SpecificationListBuilder; import org.itsallcode.openfasttrace.api.importer.input.InputFile; +import org.itsallcode.openfasttrace.testutil.core.ItemBuilderFactory; import org.itsallcode.openfasttrace.testutil.importer.input.StreamInput; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -249,7 +250,8 @@ void testTagImporter(final String content, final List expecte assertThat(result, hasSize(expectedItems.size())); if (!expectedItems.isEmpty()) { - assertThat(result, AutoMatcher.contains(expectedItems.toArray(new SpecificationItem[0]))); + assertThat(result.stream().map(ItemBuilderFactory::withoutIdLocations).toList(), + AutoMatcher.contains(expectedItems.toArray(new SpecificationItem[0]))); } } diff --git a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterWithConfig.java b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterWithConfig.java index 103a9a394..e3da9043f 100644 --- a/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterWithConfig.java +++ b/importer/tag/src/test/java/org/itsallcode/openfasttrace/importer/tag/TestTagImporterWithConfig.java @@ -8,8 +8,7 @@ import java.nio.file.Path; import java.nio.file.Paths; -import org.itsallcode.openfasttrace.api.core.SpecificationItem; -import org.itsallcode.openfasttrace.api.core.SpecificationItemId; +import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImportEventListener; import org.itsallcode.openfasttrace.api.importer.ImporterException; import org.itsallcode.openfasttrace.api.importer.input.InputFile; @@ -67,14 +66,12 @@ void testFileWithoutMatchingTag() @Test void testFileWithNewTagFormatAlsoSupported() { - final String itemName = "coveredtype~coveredname~1"; // do not inline to - // avoid error in - // self-trace + final String itemName = "coveredtype~coveredname~1"; // do not inline to avoid error in self-trace runImport("[type->" + itemName + "]"); verify(this.listenerMock).addSpecificationItem(SpecificationItem.builder() .id(SpecificationItemId.createId("type", "coveredname" + "-3264583751", 0)) .location(FILE.toString(), 1) - .addCoveredId(SpecificationItemId.parseId(itemName)) + .addCoveredId(locatedId(SpecificationItemId.parseId(itemName), 7, 18, 19, 30, 31, 32)) .build()); } @@ -150,6 +147,23 @@ private void verifyTag(final int lineNumber, final SpecificationItemId coveredId .build()); } + private static LocatedSpecificationItemId locatedId(final SpecificationItemId id, final int start, + final int artifactTypeEnd, final int nameStart, final int nameEnd, final int revisionStart, + final int end) + { + return LocatedSpecificationItemId.builder().id(id) + .range(range(start, end)) + .artifactTypeRange(range(start, artifactTypeEnd)) + .nameRange(range(nameStart, nameEnd)) + .revisionRange(range(revisionStart, end)) + .build(); + } + + private static SourceRange range(final int start, final int end) + { + return new SourceRange(new SourcePosition(0, start), new SourcePosition(0, end)); + } + private void runImport(final String content) { final InputFile file = StreamInput.forReader(FILE, diff --git a/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/ITZipFileImporter.java b/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/ITZipFileImporter.java index d2744adee..98ad9bf37 100644 --- a/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/ITZipFileImporter.java +++ b/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/ITZipFileImporter.java @@ -139,7 +139,7 @@ void testImportZipWithInvalidEncoding() throws IOException addEntryToZip("file1.c", new byte[] { (byte) 0x9F, (byte) 0x88 }); final List importedFiles = runImporter(1); assertThat(importedFiles.get(0).getPath(), equalTo(this.zipFile.getPath() + "!file1.c")); - assertThat(this.actualFileContent.get(0), equalTo("��")); + assertThat(this.actualFileContent.get(0), equalTo("\uFFFD\uFFFD")); } private void addZipEntryDirectory(final String name) throws IOException diff --git a/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/input/TestZipEntryInput.java b/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/input/TestZipEntryInput.java index ffff9e4e0..601e67232 100644 --- a/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/input/TestZipEntryInput.java +++ b/importer/zip/src/test/java/org/itsallcode/openfasttrace/importer/zip/input/TestZipEntryInput.java @@ -121,13 +121,14 @@ void testReadInvalidEncoding() throws IOException { final InputFile inputFile = ZipEntryInput.forZipEntry(zip, new ZipEntry("file"), StandardCharsets.UTF_8); - assertThat(readContent(inputFile), equalTo("��")); + assertThat(readContent(inputFile), equalTo("\uFFFD\uFFFD")); } } private String readContent(final InputFile inputFile) throws IOException { - try(final BufferedReader reader = inputFile.createReader()) { + try (final BufferedReader reader = inputFile.createReader()) + { return reader.lines().collect(joining("\n")); } } diff --git a/parent/pom.xml b/parent/pom.xml index 648ce017f..bb1d55ce9 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -10,10 +10,10 @@ Free requirement tracking suite https://github.com/itsallcode/openfasttrace - 4.8.0 + 4.9.0 17 6.1.0-M1 - 6.1.1 + 6.1.2 3.5.6 UTF-8 UTF-8 @@ -289,7 +289,7 @@ org.apache.maven.plugins maven-toolchains-plugin - 3.2.0 + 3.3.0 @@ -449,7 +449,6 @@ org.codehaus.mojo flatten-maven-plugin - 1.7.3 oss @@ -584,7 +583,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.5.0 + 3.5.1 org.apache.maven.plugins @@ -599,7 +598,7 @@ org.codehaus.mojo flatten-maven-plugin - 1.7.3 + 1.8.0 true resolveCiFriendliesOnly diff --git a/pom.xml b/pom.xml index 14e96d6b3..e0449b846 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,6 @@ itsallcode https://sonarcloud.io - ${maven.multiModuleProjectDirectory}/**/target/site/jacoco/jacoco.xml testutil/src/main/java/org/itsallcode/openfasttrace/testutil/importer/lightweightmarkup/AbstractLightWeightMarkupImporterTest.java UTF-8 @@ -49,6 +48,11 @@ true + + org.sonarsource.scanner.maven + sonar-maven-plugin + 5.7.0.6970 + diff --git a/product/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImportExport.java b/product/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImportExport.java index f22f411f0..982322fdf 100644 --- a/product/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImportExport.java +++ b/product/src/test/java/org/itsallcode/openfasttrace/importer/specobject/TestSpecobjectImportExport.java @@ -1,6 +1,7 @@ package org.itsallcode.openfasttrace.importer.specobject; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; import static org.itsallcode.openfasttrace.testutil.core.TraceAssertions.assertTraceContainsDefectIds; import static org.itsallcode.openfasttrace.testutil.core.TraceAssertions.assertTraceSize; import static org.itsallcode.openfasttrace.testutil.core.TraceAssertions.getItemFromTraceForId; @@ -21,6 +22,28 @@ class TestSpecobjectImportExport { + // [utest->dsn~located-specification-item-id-specobject~1] + @Test + void testImportsLocatedIdsWithoutSourceRanges() + { + final SpecificationItem item = parse(""" + + + example1 + feat:covered2 + req:dependency, v3 + + """).get(0); + + assertAll( + () -> assertThat(item.getLocatedId().getRange(), is((SourceRange) null)), + () -> assertThat(item.getLocatedCoveredIds().get(0).getRange(), is((SourceRange) null)), + () -> assertThat(item.getLocatedDependOnIds().get(0).getRange(), is((SourceRange) null)), + () -> assertThat(item.getLocatedId().getArtifactTypeRange().isEmpty(), is(true)), + () -> assertThat(item.getLocatedCoveredIds().get(0).getNameRange().isEmpty(), is(true)), + () -> assertThat(item.getLocatedDependOnIds().get(0).getRevisionRange().isEmpty(), is(true))); + } + @Test void testTraceContent() { diff --git a/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/core/ItemBuilderFactory.java b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/core/ItemBuilderFactory.java index 800638910..84117f590 100644 --- a/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/core/ItemBuilderFactory.java +++ b/testutil/src/main/java/org/itsallcode/openfasttrace/testutil/core/ItemBuilderFactory.java @@ -41,4 +41,18 @@ public static SpecificationItem.Builder itemWithId(SpecificationItemId id) { public static SpecificationItem.Builder itemWithDefaultFilenameInLine(final int line) { return SpecificationItem.builder().location("file", line); } + + /** + * Creates a copy of an item without source locations on its IDs. + * + * @param item item to copy + * @return copy without source locations on its IDs + */ + public static SpecificationItem withoutIdLocations(final SpecificationItem item) { + return item.toBuilder() + .id(item.getId()) + .coveredIds(item.getCoveredIds()) + .dependOnIds(item.getDependOnIds()) + .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 cca11c292..6bcafea07 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 @@ -13,6 +13,7 @@ import org.hamcrest.Matcher; import org.itsallcode.openfasttrace.api.core.*; import org.itsallcode.openfasttrace.api.importer.ImporterFactory; +import org.itsallcode.openfasttrace.testutil.core.ItemBuilderFactory; import org.itsallcode.openfasttrace.testutil.importer.ImportAssertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -98,7 +99,10 @@ protected void assertImport(final String path, final String input, protected void assertImport(final Path path, final String input, final Matcher> matcher) { - ImportAssertions.assertImportWithFactory(path, processTextInput(input), matcher, getImporterFactory()); + final List importedItems = ImportAssertions.runImporterOnText(path, + processTextInput(input), getImporterFactory()); + assertThat(importedItems.stream().map(ItemBuilderFactory::withoutIdLocations) + .toList(), matcher); } private String processTextInput(final String input)