Support range structure replacement, struct renumbering, and multi-paragraph blocks
Adds bisStelle to StrukturErsetzung for coordinated target ranges, recognizes
deletion/renumbering of whole structural units (paragraphs and Gliederung
entries), and handles insertion/replacement of multi-paragraph blocks split on
§-headings. Updates FASSUNGEN.txt accordingly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Change-Id: I637cc5effbe32c094e62c070b63c470149a77c85
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 5febfc1..029d9cc 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,87 @@
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 15. Juli 2026,
+ zuletzt geändert durch die am 15. Juli 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Auf Grund der weiteren Ertüchtigung des Befehlserkenners und des Befehlsanwenders
+werden Bereichs- und Koordinationsziele bei Struktur-Ersetzungen, die Streichung
+und Umnummerierung ganzer Struktureinheiten (Paragraphen und Gliederungs-
+einheiten) sowie das Einfügen und Ersetzen ganzer Paragraphenblöcke einer
+Verarbeitung zugänglich gemacht.
+
+
+Artikel 1
+Erweiterung der Struktur-Ersetzung um Ziel-Bereiche
+
+Der Aenderungsbefehl (Aenderungsbefehl.StrukturErsetzung) wird um das Feld
+„bisStelle“ ergänzt, welches bei einem zusammenhängenden, koordinierten Ziel-
+Bereich („Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10
+ersetzt: „…““, „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““)
+das letzte Ziel bezeichnet; bei einem Einzelziel bleibt es unbesetzt. Der
+Befehlsanwender (BefehlAnwender) löst den Bereich für Absätze, Sätze und
+Paragraphen auf und ersetzt ihn durch den zitierten Block.
+
+
+Artikel 2
+Aufnahme struktureller Streichungen und Umnummerierungen
+
+Der Befehlserkenner wird wie folgt geändert:
+
+1. Die Streichung ganzer Struktureinheiten („§ 9 wird gestrichen.“, „Die §§ 34
+ bis 39 werden gestrichen.“, „Der bisherige Teil 3 wird gestrichen.“) wird als
+ Aufhebung erkannt.
+
+2. Die Umnummerierung wird auf Paragraphen und Gliederungseinheiten (Teil,
+ Abschnitt, Unterabschnitt, Buch, Kapitel, Anlage) erstreckt sowie um die
+ paarweise §-Umnummerierung („Die §§ 46 und 47 werden zu den §§ 34 und 35.“)
+ ergänzt. Der Befehlsanwender benennt hierbei die betroffene Norm oder
+ Gliederungseinheit um; eine bereits weggefallene Zielnorm wird überschrieben.
+
+3. Die Neufassung eines §-Bereichs wird auf die Form ohne „bisherige“ erstreckt
+ und liefert bei mehrgliedrigem Ziel nunmehr eine bereichsbezogene Struktur-
+ Ersetzung statt eines Einzelbefehls.
+
+4. Die Neufassung der Überschrift einer benannten Einheit („In Anlage 7 wird die
+ Überschrift durch die folgende Überschrift ersetzt: „…““) sowie der Inhalts-
+ übersicht werden erkannt; letztere wird stets der manuellen Prüfung
+ zugewiesen.
+
+5. Die Entwurfsform der Struktur-Ersetzung, bei der die Aufzählungsbezeichnung
+ außerhalb des Zitats steht („… ersetzt: 3. „…““), wird abgefangen und dem
+ Ersatztext wieder vorangestellt.
+
+6. Der Chapeau-Qualifier („Im Satzteil vor Nummer 1 …“, „In der Angabe vor
+ Nummer 1 …“) wird auf weitere Einleitungen erstreckt; steht er allein, ohne
+ eigene Stelle, so bezieht sich die Operation auf die Kontextstelle.
+
+
+Artikel 3
+Einfügung und Ersetzung ganzer Paragraphenblöcke
+
+1. Die Einfügung mehrerer Paragraphen in einem Zuge („Nach § 60a werden die
+ folgenden §§ 60b und 60c eingefügt: „…““) sowie die Ersetzung eines §-Bereichs
+ durch einen §-Block werden erkannt.
+
+2. Der Befehlsanwender zerlegt den zitierten Block anhand der §-Überschriften
+ (unter Ausschluss bloßer Querverweise wie „§ 71 Absatz 1“) in die einzelnen
+ Paragraphen und fügt sie geschlossen ein oder setzt sie an die Stelle des
+ ersetzten Bereichs.
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die Prüfung sämtlicher Testfälle
+(einhundertvierundvierzig an der Zahl) sowie durch die vollständige Erstellung
+(mvnw verify) bestätigt worden. Nicht selbsttätig verarbeitbar bleiben
+einstweilen die mehrquellige Struktur-Ersetzung über mehrere Gesetze hinweg
+sowie vereinzelte Sonderfälle der Entwurfsfassung (Drucksachen); diese werden
+dem Abschnitt „Manuell prüfen“ zugewiesen. Die Unterstützung des bayerischen
+Landesrechts bleibt einem späteren Zeitpunkt vorbehalten.
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 13. Juli 2026,
zuletzt geändert durch die am 13. Juli 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.adoc b/README.adoc
index ad512f6..ac56564 100644
--- a/README.adoc
+++ b/README.adoc
@@ -86,9 +86,16 @@
Erkannt werden die gebräuchlichsten Änderungsbefehle des Handbuchs der
Rechtsförmlichkeit (Ersetzen, Neufassung, Einfügen, Anfügen, Aufheben,
-Streichen, Umnummerierung). Alles andere — etwa Bereichsbefehle wie
-„Die Absätze 2 bis 4 werden …“ — landet mit Begründung im Abschnitt
-*Manuell prüfen* der Synopse und wird niemals stillschweigend verworfen.
+Streichen, Umnummerierung) einschließlich zahlreicher Sonderformen:
+Bereichs- und Koordinationsziele („Die Absätze 8 und 9 werden durch die
+folgenden Absätze 8 bis 10 ersetzt“), strukturelle Streichungen ganzer
+Einheiten („§ 9 wird gestrichen“), §- und Gliederungs-Umnummerierungen
+(„§ 9a wird zu § 9“, „Der bisherige Abschnitt 2 wird zu Abschnitt 3“),
+das Einfügen/Ersetzen ganzer §-Blöcke sowie Chapeau-Lokatoren („Im
+Satzteil vor Nummer 1 …“). Im amtlichen BGBl-Format werden damit
+praktisch alle Befehle typisiert; Entwürfe (Drucksachen) enthalten
+vereinzelt Sonderfälle, die mit Begründung im Abschnitt *Manuell prüfen*
+der Synopse landen und niemals stillschweigend verworfen werden.
== Running the Tests
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index a2db382..f445811 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -47,9 +47,20 @@
/**
* „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ — ein Ziel wird durch
* einen Block ersetzt, der auch mehrere neue Einheiten enthalten darf.
+ *
+ * @param stelle das (erste) zu ersetzende Ziel.
+ * @param bisStelle bei einem zusammenhängenden Bereich („Die Absätze 8 und 9 …“, „Die Sätze 4 bis
+ * 5 …“) das letzte Ziel; {@code null} bei einem Einzelziel.
*/
- record StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz)
- implements Aenderungsbefehl {}
+ record StrukturErsetzung(
+ Stelle stelle, @Nullable Stelle bisStelle, Ebene ebene, String text, Provenienz provenienz)
+ implements Aenderungsbefehl {
+
+ /** Einzelziel-Konstruktor (kein Bereich). */
+ public StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz) {
+ this(stelle, null, ebene, text, provenienz);
+ }
+ }
/** „… werden nach dem Wort „A“ die Wörter „B“ eingefügt.“ */
record WoerterEinfuegung(Stelle stelle, WortAnker anker, String woerter, Provenienz provenienz)
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index 7283d90..2cc5108 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -45,9 +45,23 @@
// „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format);
// auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe).
+ // Der optionale Enumerator-Präfix („3. “, „a) “) fängt die Entwurfs-/Drucksachenform ab, bei der
+ // die Aufzählungsbezeichnung außerhalb des Zitats steht („… ersetzt: 3. „…““); er wird dem
+ // Ersatztext wieder vorangestellt, da das Label sonst verloren ginge.
private static final Pattern STRUKTUR_ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) durch (?:den |die |das )?folgende[nrs]? (.+?) ersetzt: "
+ + "((?:\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s*)?"
+ + Z
+ + "\\.?$");
+
+ // „In Anlage 7 wird die Überschrift durch die folgende Überschrift ersetzt: „…““ — Neufassung der
+ // Überschrift einer benannten Einheit (Anlage/Gliederung/Paragraph); das „die Überschrift“-Objekt
+ // steht hier zwischen Stelle und „durch“, weshalb STRUKTUR_ERSETZUNG nicht greift.
+ private static final Pattern UEBERSCHRIFT_ERSETZUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) die Überschrift durch "
+ + "(?:die |den |das )?folgende[nrs]? Überschrift ersetzt: "
+ Z
+ "\\.?$");
@@ -152,19 +166,31 @@
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
+ // „§ 9 wird gestrichen.“, „Absatz 3 wird gestrichen.“, „Die §§ 34 bis 39 werden gestrichen.“,
+ // „Der bisherige Teil 3 wird gestrichen.“ — Streichung ganzer Struktureinheiten (semantisch eine
+ // Aufhebung). Greift erst, wenn die Wörter-Streichung STREICHUNG (die ein Zitat verlangt) und die
+ // Überschrift-/Absatzbezeichnungs-Streichungen nicht passen.
+ private static final Pattern STRUKTUR_STREICHUNG =
+ Pattern.compile("^(.+?) (?:wird|werden) gestrichen\\.$");
+
private static final Pattern UMNUMMERIERUNG =
Pattern.compile(
"^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
- + "(Absatz|Satz|Nummer|Buchstabe) (\\d+[a-z]?)\\.$");
+ + "(§|Absatz|Satz|Nummer|Buchstabe|Teil|Abschnitt|Unterabschnitt|Buch|Kapitel|Anlage) "
+ + "(\\d+[a-z]?)\\.$");
// „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ bzw. „Die bisherigen Nummern 4
// bis 6 werden die Nummern 8 bis 10.“ — Bereichs-Umnummerierung, in Einzelbefehle aufgelöst.
private static final Pattern UMNUMMERIERUNG_BEREICH =
Pattern.compile(
- "^Die bisherigen (?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
+ "^Die (?:bisherigen )?(?:Absätze|Sätze|Nummern|Buchstaben) (\\d+) bis (\\d+) "
+ "werden (?:zu den |die )?(Absätzen|Sätzen|Nummern|Buchstaben|Absätze|Sätze) "
+ "(\\d+) bis (\\d+)\\.$");
+ // „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ — paarweise §-Umnummerierung (auch Bereiche).
+ private static final Pattern UMNUMMERIERUNG_PARAGRAPHEN =
+ Pattern.compile("^Die §§ (.+?) werden (?:zu den §§ |zu §§ |die §§ )(.+?)\\.$");
+
// „Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) …““ — Neufassung eines §-Bereichs;
// der Zitatblock wird an „§ N“-Grenzen in Einzel-Neufassungen zerlegt.
private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG =
@@ -323,32 +349,77 @@
if ((m = NEUFASSUNG.matcher(text)).matches()) {
var neuerText = zitat(zitate, m.group(2));
- return StellenParser.parse(m.group(1))
- .map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz));
+ var stellen = StellenParser.parseMehrfach(m.group(1));
+ if (stellen.size() == 1) {
+ return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
+ }
+ if (stellen.size() > 1) {
+ // „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““ — ein zusammenhängender
+ // Bereich wird durch einen Block ersetzt (Ebene aus dem Ziel abgeleitet).
+ return koordinierteErsetzung(stellen, null, kontext, neuerText, provenienz);
+ }
+ return Optional.empty();
}
if ((m = WORTLAUT_ZU_ABSATZ.matcher(text)).matches()) {
return Optional.of(new WortlautZuAbsatz(kontext, m.group(1), provenienz));
}
+ if ((m = UEBERSCHRIFT_ERSETZUNG.matcher(text)).matches()) {
+ var neuerText = zitat(zitate, m.group(2));
+ return StellenParser.parse(m.group(1))
+ .map(
+ s ->
+ new Neufassung(
+ kontext.plus(s).plus(new Stelle(List.of(new Stelle.Ueberschrift()))),
+ neuerText,
+ provenienz));
+ }
+
if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
- var neuerText = zitat(zitate, m.group(3));
+ var enumerator = m.group(3);
+ var rohText = zitat(zitate, m.group(4));
+ // Entwurfsform „… ersetzt: 3. „…““: das außerhalb des Zitats stehende Label wieder anfügen.
+ var neuerText = enumerator != null ? enumerator.strip() + " " + rohText.strip() : rohText;
var ziel = m.group(2).strip();
- var stelle = StellenParser.parse(m.group(1));
- if (stelle.isEmpty()) {
+ var stellen = StellenParser.parseMehrfach(m.group(1));
+ if (stellen.isEmpty()) {
return Optional.empty();
}
// „durch die folgende Überschrift ersetzt“ ist eine Neufassung der Überschrift,
- // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen.
- if (ziel.equals("Überschrift") || ziel.matches("§\\s*\\d+[a-z]?")) {
- return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz));
+ // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen,
+ // „Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt“ eine Neufassung der
+ // Inhaltsübersicht (die der Applier stets zur manuellen Prüfung markiert).
+ if (stellen.size() == 1
+ && (ziel.equals("Überschrift")
+ || ziel.equals("Inhaltsübersicht")
+ || ziel.matches("§\\s*\\d+[a-z]?"))) {
+ return Optional.of(new Neufassung(kontext.plus(stellen.get(0)), neuerText, provenienz));
}
var ebene = strukturEbene(ziel);
if (ebene == null) {
return Optional.empty();
}
- return Optional.of(
- new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz));
+ if (ebene == Ebene.PARAGRAPH) {
+ // „§ 71 wird durch die folgenden §§ 71 bis 71p ersetzt: „…““ bzw. „Die §§ 42 bis 45 werden
+ // durch die folgenden §§ 42 bis 45 ersetzt: „…““ — ein §-Bereich wird durch einen §-Block
+ // ersetzt.
+ var first = stellen.get(0);
+ var last = stellen.get(stellen.size() - 1);
+ return Optional.of(
+ new StrukturErsetzung(
+ kontext.plus(first),
+ stellen.size() > 1 ? kontext.plus(last) : null,
+ Ebene.PARAGRAPH,
+ neuerText,
+ provenienz));
+ }
+ if (stellen.size() == 1) {
+ return Optional.of(
+ new StrukturErsetzung(kontext.plus(stellen.get(0)), ebene, neuerText, provenienz));
+ }
+ // „Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt: „…““
+ return koordinierteErsetzung(stellen, ebene, kontext, neuerText, provenienz);
}
if ((m = WORT_ZU_SATZZEICHEN.matcher(text)).matches()) {
@@ -471,9 +542,25 @@
if ((m = STRUKTUR_EINFUEGUNG.matcher(text)).matches()) {
var vorher = m.group(1).equals("Vor");
var stelle = StellenParser.parse(m.group(2));
- var ebeneBez = ebeneUndBezeichnung(m.group(3));
var textInhalt = zitat(zitate, m.group(4));
- if (stelle.isEmpty() || ebeneBez.isEmpty()) {
+ if (stelle.isEmpty()) {
+ return Optional.empty();
+ }
+ var ebeneBez = ebeneUndBezeichnung(m.group(3));
+ if (ebeneBez.isEmpty()) {
+ // „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer
+ // Paragraphen (Aufteilung an den §-Überschriften erfolgt beim Anwenden). Signal:
+ // Ebene PARAGRAPH mit bezeichnung == null.
+ if (m.group(3).strip().matches("§§\\s*\\d.*")) {
+ return Optional.of(
+ new StrukturEinfuegung(
+ kontext.plus(stelle.get()),
+ vorher,
+ Ebene.PARAGRAPH,
+ null,
+ textInhalt,
+ provenienz));
+ }
return Optional.empty();
}
return Optional.of(
@@ -546,6 +633,14 @@
return ausStellen(m.group(1), s -> new Streichung(kontext.plus(s), woerter, provenienz));
}
+ if ((m = STRUKTUR_STREICHUNG.matcher(text)).matches()) {
+ return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz));
+ }
+
+ if ((m = UMNUMMERIERUNG_PARAGRAPHEN.matcher(text)).matches()) {
+ return paragraphenUmnummerierung(m.group(1), m.group(2), kontext, provenienz);
+ }
+
if ((m = UMNUMMERIERUNG_BEREICH.matcher(text)).matches()) {
return bereichsUmnummerierung(
m.group(3), m.group(1), m.group(2), m.group(4), m.group(5), kontext, provenienz);
@@ -685,6 +780,24 @@
}
/**
+ * Löst „Die §§ 46 und 47 werden zu den §§ 34 und 35.“ (auch Bereiche) in paarweise
+ * §-Umnummerierungen auf. Beide Seiten werden zu Paragraphenlisten expandiert und zusammengeführt.
+ */
+ private static Optional<Aenderungsbefehl> paragraphenUmnummerierung(
+ String altPhrase, String neuPhrase, Stelle kontext, Provenienz provenienz) {
+ var alt = StellenParser.parseMehrfach("§ " + altPhrase);
+ var neu = StellenParser.parseMehrfach("§ " + neuPhrase);
+ if (alt.isEmpty() || alt.size() != neu.size()) {
+ return Optional.empty();
+ }
+ var teile = new ArrayList<Aenderungsbefehl>();
+ for (int i = 0; i < alt.size(); i++) {
+ teile.add(new Umnummerierung(kontext.plus(alt.get(i)), kontext.plus(neu.get(i)), provenienz));
+ }
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+
+ /**
* Löst „Die bisherigen Absätze X bis Y werden zu den Absätzen X′ bis Y′.“ in einzelne
* Umnummerierungen auf, angewandt in absteigender Reihenfolge (Y→Y′ zuerst), damit die
* sequenzielle Anwendung keine Labels kollidieren lässt.
@@ -730,6 +843,11 @@
String phrase, Function<Stelle, Aenderungsbefehl> bauer) {
var stellen = StellenParser.parseMehrfach(phrase);
if (stellen.isEmpty()) {
+ // „Im Satzteil vor Nummer 1 …“, „In der Angabe vor Nummer 1 …“ — reiner Chapeau-Qualifier
+ // ohne eigene Stelle: die Operation bezieht sich auf die Kontextstelle.
+ if (StellenParser.istNurChapeau(phrase)) {
+ return Optional.of(bauer.apply(Stelle.LEER));
+ }
return Optional.empty();
}
if (stellen.size() == 1) {
@@ -738,6 +856,46 @@
return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList()));
}
+ /**
+ * Baut aus einem zusammenhängenden, koordinierten Ziel-Bereich („Die Absätze 8 und 9 …“, „Die
+ * bisherigen Sätze 4 und 5 …“) eine bereichsbezogene {@link StrukturErsetzung}: erstes und letztes
+ * Ziel spannen den zu ersetzenden Bereich auf; der zitierte Block ersetzt ihn. Nur für Absatz- und
+ * Satz-Bereiche (die der Applier auflösen kann); andere Ebenen bleiben unbekannt.
+ */
+ private static Optional<Aenderungsbefehl> koordinierteErsetzung(
+ List<Stelle> stellen,
+ @Nullable Ebene ebeneHint,
+ Stelle kontext,
+ String block,
+ Provenienz provenienz) {
+ var first = stellen.get(0);
+ var last = stellen.get(stellen.size() - 1);
+ var ebene = ebeneHint != null ? ebeneHint : ebeneAusStelle(first);
+ if (ebene != Ebene.ABSATZ && ebene != Ebene.SATZ) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new StrukturErsetzung(
+ kontext.plus(first), kontext.plus(last), ebene, block, provenienz));
+ }
+
+ /** Die Ebene der feinsten Komponente einer Stelle (Buchstabe < Nummer < Satz < Absatz < §). */
+ private static @Nullable Ebene ebeneAusStelle(Stelle stelle) {
+ Ebene ebene = null;
+ for (var komponente : stelle.komponenten()) {
+ ebene =
+ switch (komponente) {
+ case Stelle.Paragraph p -> Ebene.PARAGRAPH;
+ case Stelle.AbsatzNr a -> Ebene.ABSATZ;
+ case Stelle.SatzNr s -> Ebene.SATZ;
+ case Stelle.NummerNr n -> Ebene.NUMMER;
+ case Stelle.BuchstabeNr b -> Ebene.BUCHSTABE;
+ default -> ebene;
+ };
+ }
+ return ebene;
+ }
+
private record EbeneBezeichnung(Ebene ebene, String bezeichnung) {}
private static Optional<EbeneBezeichnung> ebeneUndBezeichnung(String phrase) {
@@ -771,6 +929,7 @@
private static @Nullable Ebene strukturEbene(String ziel) {
var erstesWort = ziel.split("\\s+", 2)[0];
return switch (erstesWort) {
+ case "§", "§§" -> Ebene.PARAGRAPH;
case "Absatz", "Absätze" -> Ebene.ABSATZ;
case "Satz", "Sätze" -> Ebene.SATZ;
case "Nummer", "Nummern" -> Ebene.NUMMER;
@@ -781,10 +940,13 @@
private static Stelle.Komponente komponenteFuer(String ebene, String nummer) {
return switch (ebene) {
+ case "§" -> new Stelle.Paragraph(nummer);
case "Absatz" -> new Stelle.AbsatzNr(nummer);
case "Satz" -> new Stelle.SatzNr(nummer);
case "Nummer" -> new Stelle.NummerNr(nummer);
case "Buchstabe" -> new Stelle.BuchstabeNr(nummer);
+ case "Teil", "Abschnitt", "Unterabschnitt", "Buch", "Kapitel", "Anlage" ->
+ new Stelle.Gliederungseinheit(ebene, nummer);
default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
};
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 4660343..ef45714 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -48,9 +48,19 @@
// ohne eigene Stelle-Komponente; die Ersetzung sucht ohnehin im Text der umgebenden Stelle.
private static final Pattern CHAPEAU_QUALIFIER =
Pattern.compile(
- "(?i)(?:in dem |im |in der )?(?:Satzteil|Angabe) vor "
+ "(?i)(?:im |in dem |in der |dem |der )?(?:Satzteil|Angabe) vor "
+ "(?:Nummer|Buchstabe|Satz|Absatz) \\S+");
+ /**
+ * Wahr, wenn die Phrase ausschließlich aus einem Chapeau-Qualifier besteht (z.B. „im Satzteil
+ * vor Nummer 1“, „in der Angabe vor Nummer 1“) und daher keine eigene Stelle-Komponente trägt.
+ * Die Operation bezieht sich dann auf die Kontextstelle (den umgebenden Änderungsrahmen).
+ */
+ public static boolean istNurChapeau(String phrase) {
+ var rest = CHAPEAU_QUALIFIER.matcher(phrase.strip()).replaceAll(" ").strip();
+ return rest.isEmpty() && !phrase.isBlank();
+ }
+
public static Optional<Stelle> parse(String phrase) {
phrase = CHAPEAU_QUALIFIER.matcher(phrase).replaceAll(" ").strip();
var woerter = phrase.strip().split("\\s+");
@@ -62,7 +72,7 @@
continue;
}
switch (wort) {
- case "§" -> {
+ case "§", "§§" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 86eccaf..ee56b2b 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -87,6 +87,7 @@
return switch (befehl) {
case Neufassung n -> wendeGliederungNeufassungAn(gliederungen, n);
case Aufhebung a -> wendeGliederungStreichungAn(gliederungen, a);
+ case Umnummerierung u -> wendeGliederungUmnummerierungAn(gliederungen, u);
default -> manuell(befehl, "Strukturänderung wird nicht automatisch angewandt.");
};
}
@@ -137,6 +138,23 @@
return angewandt(befehl, alt.bezeichnung());
}
+ /** „Der bisherige Abschnitt 2 wird zu Abschnitt 3.“ — Bezeichnung der Gliederungseinheit umsetzen. */
+ private static AngewandteAenderung wendeGliederungUmnummerierungAn(
+ List<Gliederung> gliederungen, Umnummerierung befehl) {
+ int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad());
+ if (idx < 0) {
+ return manuell(befehl, "Gliederungseinheit nicht gefunden: " + befehl.stelle().anzeigeText());
+ }
+ var neuPfad = befehl.neu().gliederungsPfad();
+ if (neuPfad.isEmpty()) {
+ return manuell(befehl, "Neue Gliederungsbezeichnung fehlt.");
+ }
+ var neueBezeichnung = neuPfad.get(neuPfad.size() - 1).bezeichnung();
+ var alt = gliederungen.get(idx);
+ gliederungen.set(idx, alt.mitBezeichnung(neueBezeichnung));
+ return angewandt(befehl, neueBezeichnung);
+ }
+
/**
* Findet die Gliederungseinheit zum Pfad („Teil 3 Abschnitt 2“): jede Ebene wird per Bezeichnung
* innerhalb des Kennzahl-Präfixes der übergeordneten Ebene aufgelöst.
@@ -335,14 +353,34 @@
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
+ int vonIndex = fundstelle.absatzIndex();
+ int bisIndex = vonIndex;
+ // Bereich („Die Absätze 8 und 9 werden … ersetzt“): das letzte Ziel bestimmt das Ende.
+ if (befehl.bisStelle() != null) {
+ var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle());
+ if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ yield manuell(befehl, nicht.begruendung());
+ }
+ var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle();
+ if (f2.normIndex() != fundstelle.normIndex() || f2.absatzIndex() == null) {
+ yield manuell(befehl, "Ersetzungsbereich liegt nicht in einer einzigen Norm.");
+ }
+ bisIndex = f2.absatzIndex();
+ }
+ if (bisIndex < vonIndex) {
+ yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend.");
+ }
var absaetze = new ArrayList<>(norm.absaetze());
- absaetze.remove((int) fundstelle.absatzIndex());
- absaetze.addAll(fundstelle.absatzIndex(), parseAbsaetze(befehl.text()));
+ for (int k = bisIndex; k >= vonIndex; k--) {
+ absaetze.remove(k);
+ }
+ absaetze.addAll(vonIndex, parseAbsaetze(befehl.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
yield angewandt(befehl, norm.enbez());
}
- case SATZ ->
- bearbeiteBereich(
+ case SATZ -> {
+ if (befehl.bisStelle() == null) {
+ yield bearbeiteBereich(
normen,
befehl,
(text, bereich) ->
@@ -350,6 +388,9 @@
text.substring(0, bereich.von())
+ befehl.text().strip().replaceAll("\\s+", " ")
+ text.substring(bereich.bis())));
+ }
+ yield wendeSatzBereichsErsetzungAn(normen, befehl);
+ }
case NUMMER, BUCHSTABE ->
bearbeiteBereich(
normen,
@@ -365,30 +406,116 @@
return TextErgebnis.ok(
text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
});
- case PARAGRAPH ->
- manuell(befehl, "Struktur-Ersetzung ganzer Paragraphen wird nicht unterstützt.");
+ case PARAGRAPH -> {
+ // „§ 71 wird durch die folgenden §§ 71 bis 71p ersetzt: „…““ — der adressierte §-Bereich
+ // wird entfernt und durch die Paragraphen des Blocks ersetzt.
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ yield manuell(befehl, aufloesung.fehler());
+ }
+ int vonIndex = aufloesung.normIndex();
+ int bisIndex = vonIndex;
+ if (befehl.bisStelle() != null) {
+ var a2 = loeseNormAuf(normen, befehl.bisStelle());
+ if (a2.fehler() != null) {
+ yield manuell(befehl, a2.fehler());
+ }
+ bisIndex = a2.normIndex();
+ }
+ if (bisIndex < vonIndex) {
+ yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend.");
+ }
+ var neue = parseNormenBlock(befehl.text(), normen.get(vonIndex).gliederung());
+ if (neue.isEmpty()) {
+ yield manuell(befehl, "Im Ersetzungsblock wurde kein Paragraph erkannt.");
+ }
+ for (int k = bisIndex; k >= vonIndex; k--) {
+ normen.remove(k);
+ }
+ normen.addAll(vonIndex, neue);
+ yield angewandt(befehl, neue.stream().map(Norm::enbez).toList());
+ }
};
}
+ /**
+ * Ersetzt einen zusammenhängenden Satz-Bereich („Die Sätze 4 und 5 werden … gefasst“) durch einen
+ * Block: vom Anfang des ersten bis zum Ende des letzten adressierten Satzes (beide müssen im
+ * selben Absatz derselben Norm liegen).
+ */
+ private static AngewandteAenderung wendeSatzBereichsErsetzungAn(
+ List<Norm> normen, StrukturErsetzung befehl) {
+ var e1 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
+ if (e1 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle());
+ if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ return manuell(befehl, nicht.begruendung());
+ }
+ var f1 = ((StellenAufloeser.Ergebnis.Gefunden) e1).fundstelle();
+ var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle();
+ if (f1.normIndex() != f2.normIndex()
+ || f1.absatzIndex() == null
+ || !f1.absatzIndex().equals(f2.absatzIndex())
+ || f1.bereich() == null
+ || f2.bereich() == null) {
+ return manuell(befehl, "Satz-Bereich liegt nicht in einem einzigen Absatz.");
+ }
+ int von = f1.bereich().von();
+ int bis = f2.bereich().bis();
+ if (bis < von) {
+ return manuell(befehl, "Satz-Bereich ist leer oder absteigend.");
+ }
+ var norm = normen.get(f1.normIndex());
+ var absaetze = new ArrayList<>(norm.absaetze());
+ var absatz = absaetze.get(f1.absatzIndex());
+ var text = absatz.text();
+ var neu =
+ text.substring(0, von)
+ + befehl.text().strip().replaceAll("\\s+", " ")
+ + text.substring(bis);
+ absaetze.set(f1.absatzIndex(), absatz.mitText(neu));
+ normen.set(f1.normIndex(), norm.mitAbsaetzen(absaetze));
+ return angewandt(befehl, norm.enbez());
+ }
+
private static AngewandteAenderung wendeStrukturEinfuegungAn(
List<Norm> normen, StrukturEinfuegung befehl) {
return switch (befehl.ebene()) {
case PARAGRAPH -> {
- var enbezNeu = "§ " + befehl.bezeichnung();
- if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
- yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
- }
var aufloesung = loeseNormAuf(normen, befehl.stelle());
if (aufloesung.fehler() != null) {
yield manuell(befehl, aufloesung.fehler());
}
var anker = normen.get(aufloesung.normIndex());
+ int position = aufloesung.normIndex() + (befehl.vorher() ? 0 : 1);
+
+ // „Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „…““ — Block mehrerer §§.
+ if (befehl.bezeichnung() == null) {
+ var neue = parseNormenBlock(befehl.text(), anker.gliederung());
+ if (neue.isEmpty()) {
+ yield manuell(befehl, "Im Einfügeblock wurde kein Paragraph erkannt.");
+ }
+ for (var n : neue) {
+ if (StellenAufloeser.normIndex(gesetzAus(normen), n.enbez()) >= 0) {
+ yield manuell(befehl, n.enbez() + " existiert bereits im Stammgesetz.");
+ }
+ }
+ normen.addAll(position, neue);
+ yield angewandt(befehl, neue.stream().map(Norm::enbez).toList());
+ }
+
+ var enbezNeu = "§ " + befehl.bezeichnung();
+ if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) {
+ yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz.");
+ }
var neueNorm =
parseNorm(
befehl.text(),
enbezNeu,
new Norm(enbezNeu, null, anker.gliederung(), List.of(), false));
- normen.add(aufloesung.normIndex() + (befehl.vorher() ? 0 : 1), neueNorm);
+ normen.add(position, neueNorm);
yield angewandt(befehl, enbezNeu);
}
case ABSATZ -> {
@@ -542,6 +669,31 @@
private static AngewandteAenderung wendeUmnummerierungAn(
List<Norm> normen, Umnummerierung befehl) {
+ // „§ 9a wird zu § 9.“ — Umbenennung einer ganzen Norm.
+ if (nurParagraph(befehl.stelle()) && nurParagraph(befehl.neu())) {
+ var enbezAlt = "§ " + befehl.stelle().paragraph().get().nummer();
+ var enbezNeu = "§ " + befehl.neu().paragraph().get().nummer();
+ int idx = StellenAufloeser.normIndex(gesetzAus(normen), enbezAlt);
+ if (idx < 0) {
+ return manuell(befehl, enbezAlt + " existiert nicht im Gesetz.");
+ }
+ if (!enbezNeu.equals(enbezAlt)) {
+ int zielIdx = StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu);
+ if (zielIdx >= 0 && !normen.get(zielIdx).weggefallen()) {
+ return manuell(befehl, enbezNeu + " existiert bereits im Gesetz.");
+ }
+ // Eine bereits weggefallene Zielnorm wird durch die Umnummerierung überschrieben.
+ if (zielIdx >= 0) {
+ normen.remove(zielIdx);
+ if (zielIdx < idx) {
+ idx--;
+ }
+ }
+ }
+ normen.set(idx, normen.get(idx).mitEnbez(enbezNeu));
+ return angewandt(befehl, enbezNeu);
+ }
+
var altAbsatz = befehl.stelle().absatz();
var neuAbsatz = befehl.neu().absatz();
if (altAbsatz.isPresent() && neuAbsatz.isPresent()) {
@@ -794,6 +946,34 @@
return anzahl;
}
+ // Eine §-Überschrift beginnt mit „§ N“, gefolgt von einem großgeschriebenen Titelwort — im
+ // Gegensatz zu Querverweisen wie „§ 71 Absatz 1“ oder „§§ 42 bis 45“. Die Negativliste schließt
+ // die Untergliederungs- und Verbindungswörter aus, sodass an solchen Stellen nicht getrennt wird.
+ private static final Pattern PARAGRAPH_UEBERSCHRIFT =
+ Pattern.compile(
+ "(?=§\\s*\\d+[a-z]?\\s+"
+ + "(?!Absatz|Absätze|Abs|Satz|Sätze|Nummer|Nummern|Nr|Buchstabe|Buchstaben|Buchst"
+ + "|und|bis|oder|sowie|des|der|dieses|genannten)"
+ + "\\p{Lu})");
+
+ /**
+ * Zerlegt einen Zitatblock mehrerer Paragraphen an den §-Überschriften (nicht an Querverweisen)
+ * und parst jeden Abschnitt zu einer {@link Norm}. Die Gliederung wird von der Vorlage übernommen.
+ */
+ private static List<Norm> parseNormenBlock(String block, @Nullable Gliederung gliederung) {
+ var normen = new ArrayList<Norm>();
+ for (var stueck : PARAGRAPH_UEBERSCHRIFT.split(block.strip())) {
+ var s = stueck.strip();
+ var m = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+ if (s.isEmpty() || !m.find()) {
+ continue;
+ }
+ var enbez = "§ " + m.group(1);
+ normen.add(parseNorm(s, enbez, new Norm(enbez, null, gliederung, List.of(), false)));
+ }
+ return normen;
+ }
+
/** Zerlegt einen zitierten Normtext („§ 28a Titel (1) … (2) …“) in Titel und Absätze. */
private static Norm parseNorm(String zitat, String enbez, Norm vorlage) {
var text = zitat.strip();
@@ -889,6 +1069,10 @@
befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez)));
}
+ private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, List<String> enbezliste) {
+ return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(enbezliste));
+ }
+
private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) {
return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of());
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
index 53eb6b9..7a09093 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Gliederung.java
@@ -24,4 +24,8 @@
public Gliederung mitTitel(@Nullable String neuerTitel) {
return new Gliederung(kennzahl, bezeichnung, neuerTitel);
}
+
+ public Gliederung mitBezeichnung(String neueBezeichnung) {
+ return new Gliederung(kennzahl, neueBezeichnung, titel);
+ }
}
diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
index 10c3fa6..a5703ec 100644
--- a/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
+++ b/src/main/java/eu/mulk/aendggner/gesetz/Norm.java
@@ -31,6 +31,10 @@
return new Norm(enbez, neuerTitel, gliederung, absaetze, weggefallen);
}
+ public Norm mitEnbez(String neuerEnbez) {
+ return new Norm(neuerEnbez, titel, gliederung, absaetze, weggefallen);
+ }
+
public Norm alsWeggefallen() {
return new Norm(enbez, titel, gliederung, List.of(new Absatz(null, "(weggefallen)")), true);
}
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index 44903e7..483f06a 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -110,11 +110,11 @@
assertThat(parseErgebnis.artikel()).contains("1");
assertThat(parseErgebnis.befehle().size()).isGreaterThanOrEqualTo(100);
- // Der Großteil der Befehle wird erkannt; nur einige Spezialfälle (Mehrquell-Ersetzungen,
- // §§-Bereiche, Chapeau-Angaben) bleiben unbekannt.
+ // Im BGBl-Format werden inzwischen alle Befehle typisiert (Bereichs-/Struktur-Ersetzungen,
+ // §-Blöcke, Chapeau-Lokatoren, strukturelle Streichungen usw.).
var unbekannt =
parseErgebnis.befehle().stream().filter(b -> b instanceof UnbekannterBefehl).count();
- assertThat(unbekannt).isLessThanOrEqualTo(8);
+ assertThat(unbekannt).isZero();
// Das XML ist bereits konsolidiert; entscheidend ist, dass die Anwendung sauber terminiert
// und jeden Befehl protokolliert.
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
index 230bfca..bbee916 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -441,14 +441,47 @@
}
@Test
- void faelltBeiBisBereichenAufUnbekanntZurueck() {
- // „bis“-Bereiche über Struktureinheiten sind hier (Phase 1) noch nicht unterstützt.
- assertThat(
- erkenne(
- "Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
- + " „(2) Text.“",
- Stelle.LEER))
- .isEmpty();
+ void bereichsErsetzungAbsaetzeWirdStrukturErsetzung() {
+ // „bis“-Bereich über Absätze: das erste und letzte Ziel spannen den zu ersetzenden Bereich auf.
+ var befehl =
+ erkenne(
+ "Die Absätze 2 bis 4 werden durch die folgenden Absätze 2 bis 6 ersetzt:"
+ + " „(2) Text.“",
+ new Stelle(List.of(new Stelle.Paragraph("5"))));
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(e.ebene()).isEqualTo(Ebene.ABSATZ);
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 5 Absatz 2");
+ assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 5 Absatz 4");
+ }
+
+ @Test
+ void koordinierteAbsatzErsetzungWirdBereich() {
+ // IfSG: „Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt: „…““
+ var befehl =
+ erkenne(
+ "Die Absätze 8 und 9 werden durch die folgenden Absätze 8 bis 10 ersetzt:"
+ + " „(8) Erstes. (9) Zweites. (10) Drittes.“",
+ new Stelle(List.of(new Stelle.Paragraph("14"))));
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 8");
+ assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 14 Absatz 9");
+ }
+
+ @Test
+ void mehrSatzNeufassungWirdStrukturErsetzung() {
+ // IfSG: „Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „…““
+ var befehl =
+ erkenne(
+ "Die bisherigen Sätze 4 und 5 werden wie folgt gefasst: „Erster neuer Satz."
+ + " Zweiter neuer Satz.“",
+ new Stelle(List.of(new Stelle.Paragraph("14"), new Stelle.AbsatzNr("2"))));
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(e.ebene()).isEqualTo(Ebene.SATZ);
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 14 Absatz 2 Satz 4");
+ assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 14 Absatz 2 Satz 5");
}
@Test
@@ -556,6 +589,162 @@
}
@Test
+ void strukturStreichungGanzerEinheit() {
+ // „§ 9 wird gestrichen.“ — Streichung einer ganzen Einheit ist semantisch eine Aufhebung.
+ var befehl = erkenne("§ 9 wird gestrichen.", Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 9");
+ }
+
+ @Test
+ void strukturStreichungMitKontext() {
+ var befehl =
+ erkenne("Absatz 3 wird gestrichen.", new Stelle(List.of(new Stelle.Paragraph("102"))));
+ assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+ assertThat(befehl.orElseThrow().stelle().anzeigeText()).isEqualTo("§ 102 Absatz 3");
+ }
+
+ @Test
+ void strukturStreichungBereichWirdSammelbefehl() {
+ var teile =
+ ((Sammelbefehl) erkenne("Die §§ 34 bis 39 werden gestrichen.", Stelle.LEER).orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(6).allMatch(t -> t instanceof Aufhebung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("§ 34", "§ 35", "§ 36", "§ 37", "§ 38", "§ 39");
+ }
+
+ @Test
+ void strukturStreichungGliederung() {
+ var befehl = erkenne("Der bisherige Teil 3 wird gestrichen.", Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Aufhebung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+ }
+
+ @Test
+ void chapeauLokatorBeziehtSichAufKontext() {
+ // „In der Angabe vor Nummer 1 …“ / „Im Satzteil vor Nummer 1 …“ tragen keine eigene Stelle;
+ // die Operation bezieht sich auf die Kontextstelle.
+ var kontext = new Stelle(List.of(new Stelle.Paragraph("48"), new Stelle.AbsatzNr("1")));
+ var befehl =
+ erkenne(
+ "In der Angabe vor Nummer 1 wird die Angabe „2025“ durch die Angabe „2030“ ersetzt.",
+ kontext);
+ assertThat(befehl).get().isInstanceOf(Ersetzung.class);
+ var e = (Ersetzung) befehl.orElseThrow();
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 48 Absatz 1");
+ assertThat(e.alt()).isEqualTo("2025");
+ assertThat(e.neu()).isEqualTo("2030");
+ }
+
+ @Test
+ void bereichsUmnummerierungOhneBisherigen() {
+ var teile =
+ ((Sammelbefehl)
+ erkenne(
+ "Die Absätze 4 bis 7 werden zu den Absätzen 3 bis 6.",
+ new Stelle(List.of(new Stelle.Paragraph("108"))))
+ .orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(4).allMatch(t -> t instanceof Umnummerierung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("§ 108 Absatz 7", "§ 108 Absatz 6", "§ 108 Absatz 5", "§ 108 Absatz 4");
+ }
+
+ @Test
+ void strukturErsetzungMitEnumeratorPraefix() {
+ // Entwurfs-/Drucksachenform: das Aufzählungslabel steht außerhalb des Zitats.
+ var befehl =
+ erkenne(
+ "Nummer 3 wird durch die folgende Nummer 3 ersetzt: 3. „ die Maßgaben der §§ 42 bis 45"
+ + " entsprechend eingehalten werden.“",
+ new Stelle(List.of(new Stelle.Paragraph("10"), new Stelle.AbsatzNr("2"))));
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(e.ebene()).isEqualTo(Ebene.NUMMER);
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 10 Absatz 2 Nummer 3");
+ // Das Label „3.“ wird dem Ersatztext wieder vorangestellt.
+ assertThat(e.text()).startsWith("3. die Maßgaben");
+ }
+
+ @Test
+ void paragraphUmnummerierung() {
+ var befehl = erkenne("§ 9a wird zu § 9.", Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Umnummerierung.class);
+ var u = (Umnummerierung) befehl.orElseThrow();
+ assertThat(u.stelle().anzeigeText()).isEqualTo("§ 9a");
+ assertThat(u.neu().anzeigeText()).isEqualTo("§ 9");
+ }
+
+ @Test
+ void koordinierteParagraphUmnummerierung() {
+ var teile =
+ ((Sammelbefehl) erkenne("Die §§ 46 und 47 werden zu den §§ 34 und 35.", Stelle.LEER).orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(2).allMatch(t -> t instanceof Umnummerierung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText()).containsExactly("§ 46", "§ 47");
+ assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText())
+ .containsExactly("§ 34", "§ 35");
+ }
+
+ @Test
+ void gliederungsUmnummerierung() {
+ var befehl = erkenne("Der bisherige Abschnitt 2 wird zu Abschnitt 3.", Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Umnummerierung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+ }
+
+ @Test
+ void paragraphenBlockEinfuegung() {
+ var befehl =
+ erkenne(
+ "Nach § 60a werden die folgenden §§ 60b und 60c eingefügt: „§ 60b Prüfung (1) Text."
+ + " § 60c Optimierung (1) Mehr.“",
+ Stelle.LEER);
+ assertThat(befehl).containsInstanceOf(StrukturEinfuegung.class);
+ var e = (StrukturEinfuegung) befehl.orElseThrow();
+ assertThat(e.ebene()).isEqualTo(Ebene.PARAGRAPH);
+ assertThat(e.bezeichnung()).isNull();
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 60a");
+ }
+
+ @Test
+ void paragraphBlockErsetzung() {
+ var befehl =
+ erkenne(
+ "Die §§ 42 bis 45 werden durch die folgenden §§ 42 bis 45 ersetzt: „§ 42 Grundsatz"
+ + " (1) Text.“",
+ Stelle.LEER);
+ assertThat(befehl).containsInstanceOf(Aenderungsbefehl.StrukturErsetzung.class);
+ var e = (Aenderungsbefehl.StrukturErsetzung) befehl.orElseThrow();
+ assertThat(e.ebene()).isEqualTo(Ebene.PARAGRAPH);
+ assertThat(e.stelle().anzeigeText()).isEqualTo("§ 42");
+ assertThat(e.bisStelle().anzeigeText()).isEqualTo("§ 45");
+ }
+
+ @Test
+ void ueberschriftErsetzungMitStelle() {
+ var befehl =
+ erkenne(
+ "In Anlage 7 wird die Überschrift durch die folgende Überschrift ersetzt: „Anlage 7"
+ + " (zu § 36) Höchstwerte“.",
+ Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftGliederung()).isTrue();
+ }
+
+ @Test
+ void inhaltsuebersichtErsetzung() {
+ var befehl =
+ erkenne(
+ "Die Inhaltsübersicht wird durch die folgende Inhaltsübersicht ersetzt: „Inhaltsübersicht"
+ + " § 1 Zweck § 2 Begriffe“.",
+ Stelle.LEER);
+ assertThat(befehl).get().isInstanceOf(Neufassung.class);
+ assertThat(befehl.orElseThrow().stelle().betrifftInhaltsuebersicht()).isTrue();
+ }
+
+ @Test
void mehrfachErsetzungWirdSammelbefehl() {
// Mehrere Ersetzungspaare unter einem gemeinsamen „ersetzt“.
var befehl =
diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
index 8a1b1e0..9b9c18d 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -20,6 +20,7 @@
import eu.mulk.aendggner.anwendung.BefehlAnwender.Status;
import eu.mulk.aendggner.gesetz.Absatz;
import eu.mulk.aendggner.gesetz.Gesetz;
+import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -349,6 +350,44 @@
}
@Test
+ void ersetztAbsatzBereichDurchBlock() {
+ // „Die Absätze 1 und 2 werden durch die folgenden Absätze 1 bis 3 ersetzt: „…““
+ var befehl =
+ new StrukturErsetzung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("1")),
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2")),
+ Ebene.ABSATZ,
+ "(1) Neu eins. (2) Neu zwei. (3) Neu drei.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var norm = ergebnis.neu().norm("§ 1").orElseThrow();
+ assertThat(norm.absaetze()).hasSize(3);
+ assertThat(norm.absaetze()).extracting(Absatz::nummer).containsExactly("1", "2", "3");
+ assertThat(norm.absaetze().get(0).text()).isEqualTo("Neu eins.");
+ }
+
+ @Test
+ void ersetztSatzBereichDurchBlock() {
+ // „Die Sätze 1 und 2 werden wie folgt gefasst: „…““ in § 2 (3 Sätze).
+ var befehl =
+ new StrukturErsetzung(
+ stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("1")),
+ stelle(new Stelle.Paragraph("2"), new Stelle.SatzNr("2")),
+ Ebene.SATZ,
+ "Neuer Satz eins. Neuer Satz zwei.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(absatzText(ergebnis.neu(), "§ 2", 0))
+ .isEqualTo("Neuer Satz eins. Neuer Satz zwei. Sie endet mit einem Bericht.");
+ }
+
+ @Test
void fuegtSatzNachSatzEin() {
var befehl =
new StrukturEinfuegung(
@@ -422,6 +461,112 @@
.isEqualTo("Zweck dieses Gesetzes ist die Prüfung.");
}
+ @Test
+ void nummeriertParagraphUm() {
+ var befehl =
+ new Umnummerierung(stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("4")), PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+ assertThat(enbezListe).containsExactly("§ 1", "§ 2", "§ 4");
+ assertThat(ergebnis.neu().norm("§ 4").orElseThrow().titel()).isEqualTo("Schlussvorschriften");
+ }
+
+ @Test
+ void paragraphUmnummerierungUeberschreibtWeggefalleneZielnorm() {
+ // § 2 aufheben, dann § 3 → § 2: die weggefallene Zielnorm wird ersetzt.
+ var befehle =
+ List.<eu.mulk.aendggner.aenderung.Aenderungsbefehl>of(
+ new Aufhebung(stelle(new Stelle.Paragraph("2")), PROV),
+ new Umnummerierung(
+ stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("2")), PROV));
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), befehle);
+
+ assertThat(ergebnis.protokoll()).allMatch(a -> a.status() == Status.ANGEWANDT);
+ var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+ assertThat(enbezListe).containsExactly("§ 1", "§ 2");
+ assertThat(ergebnis.neu().norm("§ 2").orElseThrow().titel()).isEqualTo("Schlussvorschriften");
+ }
+
+ @Test
+ void meldetKonfliktBeiParagraphUmnummerierung() {
+ var befehl =
+ new Umnummerierung(stelle(new Stelle.Paragraph("3")), stelle(new Stelle.Paragraph("1")), PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN);
+ assertThat(ergebnis.protokoll().get(0).begruendung()).contains("existiert bereits");
+ }
+
+ @Test
+ void fuegtParagraphenBlockEin() {
+ // „Nach § 1 werden die folgenden §§ 1a und 1b eingefügt: „…““ (bezeichnung == null).
+ var befehl =
+ new StrukturEinfuegung(
+ stelle(new Stelle.Paragraph("1")),
+ false,
+ Ebene.PARAGRAPH,
+ null,
+ "§ 1a Erstes Neu (1) Inhalt eins. § 1b Zweites Neu (1) Inhalt zwei.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+ assertThat(enbezListe).containsExactly("§ 1", "§ 1a", "§ 1b", "§ 2", "§ 3");
+ assertThat(ergebnis.neu().norm("§ 1a").orElseThrow().titel()).isEqualTo("Erstes Neu");
+ assertThat(ergebnis.neu().norm("§ 1b").orElseThrow().titel()).isEqualTo("Zweites Neu");
+ }
+
+ @Test
+ void ersetztParagraphBlock() {
+ // „§ 2 wird durch die folgenden §§ 2 und 2a ersetzt: „…““ — § 2 wird durch zwei §§ ersetzt.
+ // Der Querverweis „§ 1 Absatz 1“ im Text darf NICHT als Grenze zerteilt werden.
+ var befehl =
+ new StrukturErsetzung(
+ stelle(new Stelle.Paragraph("2")),
+ null,
+ Ebene.PARAGRAPH,
+ "§ 2 Begriffe (1) Erprobung nach § 1 Absatz 1 ist die Prüfung. § 2a Weiteres (1) Mehr.",
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var enbezListe = ergebnis.neu().normen().stream().map(Norm::enbez).toList();
+ assertThat(enbezListe).containsExactly("§ 1", "§ 2", "§ 2a", "§ 3");
+ assertThat(ergebnis.neu().norm("§ 2").orElseThrow().absaetze().get(0).text())
+ .isEqualTo("Erprobung nach § 1 Absatz 1 ist die Prüfung.");
+ assertThat(ergebnis.neu().norm("§ 2a").orElseThrow().titel()).isEqualTo("Weiteres");
+ }
+
+ @Test
+ void nummeriertGliederungUm() {
+ var gesetz =
+ new Gesetz(
+ "TestG",
+ "Gesetz",
+ "Test",
+ List.of(new Norm("§ 1", "Zweck", null, List.of(new Absatz("1", "Text.")), false)),
+ List.of(new Gliederung("010020", "Abschnitt 2", "Früherkennung")));
+ var befehl =
+ new Umnummerierung(
+ stelle(new Stelle.Gliederungseinheit("Abschnitt", "2")),
+ stelle(new Stelle.Gliederungseinheit("Abschnitt", "3")),
+ PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz, List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ assertThat(ergebnis.neu().gliederungen().get(0).bezeichnung()).isEqualTo("Abschnitt 3");
+ assertThat(ergebnis.neu().gliederungen().get(0).titel()).isEqualTo("Früherkennung");
+ }
+
private static Gesetz gesetz() {
return new Gesetz(
"TestG",