Support range, renumbering, and punctuation commands
Extend the parser and applier for several command families that were
falling back to UnbekannterBefehl:
- Range/coordinated Aufhebung ("Die Nummern 1 bis 3 werden aufgehoben.",
"Die Absätze 4 und 5 werden aufgehoben.") via bis-range expansion in
StellenParser.parseMehrfach and ausStellen.
- Range renumbering without "zu den" and for Nummern/Buchstaben ("Die
bisherigen Nummern 4 bis 6 werden die Nummern 8 bis 10."); single
renumbering now also covers Nummer/Buchstabe and "Die bisherige".
- §-range Neufassung ("Die §§ 52 bis 56 werden wie folgt gefasst: …"),
splitting the quoted block at "§ N" boundaries.
- "Der Wortlaut wird Absatz N." — a new WortlautZuAbsatz command that
numbers the previously unnumbered body.
- Word-to-punctuation replacement ("… das Wort „oder" am Ende durch ein
Komma ersetzt") and comma+words insert/replace variants.
StellenParser gains plural component words (Absätze/Sätze/Nummern/
Buchstaben) and bis-range expansion. Cuts UnbekannterBefehl further:
GEG 36->21, IfSG 7->3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change-Id: I4972d4eb1d6062521a55710c1f23822e8e22b0c4
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index 6b7c093..a2db382 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -89,6 +89,13 @@
implements Aenderungsbefehl {}
/**
+ * „Der Wortlaut wird Absatz 1.“ — der bisher unnummerierte Normtext erhält die Absatznummer, wird
+ * also zum ersten Absatz (Vorbereitung für das Anfügen weiterer Absätze).
+ */
+ record WortlautZuAbsatz(Stelle stelle, String nummer, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
+ /**
* „In A und B wird jeweils …“ — ein Befehl, der dieselbe Operation auf mehrere, per „und“
* koordinierte Stellen anwendet. Die Teilbefehle teilen sich Provenienz und Befehlszeile; der
* Applier fasst sie zu einem Protokolleintrag zusammen.
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 0ef0973..80f664e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -13,6 +13,7 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
import java.util.ArrayList;
@@ -132,14 +133,61 @@
"^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" + WOERTER + " " + Z + " gestrichen\\.$");
private static final Pattern UMNUMMERIERUNG =
- Pattern.compile("^(?:Der bisherige )?(.+?) wird (?:zu )?(Absatz|Satz) (\\d+[a-z]?)\\.$");
+ Pattern.compile(
+ "^(?:Der bisherige |Die bisherige |Das bisherige )?(.+?) wird (?:zu )?"
+ + "(Absatz|Satz|Nummer|Buchstabe) (\\d+[a-z]?)\\.$");
- // „Die bisherigen Absätze 2 bis 4 werden zu den Absätzen 3 bis 5.“ (Entwürfe) — Bereichs-
- // Umnummerierung, die in einzelne Umnummerierungen aufgelöst wird.
+ // „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) (\\d+) bis (\\d+) "
- + "werden zu den (Absätzen|Sätzen) (\\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 §§ 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 =
+ Pattern.compile(
+ "^Die §§ (\\d+[a-z]?) bis (\\d+[a-z]?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+
+ // „Der Wortlaut wird Absatz 1.“
+ private static final Pattern WORTLAUT_ZU_ABSATZ =
+ Pattern.compile("^Der Wortlaut wird Absatz (\\d+[a-z]?)\\.$");
+
+ // „In Nummer 7 wird das Wort «1» am Ende durch ein Komma ersetzt.“
+ private static final Pattern WORT_ZU_SATZZEICHEN =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) "
+ + WOERTER
+ + " "
+ + Z
+ + "(?: am Ende)? durch (ein Komma|ein Semikolon|einen Punkt) ersetzt\\.$");
+
+ // „In Satz 2 wird nach dem Wort «1» ein Komma und werden die Wörter «2» eingefügt.“
+ private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) "
+ + "(?:dem Wort|den Wörtern|der Angabe|der Zahl) "
+ + Z
+ + " ein Komma und (?:wird|werden) "
+ + WOERTER
+ + " "
+ + Z
+ + " eingefügt\\.$");
+
+ // „In § 74 werden die Wörter «1» durch ein Komma und die Wörter «2» ersetzt.“
+ private static final Pattern ERSETZUNG_DURCH_KOMMA_UND_WOERTER =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?"
+ + WOERTER
+ + " "
+ + Z
+ + " durch ein Komma und "
+ + WOERTER
+ + " "
+ + Z
+ + " ersetzt\\.$");
private static final Pattern INHALTSUEBERSICHT_EINFUEGUNG =
Pattern.compile(
@@ -236,12 +284,20 @@
Matcher m;
+ if ((m = PARAGRAPH_BEREICH_NEUFASSUNG.matcher(text)).matches()) {
+ return paragraphBereichNeufassung(zitat(zitate, m.group(3)), kontext, provenienz);
+ }
+
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));
}
+ if ((m = WORTLAUT_ZU_ABSATZ.matcher(text)).matches()) {
+ return Optional.of(new WortlautZuAbsatz(kontext, m.group(1), provenienz));
+ }
+
if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
var neuerText = zitat(zitate, m.group(3));
var ziel = m.group(2).strip();
@@ -262,6 +318,21 @@
new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz));
}
+ if ((m = WORT_ZU_SATZZEICHEN.matcher(text)).matches()) {
+ var alt = wortZitat(zitate, m.group(2));
+ var neu = satzzeichen(m.group(3));
+ var amEnde = text.contains(" am Ende ");
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, amEnde, provenienz));
+ }
+
+ if ((m = ERSETZUNG_DURCH_KOMMA_UND_WOERTER.matcher(text)).matches()) {
+ var alt = wortZitat(zitate, m.group(2));
+ var neu = ", " + wortZitat(zitate, m.group(3));
+ return ausStellen(
+ m.group(1), s -> new Ersetzung(kontext.plus(s), alt, neu, false, false, provenienz));
+ }
+
if ((m = ERSETZUNG.matcher(text)).matches()) {
var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
var alt = wortZitat(zitate, m.group(3));
@@ -329,6 +400,17 @@
m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
}
+ if ((m = KOMMA_UND_WOERTER_EINFUEGUNG.matcher(text)).matches()) {
+ var ankerWoerter = wortZitat(zitate, m.group(3));
+ var anker =
+ m.group(2).equals("nach")
+ ? new WortAnker.NachWoertern(ankerWoerter)
+ : new WortAnker.VorWoertern(ankerWoerter);
+ var woerter = ", " + wortZitat(zitate, m.group(4));
+ return ausStellen(
+ m.group(1), s -> new WoerterEinfuegung(kontext.plus(s), anker, woerter, provenienz));
+ }
+
if ((m = EINFUEGUNG_ANKER_ZUERST.matcher(text)).matches()) {
var ankerWoerter = wortZitat(zitate, m.group(2));
var anker =
@@ -412,7 +494,7 @@
}
if ((m = AUFHEBUNG.matcher(text)).matches()) {
- return StellenParser.parse(m.group(1)).map(s -> new Aufhebung(kontext.plus(s), provenienz));
+ return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz));
}
if ((m = STREICHUNG.matcher(text)).matches()) {
@@ -571,7 +653,10 @@
String neuBis,
Stelle kontext,
Provenienz provenienz) {
- var ebene = ebeneWort.equals("Absätzen") ? "Absatz" : "Satz";
+ var ebene = ebeneAusWort(ebeneWort);
+ if (ebene == null) {
+ return Optional.empty();
+ }
int av = Integer.parseInt(altVon);
int ab = Integer.parseInt(altBis);
int nv = Integer.parseInt(neuVon);
@@ -654,10 +739,49 @@
return switch (ebene) {
case "Absatz" -> new Stelle.AbsatzNr(nummer);
case "Satz" -> new Stelle.SatzNr(nummer);
+ case "Nummer" -> new Stelle.NummerNr(nummer);
+ case "Buchstabe" -> new Stelle.BuchstabeNr(nummer);
default -> throw new IllegalArgumentException("Unbekannte Ebene: " + ebene);
};
}
+ /** Normalisiert die Ebenenwörter (auch Dativ-/Pluralformen) auf den Basisnamen. */
+ private static @Nullable String ebeneAusWort(String wort) {
+ return switch (wort) {
+ case "Absatz", "Absätze", "Absätzen" -> "Absatz";
+ case "Satz", "Sätze", "Sätzen" -> "Satz";
+ case "Nummer", "Nummern" -> "Nummer";
+ case "Buchstabe", "Buchstaben" -> "Buchstabe";
+ default -> null;
+ };
+ }
+
+ /**
+ * Zerlegt den Zitatblock einer §-Bereichs-Neufassung („§ 52 (weggefallen) § 53 (weggefallen) …“)
+ * an den „§ N“-Grenzen und erzeugt je eine {@link Neufassung} für den betroffenen Paragraphen.
+ */
+ private static Optional<Aenderungsbefehl> paragraphBereichNeufassung(
+ String block, Stelle kontext, Provenienz provenienz) {
+ var teile = new ArrayList<Aenderungsbefehl>();
+ var stuecke = block.strip().split("(?=§\\s*\\d)");
+ for (var stueck : stuecke) {
+ var s = stueck.strip();
+ if (s.isEmpty()) {
+ continue;
+ }
+ var pm = Pattern.compile("^§\\s*(\\d+[a-z]?)\\b").matcher(s);
+ if (!pm.find()) {
+ return Optional.empty();
+ }
+ var stelle = kontext.plus(new Stelle(List.of(new Stelle.Paragraph(pm.group(1)))));
+ teile.add(new Neufassung(stelle, s, provenienz));
+ }
+ if (teile.isEmpty()) {
+ return Optional.empty();
+ }
+ return Optional.of(teile.size() == 1 ? teile.get(0) : new Sammelbefehl(teile));
+ }
+
/** Zitat für Textblöcke (Neufassung, Einfügung ganzer Einheiten): Zeilenstruktur erhalten. */
private static String zitat(ZitatExtraktor.Ergebnis zitate, String index) {
return zitate.zitat(Integer.parseInt(index));
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 db7633e..2b2b80e 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -6,6 +6,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
/**
* Parst Stellenangaben wie „§ 5a Absatz 2 Satz 1 Nummer 4 Buchstabe c“, „der Inhaltsübersicht“ oder
@@ -58,7 +59,7 @@
komponenten.add(new Stelle.Paragraph(wert));
i++;
}
- case "Absatz", "Abs." -> {
+ case "Absatz", "Abs.", "Absätze" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -66,7 +67,7 @@
komponenten.add(new Stelle.AbsatzNr(wert));
i++;
}
- case "Satz" -> {
+ case "Satz", "Sätze" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -74,7 +75,7 @@
komponenten.add(new Stelle.SatzNr(wert));
i++;
}
- case "Nummer", "Nr." -> {
+ case "Nummer", "Nr.", "Nummern" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !NUMMER_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -82,7 +83,7 @@
komponenten.add(new Stelle.NummerNr(wert));
i++;
}
- case "Buchstabe", "Buchst.", "Doppelbuchstabe" -> {
+ case "Buchstabe", "Buchst.", "Doppelbuchstabe", "Buchstaben" -> {
var wert = naechstesWort(woerter, i);
if (wert == null || !BUCHSTABE_WERT.matcher(wert).matches()) {
return Optional.empty();
@@ -119,16 +120,21 @@
*/
public static List<Stelle> parseMehrfach(String phrase) {
var segmente = KOORDINATION.split(phrase.strip());
- if (segmente.length <= 1) {
- return parse(phrase).map(List::of).orElseGet(List::of);
- }
-
var ergebnis = new ArrayList<Stelle>();
Stelle vorige = null;
for (var segment : segmente) {
if (segment.isBlank()) {
return List.of();
}
+ var bereich = entfalteBereich(segment, vorige);
+ if (bereich != null) {
+ if (bereich.isEmpty()) {
+ return List.of();
+ }
+ ergebnis.addAll(bereich);
+ vorige = bereich.get(bereich.size() - 1);
+ continue;
+ }
Stelle voll;
var teil = parse(segment);
if (teil.isPresent()) {
@@ -162,17 +168,7 @@
/** Ersetzt die letzte Komponente von {@code vorige} durch dieselbe Komponentenart mit neuem Label. */
private static Optional<Stelle> mitGeerbtemLabel(Stelle vorige, String label) {
var komponenten = new ArrayList<>(vorige.komponenten());
- var letzte = komponenten.get(komponenten.size() - 1);
- Stelle.Komponente neu =
- switch (letzte) {
- case Stelle.Paragraph p -> new Stelle.Paragraph(label);
- case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
- case Stelle.SatzNr s -> new Stelle.SatzNr(label);
- case Stelle.NummerNr n -> new Stelle.NummerNr(label);
- case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
- case Stelle.Ueberschrift u -> null;
- case Stelle.Inhaltsuebersicht i -> null;
- };
+ var neu = mitLabel(komponenten.get(komponenten.size() - 1), label);
if (neu == null) {
return Optional.empty();
}
@@ -180,6 +176,112 @@
return Optional.of(new Stelle(komponenten));
}
+ private static final Pattern BEREICH =
+ Pattern.compile(
+ "(?:(§§?|Absätze|Absatz|Sätze|Satz|Nummern|Nummer|Buchstaben|Buchstabe)\\s+)?"
+ + "(\\d+|[a-z])\\s+bis\\s+(\\d+|[a-z])");
+
+ /**
+ * Entfaltet einen {@code X bis Y}-Bereich (ganzzahlig oder Einzelbuchstabe) in Einzelstellen. Die
+ * Komponentenart stammt aus der Bereichsangabe („Nummern 1 bis 3“) oder — bei bloßem „1 bis 3“ —
+ * aus der letzten Komponente der vorigen Stelle. Liefert {@code null}, wenn {@code segment} kein
+ * Bereich ist, und eine leere Liste, wenn er unauflösbar ist (z.B. absteigend, gemischt).
+ */
+ private static @Nullable List<Stelle> entfalteBereich(String segment, @Nullable Stelle vorige) {
+ var ohneArtikel = segment.strip().replaceFirst("^(?:[Dd]ie|[Dd]er|[Dd]as|[Dd]en) ", "");
+ var m = BEREICH.matcher(ohneArtikel);
+ if (!m.matches()) {
+ return null;
+ }
+ var art = m.group(1);
+ var von = m.group(2);
+ var bis = m.group(3);
+ boolean numerisch = von.matches("\\d+") && bis.matches("\\d+");
+ boolean alpha = von.matches("[a-z]") && bis.matches("[a-z]");
+ if (!numerisch && !alpha) {
+ return List.of();
+ }
+ var praefix = new ArrayList<Stelle.Komponente>();
+ Stelle.Komponente muster;
+ if (art != null) {
+ muster = komponenteFuerArt(art, von);
+ if (muster == null) {
+ return List.of();
+ }
+ } else {
+ // Bloßer Bereich „1 bis 3“: Art und Präfix von der vorigen Stelle erben.
+ if (vorige == null || vorige.komponenten().isEmpty()) {
+ return List.of();
+ }
+ praefix.addAll(vorige.komponenten().subList(0, vorige.komponenten().size() - 1));
+ muster = vorige.komponenten().get(vorige.komponenten().size() - 1);
+ }
+ var labels = numerisch ? zahlenBereich(von, bis) : buchstabenBereich(von, bis);
+ if (labels.isEmpty()) {
+ return List.of();
+ }
+ var stellen = new ArrayList<Stelle>();
+ for (var label : labels) {
+ var komponenten = new ArrayList<>(praefix);
+ var neu = mitLabel(muster, label);
+ if (neu == null) {
+ return List.of();
+ }
+ komponenten.add(neu);
+ stellen.add(new Stelle(komponenten));
+ }
+ return stellen;
+ }
+
+ private static Stelle.@Nullable Komponente komponenteFuerArt(String art, String label) {
+ return switch (art) {
+ case "§", "§§" -> new Stelle.Paragraph(label);
+ case "Absatz", "Absätze" -> new Stelle.AbsatzNr(label);
+ case "Satz", "Sätze" -> new Stelle.SatzNr(label);
+ case "Nummer", "Nummern" -> new Stelle.NummerNr(label);
+ case "Buchstabe", "Buchstaben" -> new Stelle.BuchstabeNr(label);
+ default -> null;
+ };
+ }
+
+ private static Stelle.@Nullable Komponente mitLabel(Stelle.Komponente muster, String label) {
+ return switch (muster) {
+ case Stelle.Paragraph p -> new Stelle.Paragraph(label);
+ case Stelle.AbsatzNr a -> new Stelle.AbsatzNr(label);
+ case Stelle.SatzNr s -> new Stelle.SatzNr(label);
+ case Stelle.NummerNr n -> new Stelle.NummerNr(label);
+ case Stelle.BuchstabeNr b -> new Stelle.BuchstabeNr(label);
+ case Stelle.Ueberschrift u -> null;
+ case Stelle.Inhaltsuebersicht i -> null;
+ };
+ }
+
+ private static List<String> zahlenBereich(String von, String bis) {
+ int a = Integer.parseInt(von);
+ int b = Integer.parseInt(bis);
+ if (b < a) {
+ return List.of();
+ }
+ var labels = new ArrayList<String>();
+ for (int k = a; k <= b; k++) {
+ labels.add(String.valueOf(k));
+ }
+ return labels;
+ }
+
+ private static List<String> buchstabenBereich(String von, String bis) {
+ char a = von.charAt(0);
+ char b = bis.charAt(0);
+ if (b < a) {
+ return List.of();
+ }
+ var labels = new ArrayList<String>();
+ for (char c = a; c <= b; c++) {
+ labels.add(String.valueOf(c));
+ }
+ return labels;
+ }
+
/**
* Ergänzt {@code segment} um die Präfix-Komponenten von {@code vorige}, die feiner-granular als
* die führende Komponente des Segments sind. Die führende Komponentenklasse des Segments wird in
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 2c9a33e..94c5db1 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -13,6 +13,7 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
import eu.mulk.aendggner.aenderung.Stelle;
import eu.mulk.aendggner.gesetz.Absatz;
import eu.mulk.aendggner.gesetz.Gesetz;
@@ -87,6 +88,7 @@
case Anfuegung a -> wendeAnfuegungAn(normen, a);
case Aufhebung a -> wendeAufhebungAn(normen, a);
case Umnummerierung u -> wendeUmnummerierungAn(normen, u);
+ case WortlautZuAbsatz w -> wendeWortlautZuAbsatzAn(normen, w);
case Sammelbefehl s -> wendeSammelAn(normen, s);
case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
};
@@ -469,6 +471,26 @@
return angewandt(befehl, "(keine Textänderung nötig)");
}
+ private static AngewandteAenderung wendeWortlautZuAbsatzAn(
+ List<Norm> normen, WortlautZuAbsatz befehl) {
+ var aufloesung = loeseNormAuf(normen, befehl.stelle());
+ if (aufloesung.fehler() != null) {
+ return manuell(befehl, aufloesung.fehler());
+ }
+ var norm = normen.get(aufloesung.normIndex());
+ var sb = new StringBuilder();
+ for (var absatz : norm.absaetze()) {
+ if (sb.length() > 0) {
+ sb.append("\n\n");
+ }
+ sb.append(absatz.text());
+ }
+ normen.set(
+ aufloesung.normIndex(),
+ norm.mitAbsaetzen(List.of(new Absatz(befehl.nummer(), sb.toString()))));
+ return angewandt(befehl, norm.enbez());
+ }
+
/**
* Ein Mehrfachziel-Befehl („In A und B wird jeweils …“): wendet jeden Teilbefehl nacheinander an
* (jeder mutiert den fortlaufenden Zwischenstand) und fasst sie zu einem Protokolleintrag
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 ba0dd05..2d0db8a 100644
--- a/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/BefehlErkennerTest.java
@@ -14,6 +14,7 @@
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz;
import eu.mulk.aendggner.aenderung.Provenienz;
import eu.mulk.aendggner.aenderung.Stelle;
import java.util.List;
@@ -451,6 +452,73 @@
}
@Test
+ void bereichsAufhebungWirdSammelbefehl() {
+ var teile =
+ ((Sammelbefehl) erkenne("Die Nummern 1 bis 3 werden aufgehoben.", Stelle.LEER).orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(3).allMatch(t -> t instanceof Aufhebung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("Nummer 1", "Nummer 2", "Nummer 3");
+ }
+
+ @Test
+ void koordinierteAufhebungWirdSammelbefehl() {
+ var teile =
+ ((Sammelbefehl) erkenne("Die Absätze 4 und 5 werden aufgehoben.", Stelle.LEER).orElseThrow())
+ .teilbefehle();
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("Absatz 4", "Absatz 5");
+ }
+
+ @Test
+ void bereichsUmnummerierungNummernOhneZuDen() {
+ var teile =
+ ((Sammelbefehl)
+ erkenne("Die bisherigen Nummern 4 bis 6 werden die Nummern 8 bis 10.", Stelle.LEER)
+ .orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(3).allMatch(t -> t instanceof Umnummerierung);
+ // Absteigend: 6→10, 5→9, 4→8.
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("Nummer 6", "Nummer 5", "Nummer 4");
+ assertThat(teile).extracting(t -> ((Umnummerierung) t).neu().anzeigeText())
+ .containsExactly("Nummer 10", "Nummer 9", "Nummer 8");
+ }
+
+ @Test
+ void paragraphBereichNeufassungWirdSammelbefehl() {
+ var teile =
+ ((Sammelbefehl)
+ erkenne(
+ "Die §§ 52 bis 56 werden wie folgt gefasst: „§ 52 (weggefallen) § 53"
+ + " (weggefallen) § 54 (weggefallen)“.",
+ Stelle.LEER)
+ .orElseThrow())
+ .teilbefehle();
+ assertThat(teile).hasSize(3).allMatch(t -> t instanceof Neufassung);
+ assertThat(teile).extracting(t -> t.stelle().anzeigeText())
+ .containsExactly("§ 52", "§ 53", "§ 54");
+ }
+
+ @Test
+ void wortlautWirdAbsatz() {
+ var befehl =
+ erkenne("Der Wortlaut wird Absatz 1.", new Stelle(List.of(new Stelle.Paragraph("5"))));
+ assertThat(befehl).get().isInstanceOf(WortlautZuAbsatz.class);
+ assertThat(((WortlautZuAbsatz) befehl.orElseThrow()).nummer()).isEqualTo("1");
+ }
+
+ @Test
+ void wortDurchSatzzeichen() {
+ var befehl =
+ erkenne("In Nummer 7 wird das Wort „oder“ am Ende durch ein Komma ersetzt.", Stelle.LEER);
+ var e = (Ersetzung) befehl.orElseThrow();
+ assertThat(e.alt()).isEqualTo("oder");
+ assertThat(e.neu()).isEqualTo(",");
+ assertThat(e.amEnde()).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 a854632..8a1b1e0 100644
--- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
+++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java
@@ -459,6 +459,44 @@
false)));
}
+ @Test
+ void wendetWortlautZuAbsatzAn() {
+ // § 2 hat einen unnummerierten Wortlaut, der zu Absatz 1 wird.
+ var befehl =
+ new eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortlautZuAbsatz(
+ stelle(new Stelle.Paragraph("2")), "1", PROV);
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var norm = ergebnis.neu().norm("§ 2").orElseThrow();
+ assertThat(norm.absaetze()).hasSize(1);
+ assertThat(norm.absaetze().get(0).nummer()).isEqualTo("1");
+ assertThat(norm.absaetze().get(0).anzeigeText()).startsWith("(1) Erprobung ist");
+ }
+
+ @Test
+ void wendetBereichsAufhebungAn() {
+ // „Die Nummern 1 bis 3 werden aufgehoben.“ innerhalb von § 1 Absatz 2.
+ var teile =
+ List.<eu.mulk.aendggner.aenderung.Aenderungsbefehl>of(
+ new Aufhebung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("1")),
+ PROV),
+ new Aufhebung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("2")),
+ PROV),
+ new Aufhebung(
+ stelle(new Stelle.Paragraph("1"), new Stelle.AbsatzNr("2"), new Stelle.NummerNr("3")),
+ PROV));
+
+ var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(new Sammelbefehl(teile)));
+
+ assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT);
+ var text = absatzText(ergebnis.neu(), "§ 1", 1);
+ assertThat(text).contains("1. (weggefallen)").contains("2. (weggefallen)").contains("3. (weggefallen)");
+ }
+
private static String absatzText(Gesetz gesetz, String enbez, int index) {
return gesetz.norm(enbez).orElseThrow().absaetze().get(index).text();
}