diff --git a/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java b/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java
index f0ed6851b..1906eb20b 100644
--- a/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java
+++ b/archunit/src/main/java/com/tngtech/archunit/core/domain/PackageMatcher.java
@@ -32,34 +32,49 @@
import static java.util.stream.Collectors.toList;
/**
- * Matches packages with a syntax similar to AspectJ. In particular '*' stands for any sequence of
- * characters but not the dot '.', while '..' stands for any sequence of packages, including zero packages.
+ * Matches packages with a syntax similar to AspectJ. In particular
+ *
+ * - {@code *} stands for any non-empty sequence of characters but not the dot '.', while
+ * - {@code ..} stands for any sequence of packages, including zero packages.
+ *
* For example
*
- * - {@code '..pack..'} matches {@code 'a.pack'}, {@code 'a.pack.b'} or {@code 'a.b.pack.c.d'},
- * but not {@code 'a.packa.b'}
- * - {@code '*.pack.*'} matches {@code 'a.pack.b'}, but not {@code 'a.b.pack.c'}
- * - {@code '..*pack*..'} matches {@code 'a.prepackfix.b'}
- * - {@code '*.*.pack*..'} matches {@code 'a.b.packfix.c.d'},
- * but neither {@code 'a.packfix.b'} nor {@code 'a.b.prepack.d'}
+ * - {@code "..pack.."} matches {@code a.pack}, {@code a.pack.b} or {@code a.b.pack.c.d},
+ * but not {@code a.packa.b}
+ * - {@code "*.pack.*"} matches {@code a.pack.b},
+ * but not {@code a.b.pack.c}
+ * - {@code "..*pack*.."} matches {@code a.prepackfix.b},
+ * but not {@code a.prepack.b}
+ * - {@code "*.*.pack*.."} matches {@code a.b.packfix.c.d},
+ * but neither {@code a.packfix.b} nor {@code a.b.prepack.d}
*
- * You can also use alternations with the '|' operator within brackets. For example
+ *
+ * You can also use alternations with the {@code |} operator within brackets. For example
*
- * - {@code 'pack.[a.c|b*].d'} matches {@code 'pack.a.c.d'} or {@code 'pack.bar.d'}, but neither
- * {@code 'pack.a.d'} nor {@code 'pack.b.c.d'}
+ * - {@code "pack.[a.c|b*].d"} matches {@code pack.a.c.d} or {@code pack.bar.d},
+ * but neither {@code pack.a.d} nor {@code pack.b.c.d}
*
*
- * Furthermore, the use of capturing groups is supported. In this case '(*)' matches any sequence of characters,
- * but not the dot '.', while '(**)' matches any sequence including the dot.
+ * Furthermore, the use of capturing groups is supported:
+ *
+ * - {@code (*)} matches any sequence of characters, * but not the dot '.', while
+ * - {@code (**)} matches any sequence including the dot.
+ *
* For example
*
- * - {@code '..service.(*)..'} matches {@code 'a.service.hello.b'} and group 1 would be {@code 'hello'}
- * - {@code '..service.(**)'} matches {@code 'a.service.hello.more'} and group 1 would be {@code 'hello.more'}
- * - {@code 'my.(*)..service.(**)'} matches {@code 'my.company.some.service.hello.more'}
- * and group 1 would be {@code 'company'}, while group 2 would be {@code 'hello.more'}
- * - {@code '..service.(a|b*)..'} matches {@code 'a.service.bar.more'} and group 1 would be {@code 'bar'}
+ * - {@code "..service.(*).."} matches {@code a.service.hello.b},
+ * and group 1 would be {@code "hello"}
+ * - {@code "..service.(**)"} matches {@code a.service.hello.more},
+ * and group 1 would be {@code "hello.more"}
+ * - {@code "my.(*)..service.(**)"} matches {@code my.company.some.service.hello.more},
+ * group 1 would be {@code "company"}, and group 2 would be {@code "hello.more"}
+ * - {@code "..service.(a|b*).."} matches {@code a.service.bar.more},
+ * and group 1 would be {@code "bar"}
*
- * Create via {@link PackageMatcher#of(String) PackageMatcher.of(packageIdentifier)}
+ *The segments matched by capturing groups can be retrieved from the result of {@link #match(String)}
+ * via {@link Result#getGroup(int)}.
+ *
+ * @see PackageMatcher#of(String) PackageMatcher.of(packageIdentifier)
*/
@PublicAPI(usage = ACCESS)
public final class PackageMatcher {
@@ -204,6 +219,11 @@ public int getNumberOfGroups() {
return matcher.groupCount();
}
+ /**
+ * @param number 1-based number of the capturing group
+ * @return The subsequence captured by the given group during the previous match operation.
+ * @throws IndexOutOfBoundsException if there is no capturing group in the pattern with the given number.
+ */
@PublicAPI(usage = ACCESS)
public String getGroup(int number) {
return matcher.group(number);
diff --git a/archunit/src/test/java/com/tngtech/archunit/core/domain/PackageMatcherTest.java b/archunit/src/test/java/com/tngtech/archunit/core/domain/PackageMatcherTest.java
index 1e1178d17..6dce0cfcf 100644
--- a/archunit/src/test/java/com/tngtech/archunit/core/domain/PackageMatcherTest.java
+++ b/archunit/src/test/java/com/tngtech/archunit/core/domain/PackageMatcherTest.java
@@ -89,16 +89,23 @@ public void match(String matcher, String target, boolean matches) {
"..[application|domain.*|infrastructure].(*).. , com.example.infrastructure.a.file , a"
})
public void capture_groups(String matcher, String target, String groupString) {
- assertThat(PackageMatcher.of(matcher).match(target).isPresent())
+ Optional match = PackageMatcher.of(matcher).match(target);
+ assertThat(match.isPresent())
.as("'%s' matching '%s'", matcher, target)
.isEqualTo(groupString != null);
- String[] groups = groupString != null ? groupString.split(":") : new String[0];
- for (int i = 0; i < groups.length; i++) {
- assertThat(PackageMatcher.of(matcher).match(target).get().getGroup(i + 1))
- .as("group number %d matches when matching '%s' against '%s'", i + 1, matcher, target)
- .isEqualTo(groups[i]);
- }
+ match.ifPresent(result -> {
+ String[] groups = groupString.split(":");
+ for (int i = 0; i < groups.length; i++) {
+ assertThat(result.getGroup(i + 1))
+ .as("group number %d matches when matching '%s' against '%s'", i + 1, matcher, target)
+ .isEqualTo(groups[i]);
+ }
+ assertThatThrownBy(() -> result.getGroup(-1))
+ .isInstanceOf(IndexOutOfBoundsException.class);
+ assertThatThrownBy(() -> result.getGroup(groups.length + 1))
+ .isInstanceOf(IndexOutOfBoundsException.class);
+ });
}
@Test
diff --git a/docs/Dockerfile b/docs/Dockerfile
index 7735dbc36..7647d5103 100644
--- a/docs/Dockerfile
+++ b/docs/Dockerfile
@@ -1,10 +1,15 @@
-FROM jekyll/jekyll:stable
+FROM ruby:3.3-slim
-MAINTAINER Peter Gafert
+LABEL maintainer="Peter Gafert "
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential \
+ git \
+ && rm -rf /var/lib/apt/lists/*
WORKDIR /srv/jekyll
-COPY Gemfile .
-COPY Gemfile.lock .
+COPY Gemfile Gemfile.lock ./
-RUN bundle install
\ No newline at end of file
+RUN gem install bundler -v 2.3.25 \
+ && bundle install
diff --git a/docs/README.md b/docs/README.md
index d3dae43fb..2ff75c57a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -10,9 +10,22 @@ Install [Docker](https://docs.docker.com/engine/installation/) and [Docker Compo
then execute
```
-docker-compose up
+docker compose up
```
-The site will be served on [localhost:4000](http://localhost:4000)
+
+The site will be served on [localhost:4000](http://localhost:4000).
+
+### Rebuilding the User Guide
+
+Jekyll does not render the AsciiDoc user guide (`userguide/*.adoc`) — Gradle does.
+After editing any `.adoc` file, run from the repository root:
+
+```
+./gradlew :docs:renderUserGuide
+```
+
+This regenerates `userguide/html/` and `_pages/use-cases.md`, which the
+running Jekyll container will pick up on the next browser refresh.
## Credits
diff --git a/docs/_config-dev.yml b/docs/_config-dev.yml
index 238ae4328..411dbcd50 100644
--- a/docs/_config-dev.yml
+++ b/docs/_config-dev.yml
@@ -7,7 +7,7 @@ title : "ArchUnit"
title_separator : "-"
name : "Peter Gafert"
description : "A Java architecture test library, to specify and assert architecture rules in plain Java"
-url : "http://localhost"
+url : "http://localhost:4000"
baseurl : ""
repository : "codecholeric/archunit"
teaser : # path of fallback teaser image, e.g. "/assets/images/500x300.png"
@@ -33,6 +33,8 @@ include:
- .htaccess
- _pages
exclude:
+ - _site
+ - .sass-cache
- vendor
- assets/js/plugins
- assets/js/_main.js
@@ -54,7 +56,7 @@ markdown: kramdown
highlighter: rouge
lsi: false
excerpt_separator: "\n\n"
-incremental: false
+incremental: true
# Markdown Processing
diff --git a/docs/docker-compose.yml b/docs/docker-compose.yml
index 5026690e8..86282854d 100644
--- a/docs/docker-compose.yml
+++ b/docs/docker-compose.yml
@@ -1,14 +1,12 @@
-version: "3.0"
-
services:
site:
build: .
image: archunit/jekyll:latest
- command: bundle exec jekyll serve --verbose --trace --host 0.0.0.0 --watch --incremental
+ command: bundle exec jekyll serve --verbose --trace --host 0.0.0.0 --watch --force_polling
environment:
- JEKYLL_ENV=development
volumes:
- ./:/srv/jekyll
- ./_config-dev.yml:/srv/jekyll/_config.yml
ports:
- - 4000:4000
+ - "4000:4000"
diff --git a/docs/userguide/003_Getting_Started.adoc b/docs/userguide/003_Getting_Started.adoc
index a7e0ffea0..843e94be9 100644
--- a/docs/userguide/003_Getting_Started.adoc
+++ b/docs/userguide/003_Getting_Started.adoc
@@ -50,8 +50,9 @@ ArchRule myRule = classes()
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
----
-The two dots represent any number of packages (compare AspectJ Pointcuts). The returned
-object of type `ArchRule` can now be evaluated against a set of imported classes:
+The two dots represent any number of packages (compare AspectJ Pointcuts); for details on
+the supported package pattern syntax see <>. The returned object of
+type `ArchRule` can now be evaluated against a set of imported classes:
[source,java,options="nowrap"]
----
diff --git a/docs/userguide/006_The_Core_API.adoc b/docs/userguide/006_The_Core_API.adoc
index 1cb287c74..5deaf2370 100644
--- a/docs/userguide/006_The_Core_API.adoc
+++ b/docs/userguide/006_The_Core_API.adoc
@@ -396,3 +396,116 @@ ArchUnit's own rule APIs (compare <>) never rely on the
classpath though. Thus the evaluation of default rules and syntax combinations, described in the
next section, does not depend on whether the classes were imported from the classpath or
some JAR / folder.
+
+==== Package Identifiers
+
+Several ArchUnit methods accept a `String` package identifier to describe a set of packages,
+for example `resideInAPackage(..)` and `resideInAnyPackage(..)` (and their negated variants)
+in the Lang API, `slices().matching(..)` and `modules().definedByPackages(..)` in the Library
+API, or the component stereotypes of a PlantUML component diagram. All of them delegate to
+the same underlying https://javadoc.io/doc/com.tngtech.archunit/archunit/latest/com/tngtech/archunit/core/domain/PackageMatcher.html[`PackageMatcher`], whose syntax is inspired by AspectJ type patterns and
+extended with capturing groups.
+
+===== Wildcards
+
+The syntax is built from the following elements:
+
+[cols="1l,4"]
+|===
+|Pattern | Meaning
+
+|*
+| Matches any non-empty sequence of characters not containing the dot `+.+`, i.e. exactly one package segment.
+
+|..
+| Matches any (possibly empty) sequence of characters that may contain the dot `+.+`, i.e. any number of package segments (including zero).
+
+|(*)
+| Like `+*+`, but additionally _captures_ the matched segment, so it can be referenced later (e.g. as a slice identifier, see <>).
+
+|(**)
+| Like `+..+`, but additionally _captures_ the matched segments.
+
+|[a\|b]
+| Alternation: matches either `a` or `b`. Alternations are only allowed inside brackets `+[...]+` or inside capturing groups `+(...)+`.
+|===
+
+Note that `(..)` is not a valid capturing group — use `(**)` instead. `()` and `[]` cannot
+be nested inside each other.
+
+The segments matched by capturing groups can be retrieved from the result of
+https://javadoc.io/doc/com.tngtech.archunit/archunit/latest/com/tngtech/archunit/core/domain/PackageMatcher.html#match(java.lang.String)[`PackageMatcher#match(String)`]
+via
+https://javadoc.io/doc/com.tngtech.archunit/archunit/latest/com/tngtech/archunit/core/domain/PackageMatcher.Result.html#getGroup(int)[`PackageMatcher.Result#getGroup(int)`]
+(groups are 1-based, in the order the capturing groups appear in the pattern).
+
+===== Worked Example
+
+Consider the following five classes and the packages they reside in:
+
+[cols="2,2"]
+|===
+| Class | Package
+
+| `com.myapp.controller.SomeController` | `com.myapp.controller`
+| `com.myapp.service.SomeService` | `com.myapp.service`
+| `com.myapp.service.impl.SomeServiceImpl` | `com.myapp.service.impl`
+| `com.myapp.persistence.dao.SomeDao` | `com.myapp.persistence.dao`
+| `com.myapp.persistence.dao.jpa.SomeJpa` | `com.myapp.persistence.dao.jpa`
+|===
+
+The following table shows a range of package identifiers and which of the five packages
+above each of them matches:
+
+[cols="2,3"]
+|===
+| Package Identifier | Matches
+
+| `com.myapp.service`
+| `com.myapp.service` only (exact match).
+
+| `com.myapp.*`
+| `com.myapp.controller` and `com.myapp.service`
+(exactly one more segment after `com.myapp`).
+
+| `com.myapp..`
+| All five packages — `com.myapp..` matches `com.myapp` itself and every subpackage of it.
+
+| `..service`
+| `com.myapp.service` only (segment `service` at the end).
+
+| `..service..`
+| `com.myapp.service` and `com.myapp.service.impl`
+(any package containing a segment `service`).
+
+| `..dao..`
+| `com.myapp.persistence.dao` and `com.myapp.persistence.dao.jpa`.
+
+| `..impl`
+| `com.myapp.service.impl` only.
+
+| `com.myapp.(*)`
+| `com.myapp.controller` (group 1 = `controller`) and
+`com.myapp.service` (group 1 = `service`).
+The deeper packages do not match because `(*)` allows only a single segment.
+
+| `com.myapp.(*)..`
+| All five packages; group 1 captures the first sub-package below `com.myapp`
+(`controller`, `service`, `service`, `persistence`, `persistence` respectively).
+This is the typical pattern used by `slices().matching(..)`.
+
+| `..[service\|controller]..`
+| `com.myapp.controller`, `com.myapp.service` and `com.myapp.service.impl`.
+|===
+
+===== What is Matched Against the Pattern
+
+An important detail is that the package identifier is matched against the
+**package name of a class**, not against the fully qualified class name.
+So for `com.myapp.service.SomeService` the string that is checked against
+the pattern is `com.myapp.service`, not `com.myapp.service.SomeService`.
+
+This means that a pattern like `..SomeService` will _not_ match a class named `SomeService`,
+because `SomeService` is the simple class name, not a package segment. To match by class
+name use a name-based predicate such as
+`haveSimpleName("SomeService")` or `haveNameMatching(".*SomeService")` instead.
diff --git a/docs/userguide/008_The_Library_API.adoc b/docs/userguide/008_The_Library_API.adoc
index 9b787f404..10ed830ca 100644
--- a/docs/userguide/008_The_Library_API.adoc
+++ b/docs/userguide/008_The_Library_API.adoc
@@ -130,7 +130,10 @@ com.tngtech.archunit.library.dependencies.SlicesRuleDefinition
----
The API is based on the idea to sort classes into slices according to one or several package
-infixes, and then write assertions against those slices. At the moment this is for example:
+infixes, and then write assertions against those slices. The `matching(..)` argument follows
+the package pattern syntax described in <>, where the parentheses
+`+++(*)+++` / `+++(**)+++` mark the captured segment(s) that are used as slice identifiers.
+At the moment this is for example:
[source,java,options="nowrap"]
----
@@ -245,7 +248,7 @@ ModuleRuleDefinition.modules().definedByPackages("..example.(*)..").should().beF
----
As the example shows, it shares some concepts with the <> API. For example `definedByPackages(..)`
-follows the same semantics as `slices().matching(..)`.
+follows the same semantics as `slices().matching(..)`, using <>.
Also, the configuration options for cycle detection mentioned in the last section are shared by these APIs.
But, it also offers several powerful concepts beyond that API to express many different modularization scenarios.
@@ -417,8 +420,8 @@ A PlantUML diagram used with ArchUnit must abide by a certain set of rules:
1. Components must be declared in the bracket notation (i.e. `[Some Component]`)
2. Components must have at least one (possible multiple) stereotype(s). Each stereotype in the diagram
-must be unique and represent a valid package identifier (e.g. `\<<..example..>>` where `..` represents
-an arbitrary number of packages; compare the core API)
+must be unique and represent a valid <>
+(e.g. `\<<..example..>>` where `..` represents an arbitrary number of packages; compare the core API)
3. Components may have an optional alias (e.g. `[Some Component] \<<..example..>> as myalias`). The alias must be alphanumeric and must not be quoted.
4. Components may have an optional color (e.g. `[Some Component] \<<..example..>> #OrangeRed`)
5. Dependencies must use arrows only consisting of dashes (e.g. `-\->`)
diff --git a/docs/userguide/archunit.css b/docs/userguide/archunit.css
index c164c85bf..d49810048 100644
--- a/docs/userguide/archunit.css
+++ b/docs/userguide/archunit.css
@@ -430,4 +430,11 @@ h3 {
h4 {
margin-top: 1.2em !important;
margin-bottom: 0.55em !important;
+}
+/* Restore code box for single inline-code inside table cells */
+p.tableblock > code:only-child {
+ background-color: #f7f7f8 !important;
+ padding: .1em .5ex !important;
+ border-radius: 4px !important;
+ border: 1px solid #e0e0e0; /* makes a lone dot's box clearly visible */
}
\ No newline at end of file