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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.arc_e_tect.gradle.gherkin;

import com.arc_e_tect.gradle.gherkin.glue.GlueCodeScanner;
import com.arc_e_tect.gradle.gherkin.indexing.FeatureIndexer;
import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode;
import com.arc_e_tect.gradle.gherkin.parser.FeatureParser;
import com.arc_e_tect.gradle.gherkin.parser.ScenarioGrouping;
import com.arc_e_tect.gradle.gherkin.parser.ScenarioInfo;
Expand Down Expand Up @@ -30,6 +32,7 @@
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -156,6 +159,17 @@ public abstract class GenerateFeatureDocsTask extends DefaultTask {
@Input
public abstract Property<String> getSystemUnderTestVersion();

/**
* Whether - and how - to number {@code Feature}/{@code Scenario} titles directly in the
* source {@code .feature} files. Only allowed when {@link #getIncludeSubDirs()} is
* {@code true}; when {@link #getGroupByFeature()} is {@code false}, only
* {@link IndexingMode#OFF} and {@link IndexingMode#SCENARIO} are allowed.
*
* @return mutable property for the indexing mode
*/
@Input
public abstract Property<IndexingMode> getIndexing();

/**
* Root directory of the project, used to resolve the default source directory
* when neither {@link #getSourceDirs()} nor {@link #getSourceFile()} is set.
Expand Down Expand Up @@ -208,10 +222,23 @@ public void generate() {
}
}

IndexingMode indexing = getIndexing().get();
boolean groupByFeature = getGroupByFeature().get();
if (indexing != IndexingMode.OFF && !getIncludeSubDirs().get()) {
throw new GradleException(
"gherkinToAsciidoc: indexing can only be used when includeSubDirs is true.");
}
if (indexing != IndexingMode.OFF && indexing != IndexingMode.SCENARIO && !groupByFeature) {
throw new GradleException(
"gherkinToAsciidoc: when groupByFeature is false, indexing can only be "
+ "'off' or 'scenario'.");
}

// trackProgress implies recursive scanning, regardless of includeSubDirs's own value.
boolean recursive = trackProgress || getIncludeSubDirs().get();

List<File> featureFiles = collectFeatureFiles(sourceDirsSet, sourceFileSet, recursive);
new FeatureIndexer().reindex(featureFiles, indexing);

List<ScenarioInfo> scenarios = new ArrayList<>();
FeatureParser featureParser = new FeatureParser();
Expand All @@ -231,10 +258,10 @@ public void generate() {
List<Expression> glueCode = scanGlueCode();
File template = getTemplate().isPresent() ? getTemplate().getAsFile().get() : null;
ProgressReportOptions options = new ProgressReportOptions(
getGroupByFeature().get(), getSnippetDir().getAsFile().get(), template, systemUnderTestVersion);
groupByFeature, getSnippetDir().getAsFile().get(), template, systemUnderTestVersion);
new ProgressReportWriter().write(outputFile, scenarios, glueCode, options);
} else {
writeAsciidoc(outputFile, scenarios, getGroupByFeature().get(), systemUnderTestVersion);
writeAsciidoc(outputFile, scenarios, groupByFeature, systemUnderTestVersion);
}

getLogger().lifecycle("Generated {} scenario title(s) to {}", scenarios.size(), outputFile);
Expand All @@ -254,7 +281,12 @@ private List<File> collectFeatureFiles(boolean sourceDirsSet, boolean sourceFile
if (sourceFileSet) {
files.add(getSourceFile().getAsFile().get());
} else if (sourceDirsSet) {
for (File dir : getSourceDirs()) {
// Ordered by path so that processing order (and thus indexing numbers, and the order
// scenarios appear in the generated report) is deterministic rather than
// filesystem-dependent, regardless of the order sourceDirs was configured in.
List<File> dirs = new ArrayList<>(getSourceDirs().getFiles());
dirs.sort(Comparator.comparing(File::getAbsolutePath));
for (File dir : dirs) {
collectFromDir(dir, files, recursive);
}
} else {
Expand All @@ -265,6 +297,12 @@ private List<File> collectFeatureFiles(boolean sourceDirsSet, boolean sourceFile
return files;
}

/**
* Collects {@code .feature} files from {@code dir} in pre-order: every {@code .feature} file
* directly in {@code dir} first (alphabetically by file name), then - when {@code recursive}
* is {@code true} - every direct sub-directory's own files, recursively, in the same fashion
* (sub-directories visited alphabetically by name).
*/
private void collectFromDir(File dir, List<File> files, boolean recursive) {
if (!dir.isDirectory()) {
return;
Expand All @@ -273,13 +311,24 @@ private void collectFromDir(File dir, List<File> files, boolean recursive) {
if (children == null) {
return;
}

List<File> featureFilesHere = new ArrayList<>();
List<File> subDirs = new ArrayList<>();
for (File child : children) {
if (child.isFile() && child.getName().endsWith(".feature")) {
files.add(child);
featureFilesHere.add(child);
} else if (child.isDirectory() && recursive) {
collectFromDir(child, files, true);
subDirs.add(child);
}
}

featureFilesHere.sort(Comparator.comparing(File::getName));
files.addAll(featureFilesHere);

subDirs.sort(Comparator.comparing(File::getName));
for (File subDir : subDirs) {
collectFromDir(subDir, files, true);
}
}

private void writeAsciidoc(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.arc_e_tect.gradle.gherkin;

import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode;
import org.gradle.api.file.ConfigurableFileCollection;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.file.RegularFileProperty;
Expand All @@ -11,15 +12,16 @@
* <pre>
* gherkinToAsciidoc {
* sourceDirs.from('src/test/resources/features') // default
* includeSubDirs = false // default
* includeSubDirs = true // default
* outputDir = layout.buildDirectory.dir('generated-docs') // default
* outputFileName = 'features.adoc' // default
* trackProgress = false // default
* // glueCodeDirs.from('src/test/java/.../steps') // required when trackProgress = true
* groupByFeature = false // default; forced to true whenever trackProgress = true
* groupByFeature = true // default; forced to true whenever trackProgress = true
* // snippetDir = layout.buildDirectory.dir('generated-docs/features/snippets') // default
* // template = file('templates/report.mustache') // optional
* // systemUnderTestVersion = 'v1.0.0' // optional; default: project.version
* indexing = IndexingMode.OFF // default; requires includeSubDirs = true
* }
* </pre>
*/
Expand Down Expand Up @@ -59,7 +61,7 @@ public GherkinToAsciidocExtension() {}

/**
* Whether to recursively scan sub-directories of every configured directory in
* {@link #getSourceDirs()}. Defaults to {@code false}. Forced to {@code true} whenever
* {@link #getSourceDirs()}. Defaults to {@code true}. Forced to {@code true} whenever
* {@link #getTrackProgress()} is {@code true}.
*
* @return mutable boolean property controlling recursive directory scanning
Expand Down Expand Up @@ -106,7 +108,7 @@ public GherkinToAsciidocExtension() {}

/**
* Whether to group scenarios by their enclosing {@code Feature} in the generated AsciiDoc,
* instead of a flat list. Defaults to {@code false}. Forced to {@code true} whenever
* instead of a flat list. Defaults to {@code true}. Forced to {@code true} whenever
* {@link #getTrackProgress()} is {@code true}, in which case scenarios are grouped by
* feature within each of the listed/defined/implemented sections.
*
Expand Down Expand Up @@ -148,4 +150,36 @@ public GherkinToAsciidocExtension() {}
* @return mutable string property for the system-under-test version
*/
public abstract Property<String> getSystemUnderTestVersion();

/**
* Whether - and how - to number {@code Feature}/{@code Scenario} titles directly in the
* source {@code .feature} files. Defaults to {@link IndexingMode#OFF}.
*
* <ul>
* <li>{@link IndexingMode#OFF} - nothing is numbered (default).</li>
* <li>{@link IndexingMode#FEATURE} - every feature is numbered, e.g.
* {@code Feature: 1 - User authentication}.</li>
* <li>{@link IndexingMode#SCENARIO} - every scenario is numbered continuously across all
* feature files, e.g. {@code Scenario: 1 - User logs in}.</li>
* <li>{@link IndexingMode#ALL} - both are numbered, scenarios as
* {@code <featureNumber>.<scenarioNumber>}, e.g. {@code Scenario: 1.1 - User logs in}.</li>
* </ul>
*
* <p>Feature files are processed in the same order the generated report lists them in: for each
* source directory (directories themselves ordered alphabetically by path when more than one is
* configured), that directory's own feature files first - alphabetically by file name - and only
* then, when {@link #getIncludeSubDirs()} is {@code true}, its sub-directories' files, each
* sub-directory visited the same way, alphabetically by name. Scenario numbers additionally
* follow document order within each file. Changing this property rewrites the source
* {@code .feature} files: any numbering left over from a previous run is removed first, then
* fresh numbering is applied for the new mode - including removing all numbering when set back
* to {@link IndexingMode#OFF}.</p>
*
* <p>Only allowed when {@link #getIncludeSubDirs()} is {@code true}. When
* {@link #getGroupByFeature()} is {@code false}, only {@link IndexingMode#OFF} and
* {@link IndexingMode#SCENARIO} are allowed.</p>
*
* @return mutable property for the indexing mode
*/
public abstract Property<IndexingMode> getIndexing();
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.arc_e_tect.gradle.gherkin;

import com.arc_e_tect.gradle.gherkin.indexing.IndexingMode;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.gradle.api.file.FileCollection;
Expand Down Expand Up @@ -28,9 +29,11 @@
* <h2>Defaults</h2>
* <ul>
* <li>Source directory: {@code src/test/resources/features}</li>
* <li>Include sub-directories: {@code false}</li>
* <li>Include sub-directories: {@code true}</li>
* <li>Group scenarios by feature: {@code true}</li>
* <li>Output directory: {@code build/generated-docs}</li>
* <li>Output file name: {@code features.adoc}</li>
* <li>Indexing: {@code off}</li>
* </ul>
*
* <h2>Multi-project builds</h2>
Expand Down Expand Up @@ -75,11 +78,13 @@ public void apply(Project project) {
ext.getOutputFileName().convention(rootExt.getOutputFileName());
ext.getTemplate().convention(rootExt.getTemplate());
ext.getSystemUnderTestVersion().convention(rootExt.getSystemUnderTestVersion());
ext.getIndexing().convention(rootExt.getIndexing());
} else {
ext.getTrackProgress().convention(false);
ext.getOutputFileName().convention(GherkinToAsciidocExtension.DEFAULT_OUTPUT_FILE_NAME);
ext.getSystemUnderTestVersion().convention(
project.provider(() -> String.valueOf(project.getVersion())));
ext.getIndexing().convention(IndexingMode.OFF);
}

// outputDir/snippetDir intentionally always default to this project's own build directory,
Expand All @@ -93,10 +98,11 @@ public void apply(Project project) {
// Enabling trackProgress implies recursive scanning and grouping by feature, unless
// includeSubDirs/groupByFeature have been set explicitly - either directly on this
// project, or (absent a local trackProgress override) inherited from the root project.
// Both default to true when neither this project nor the root project configures them.
Provider<Boolean> inheritedIncludeSubDirs =
rootExt != null ? rootExt.getIncludeSubDirs() : project.provider(() -> false);
rootExt != null ? rootExt.getIncludeSubDirs() : project.provider(() -> true);
Provider<Boolean> inheritedGroupByFeature =
rootExt != null ? rootExt.getGroupByFeature() : project.provider(() -> false);
rootExt != null ? rootExt.getGroupByFeature() : project.provider(() -> true);
ext.getIncludeSubDirs().convention(ext.getTrackProgress()
.flatMap(trackProgress -> trackProgress ? project.provider(() -> true) : inheritedIncludeSubDirs));
ext.getGroupByFeature().convention(ext.getTrackProgress()
Expand All @@ -115,6 +121,7 @@ public void apply(Project project) {
task.getSnippetDir().set(ext.getSnippetDir());
task.getTemplate().set(ext.getTemplate());
task.getSystemUnderTestVersion().set(ext.getSystemUnderTestVersion());
task.getIndexing().set(ext.getIndexing());
task.getProjectDirectory().set(project.getLayout().getProjectDirectory());
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.arc_e_tect.gradle.gherkin.indexing;

import org.gradle.api.GradleException;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Numbers {@code Feature}/{@code Scenario} titles directly in the source {@code .feature} files
* according to a configured {@link IndexingMode}.
*
* <p>Every run first strips any numbering left over from a previous run (recognised by the
* {@code <number> - } prefix this class itself adds), then - unless the mode is
* {@link IndexingMode#OFF} - applies fresh numbering. This makes the operation idempotent and
* makes switching between modes (including back to {@code OFF}) simply undo the previous
* numbering rather than requiring any state to be tracked between runs.</p>
*/
public class FeatureIndexer {

private static final Pattern KEYWORD_LINE =
Pattern.compile("^(\\s*)(Feature|Scenario Outline|Scenario):(\\s*)(.*)$");
private static final Pattern EXISTING_INDEX = Pattern.compile("^\\d+(?:\\.\\d+)? - (.*)$");

/** Creates a new {@code FeatureIndexer}. */
public FeatureIndexer() {}

/**
* Rewrites every file in {@code featureFiles} in place: strips any {@code Feature}/
* {@code Scenario} numbering added by a previous run, then applies numbering per
* {@code mode}. Files are numbered in the order they appear in {@code featureFiles} - the
* caller is responsible for ordering that list the way numbers should be assigned. A file is
* only rewritten on disk when its content actually changes.
*
* @param featureFiles the feature files collected for this run, in the order to number them in
* @param mode the indexing mode to apply
*/
public void reindex(List<File> featureFiles, IndexingMode mode) {
int featureNumber = 0;
int scenarioNumber = 0;
for (File featureFile : featureFiles) {
featureNumber++;
int scenarioInFeature = 0;
List<String> lines = readLines(featureFile);
List<String> rewritten = new ArrayList<>(lines.size());
boolean changed = false;

for (String line : lines) {
Matcher matcher = KEYWORD_LINE.matcher(line);
if (!matcher.matches()) {
rewritten.add(line);
continue;
}

String indent = matcher.group(1);
String keyword = matcher.group(2);
String gap = matcher.group(3);
String name = stripExistingIndex(matcher.group(4));

String newName;
if ("Feature".equals(keyword)) {
newName = (mode == IndexingMode.FEATURE || mode == IndexingMode.ALL)
? featureNumber + " - " + name
: name;
} else if (mode == IndexingMode.SCENARIO) {
scenarioNumber++;
newName = scenarioNumber + " - " + name;
} else if (mode == IndexingMode.ALL) {
scenarioInFeature++;
newName = featureNumber + "." + scenarioInFeature + " - " + name;
} else {
newName = name;
}

String newLine = indent + keyword + ":" + gap + newName;
changed |= !newLine.equals(line);
rewritten.add(newLine);
}

if (changed) {
writeLines(featureFile, rewritten);
}
}
}

private String stripExistingIndex(String name) {
Matcher matcher = EXISTING_INDEX.matcher(name);
return matcher.matches() ? matcher.group(1) : name;
}

private List<String> readLines(File file) {
try {
return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new GradleException("gherkinToAsciidoc: could not read feature file: " + file, e);
}
}

private void writeLines(File file, List<String> lines) {
try {
Files.write(file.toPath(), lines, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new GradleException("gherkinToAsciidoc: could not update feature file: " + file, e);
}
}
}
Loading
Loading