From 733ae8b53629c6dbfb5713a99a11eb4a4839d571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Z=C3=B6ller?= Date: Thu, 23 Jul 2026 23:38:32 +0200 Subject: [PATCH 1/3] add package matcher section to user guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add a detailed part to the core API section, as PackageMatching resides there. * reference the detailed section where the current user guide already touches the topic * adjust css to fix rendering issue with codeboxes containing only the `.` (dot) character Signed-off-by: Andreas Zöller --- docs/userguide/003_Getting_Started.adoc | 5 +- docs/userguide/006_The_Core_API.adoc | 113 ++++++++++++++++++++++++ docs/userguide/008_The_Library_API.adoc | 11 ++- docs/userguide/archunit.css | 7 ++ 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/docs/userguide/003_Getting_Started.adoc b/docs/userguide/003_Getting_Started.adoc index a7e0ffea07..843e94be99 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 1cb287c745..5deaf2370e 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 9b787f404e..10ed830ca0 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 c164c85bfa..d498100489 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 From e848c207b0218a19987630f0338925cf303397ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Z=C3=B6ller?= Date: Fri, 24 Jul 2026 16:55:23 +0200 Subject: [PATCH 2/3] Fix local docs Docker setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `docker compose up` based dev setup for the ArchUnit website was broken in several independent ways. This commit gets it working again end-to-end. * replace unmaintained base-image with a current ruby base image. Install bundler version according to gemlock.file * avoid incremental jekyll serve mode as it does not rebuild on configuration change * minor changes to docker-compose.yml: ** drop obsolete top-level version ** quote port mapping * add documentation how to rebuild the user guide to README Signed-off-by: Andreas Zöller --- docs/Dockerfile | 15 ++++++++++----- docs/README.md | 17 +++++++++++++++-- docs/_config-dev.yml | 6 ++++-- docs/docker-compose.yml | 6 ++---- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 7735dbc36d..7647d5103b 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 d3dae43fb4..2ff75c57ad 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 238ae43287..411dbcd50e 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 5026690e83..86282854d7 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" From aad4cf31cfff3b30a446f60200fe91b7da178fb6 Mon Sep 17 00:00:00 2001 From: Manfred Hanke Date: Tue, 28 Jul 2026 10:26:10 +0200 Subject: [PATCH 3/3] improve documentation of PackageMatcher and its capturing groups Signed-off-by: Manfred Hanke --- .../archunit/core/domain/PackageMatcher.java | 58 +++++++++++++------ .../core/domain/PackageMatcherTest.java | 21 ++++--- 2 files changed, 53 insertions(+), 26 deletions(-) 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 f0ed6851ba..1906eb20b8 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 1e1178d178..6dce0cfcfe 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