blob: ee56b2bce866379af8db2c661e216447e9f344b3 [file] [log] [blame]
package eu.mulk.aendggner.anwendung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Anfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Aufhebung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Sammelbefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
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;
import eu.mulk.aendggner.gesetz.Gliederung;
import eu.mulk.aendggner.gesetz.Norm;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
/**
* Wendet Änderungsbefehle nacheinander auf ein Stammgesetz an (die Reihenfolge im Änderungsgesetz
* ist maßgeblich). Jeder Befehl erhält einen Protokolleintrag; was nicht sicher angewandt werden
* kann, wird mit Begründung als „manuell prüfen“ markiert — niemals stillschweigend verworfen.
*/
public final class BefehlAnwender {
public enum Status {
ANGEWANDT,
MANUELL_PRUEFEN
}
public record AngewandteAenderung(
Aenderungsbefehl befehl, Status status, String begruendung, Set<String> betroffeneEnbez) {}
public record AnwendungsErgebnis(Gesetz neu, List<AngewandteAenderung> protokoll) {
public long anzahlAngewandt() {
return protokoll.stream().filter(a -> a.status() == Status.ANGEWANDT).count();
}
public long anzahlManuell() {
return protokoll.stream().filter(a -> a.status() == Status.MANUELL_PRUEFEN).count();
}
}
// Absatzmarker in Zitaten: „(1) “ am Zeilenanfang oder nach Leerraum (PDF-Umbrüche verwischen
// die Zeilenstruktur).
private static final Pattern ABSATZ_MARKER =
Pattern.compile("(?m)(?:^|(?<=\\s))\\((\\d+[a-z]?)\\)\\s+");
private BefehlAnwender() {}
public static AnwendungsErgebnis anwenden(Gesetz alt, List<Aenderungsbefehl> befehle) {
var normen = new ArrayList<>(alt.normen());
var gliederungen = new ArrayList<>(alt.gliederungen());
var protokoll = new ArrayList<AngewandteAenderung>();
for (var befehl : befehle) {
protokoll.add(wendeAn(normen, gliederungen, befehl));
}
return new AnwendungsErgebnis(
alt.mitNormen(normen).mitGliederungen(gliederungen), protokoll);
}
private static AngewandteAenderung wendeAn(
List<Norm> normen, List<Gliederung> gliederungen, Aenderungsbefehl befehl) {
if (befehl instanceof UnbekannterBefehl) {
return manuell(befehl, "Befehl nicht erkannt.");
}
if (befehl.stelle().betrifftInhaltsuebersicht()) {
return manuell(
befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt.");
}
try {
// Änderungen an Gliederungs-Überschriften (Teil/Abschnitt/…) wirken auf den Gliederungsbaum.
if (befehl.stelle().betrifftGliederung()) {
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.");
};
}
return switch (befehl) {
case Ersetzung e -> wendeErsetzungAn(normen, e);
case Streichung s -> wendeStreichungAn(normen, s);
case WoerterEinfuegung w -> wendeWoerterEinfuegungAn(normen, w);
case Neufassung n -> wendeNeufassungAn(normen, n);
case StrukturErsetzung s -> wendeStrukturErsetzungAn(normen, s);
case StrukturEinfuegung s -> wendeStrukturEinfuegungAn(normen, s);
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, gliederungen, s);
case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt.");
};
} catch (RuntimeException e) {
return manuell(befehl, "Anwendung fehlgeschlagen: " + e);
}
}
// --- Gliederungs-Überschriften -------------------------------------------------------------
private static AngewandteAenderung wendeGliederungNeufassungAn(
List<Gliederung> gliederungen, Neufassung befehl) {
int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad());
if (idx < 0) {
return manuell(befehl, "Gliederungseinheit nicht gefunden.");
}
var alt = gliederungen.get(idx);
var titel = befehl.neuerText().replaceAll("\\s+", " ").strip();
if (titel.startsWith(alt.bezeichnung())) {
titel = titel.substring(alt.bezeichnung().length()).strip();
}
gliederungen.set(idx, alt.mitTitel(titel.isEmpty() ? null : titel));
return angewandt(befehl, alt.bezeichnung());
}
private static AngewandteAenderung wendeGliederungStreichungAn(
List<Gliederung> gliederungen, Aufhebung befehl) {
int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad());
if (idx < 0) {
return manuell(befehl, "Gliederungseinheit nicht gefunden.");
}
var alt = gliederungen.get(idx);
gliederungen.set(idx, alt.mitTitel("(weggefallen)"));
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.
*/
private static int findeGliederung(
List<Gliederung> gliederungen, List<Stelle.Gliederungseinheit> pfad) {
if (pfad.isEmpty()) {
return -1;
}
String praefix = "";
int gefunden = -1;
for (var einheit : pfad) {
gefunden = -1;
for (int i = 0; i < gliederungen.size(); i++) {
var g = gliederungen.get(i);
if (g.bezeichnung().equals(einheit.bezeichnung())
&& (g.kennzahl() == null || g.kennzahl().startsWith(praefix))) {
gefunden = i;
break;
}
}
if (gefunden < 0) {
return -1;
}
var kennzahl = gliederungen.get(gefunden).kennzahl();
if (kennzahl != null) {
praefix = kennzahl;
}
}
return gefunden;
}
// --- Wortweise Textoperationen -------------------------------------------------------------
private static AngewandteAenderung wendeErsetzungAn(List<Norm> normen, Ersetzung befehl) {
return bearbeiteText(
normen,
befehl,
text -> {
if (befehl.amEnde()) {
var gestutzt = text.stripTrailing();
if (!gestutzt.endsWith(befehl.alt())) {
return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“.");
}
return TextErgebnis.ok(
gestutzt.substring(0, gestutzt.length() - befehl.alt().length()) + befehl.neu());
}
int anzahl = zaehleVorkommen(text, befehl.alt());
if (anzahl == 0) {
return TextErgebnis.fehler("„" + befehl.alt() + "“ kommt im Zieltext nicht vor.");
}
if (anzahl > 1 && !befehl.jeweils()) {
return TextErgebnis.fehler(
"„" + befehl.alt() + "“ kommt " + anzahl + "-mal vor (ohne „jeweils“ mehrdeutig).");
}
return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu()));
});
}
private static AngewandteAenderung wendeStreichungAn(List<Norm> normen, Streichung befehl) {
return bearbeiteText(
normen,
befehl,
text -> {
int anzahl = zaehleVorkommen(text, befehl.woerter());
if (anzahl == 0) {
return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt im Zieltext nicht vor.");
}
if (anzahl > 1) {
return TextErgebnis.fehler("„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor.");
}
return TextErgebnis.ok(
text.replace(befehl.woerter(), "")
.replaceAll(" +", " ")
.replaceAll(" ([,;.])", "$1"));
});
}
private static AngewandteAenderung wendeWoerterEinfuegungAn(
List<Norm> normen, WoerterEinfuegung befehl) {
return bearbeiteText(
normen,
befehl,
text ->
switch (befehl.anker()) {
case WortAnker.NachWoertern nach -> {
var pruefung = eindeutigeFundstelle(text, nach.woerter());
if (pruefung.fehler() != null) {
yield TextErgebnis.fehler(pruefung.fehler());
}
int ende = pruefung.index() + nach.woerter().length();
yield TextErgebnis.ok(
text.substring(0, ende) + " " + befehl.woerter() + text.substring(ende));
}
case WortAnker.VorWoertern vor -> {
var pruefung = eindeutigeFundstelle(text, vor.woerter());
if (pruefung.fehler() != null) {
yield TextErgebnis.fehler(pruefung.fehler());
}
yield TextErgebnis.ok(
text.substring(0, pruefung.index())
+ befehl.woerter()
+ " "
+ text.substring(pruefung.index()));
}
case WortAnker.VorKommaAmEnde ignoriert -> {
var gestutzt = text.stripTrailing();
if (!gestutzt.endsWith(",")) {
yield TextErgebnis.fehler("Der Zieltext endet nicht mit einem Komma.");
}
yield TextErgebnis.ok(
gestutzt.substring(0, gestutzt.length() - 1) + " " + befehl.woerter() + ",");
}
case WortAnker.AmEnde ignoriert -> {
var gestutzt = text.stripTrailing();
if (gestutzt.endsWith(".") || gestutzt.endsWith(",") || gestutzt.endsWith(";")) {
var satzzeichen = gestutzt.charAt(gestutzt.length() - 1);
yield TextErgebnis.ok(
gestutzt.substring(0, gestutzt.length() - 1)
+ " "
+ befehl.woerter()
+ satzzeichen);
}
yield TextErgebnis.ok(gestutzt + " " + befehl.woerter());
}
});
}
// --- Strukturoperationen -------------------------------------------------------------------
private static AngewandteAenderung wendeNeufassungAn(List<Norm> normen, Neufassung befehl) {
var stelle = befehl.stelle();
if (stelle.betrifftUeberschrift()) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
var titel = befehl.neuerText().replaceFirst("^§\\s*\\S+\\s+", "").strip();
normen.set(aufloesung.normIndex(), norm.mitTitel(titel));
return angewandt(befehl, norm.enbez());
}
if (nurParagraph(stelle)) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
var alteNorm = normen.get(aufloesung.normIndex());
var neueNorm = parseNorm(befehl.neuerText(), alteNorm.enbez(), alteNorm);
normen.set(aufloesung.normIndex(), neueNorm);
return angewandt(befehl, alteNorm.enbez());
}
if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
var alterAbsatz = absaetze.get(fundstelle.absatzIndex());
var neueAbsaetze = parseAbsaetze(befehl.neuerText());
if (neueAbsaetze.size() == 1) {
var neuer = neueAbsaetze.get(0);
absaetze.set(
fundstelle.absatzIndex(),
new Absatz(
neuer.nummer() != null ? neuer.nummer() : alterAbsatz.nummer(), neuer.text()));
} else {
absaetze.remove((int) fundstelle.absatzIndex());
absaetze.addAll(fundstelle.absatzIndex(), neueAbsaetze);
}
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
// Neufassung eines Satzes / einer Nummer / eines Buchstabens: Bereich ersetzen.
return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip()));
}
/** Ein Ziel (Absatz, Satz, Nummer, Buchstabe) wird durch einen Block ersetzt (ggf. 1 → N). */
private static AngewandteAenderung wendeStrukturErsetzungAn(
List<Norm> normen, StrukturErsetzung befehl) {
return switch (befehl.ebene()) {
case ABSATZ -> {
var stelle = befehl.stelle();
if (stelle.absatz().isEmpty()) {
yield manuell(befehl, "Ersetzungsziel nennt keinen Absatz: " + stelle.anzeigeText());
}
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
yield manuell(befehl, nicht.begruendung());
}
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());
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 -> {
if (befehl.bisStelle() == null) {
yield bearbeiteBereich(
normen,
befehl,
(text, bereich) ->
TextErgebnis.ok(
text.substring(0, bereich.von())
+ befehl.text().strip().replaceAll("\\s+", " ")
+ text.substring(bereich.bis())));
}
yield wendeSatzBereichsErsetzungAn(normen, befehl);
}
case NUMMER, BUCHSTABE ->
bearbeiteBereich(
normen,
befehl,
(text, bereich) -> {
var einrueckung = einrueckungVon(text, bereich.von());
var ersatz =
normalisiereZitatText(befehl.text())
.lines()
.map(zeile -> einrueckung + zeile.strip())
.reduce((a, b) -> a + "\n" + b)
.orElse("");
return TextErgebnis.ok(
text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
});
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 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(position, neueNorm);
yield angewandt(befehl, enbezNeu);
}
case ABSATZ -> {
var stelle = befehl.stelle();
if (stelle.absatz().isEmpty()) {
yield manuell(befehl, "Einfügeanker nennt keinen Absatz: " + stelle.anzeigeText());
}
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
yield manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
int position = fundstelle.absatzIndex() + (befehl.vorher() ? 0 : 1);
absaetze.addAll(position, parseAbsaetze(befehl.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
yield angewandt(befehl, norm.enbez());
}
case SATZ ->
bearbeiteBereich(
normen,
befehl,
(text, bereich) -> {
var einschub = " " + befehl.text().strip();
int position = befehl.vorher() ? bereich.von() : bereich.bis();
return TextErgebnis.ok(
befehl.vorher()
? text.substring(0, position)
+ befehl.text().strip()
+ " "
+ text.substring(position)
: text.substring(0, position) + einschub + text.substring(position));
});
case NUMMER, BUCHSTABE ->
bearbeiteBereich(
normen,
befehl,
(text, bereich) -> {
var einrueckung = einrueckungVon(text, bereich.von());
int position = befehl.vorher() ? bereich.von() : bereich.bis();
var zeile = einrueckung + befehl.text().strip().replaceAll("\\s+", " ");
return TextErgebnis.ok(
befehl.vorher()
? text.substring(0, position) + zeile + "\n" + text.substring(position)
: text.substring(0, position) + "\n" + zeile + text.substring(position));
});
};
}
private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) {
return switch (befehl.ebene()) {
case ABSATZ -> {
var aufloesung = loeseNormAuf(normen, befehl.stelle());
if (aufloesung.fehler() != null) {
yield manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
absaetze.addAll(parseAbsaetze(befehl.text()));
normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
yield angewandt(befehl, norm.enbez());
}
case SATZ ->
bearbeiteText(
normen,
befehl,
text -> TextErgebnis.ok(text.stripTrailing() + " " + befehl.text().strip()));
case NUMMER, BUCHSTABE ->
bearbeiteText(
normen,
befehl,
text ->
TextErgebnis.ok(
text.stripTrailing()
+ "\n "
+ befehl.text().strip().replaceAll("\\s+", " ")));
case PARAGRAPH -> manuell(befehl, "Anfügen ganzer Paragraphen wird nicht unterstützt.");
};
}
private static AngewandteAenderung wendeAufhebungAn(List<Norm> normen, Aufhebung befehl) {
var stelle = befehl.stelle();
if (stelle.absatzbezeichnung().isPresent()) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
var nummer = stelle.absatzbezeichnung().get().nummer();
var absaetze = new ArrayList<>(norm.absaetze());
for (int i = 0; i < absaetze.size(); i++) {
if (nummer.equals(absaetze.get(i).nummer())) {
absaetze.set(i, new Absatz(null, absaetze.get(i).text()));
normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
}
return manuell(befehl, "Absatz (" + nummer + ") nicht gefunden.");
}
if (nurParagraph(stelle)) {
var aufloesung = loeseNormAuf(normen, stelle);
if (aufloesung.fehler() != null) {
return manuell(befehl, aufloesung.fehler());
}
var norm = normen.get(aufloesung.normIndex());
if (norm.weggefallen()) {
return manuell(befehl, norm.enbez() + " ist bereits weggefallen.");
}
normen.set(aufloesung.normIndex(), norm.alsWeggefallen());
return angewandt(befehl, norm.enbez());
}
if (stelle.absatz().isPresent() && feinsteIstAbsatz(stelle)) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
var alter = absaetze.get(fundstelle.absatzIndex());
absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)"));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
// Satz / Nummer / Buchstabe aufheben: Bereich entfernen bzw. als weggefallen markieren.
return bearbeiteBereich(
normen,
befehl,
(text, bereich) -> {
var label = labelVon(stelle);
if (label != null) {
var einrueckung = einrueckungVon(text, bereich.von());
return TextErgebnis.ok(
text.substring(0, bereich.von())
+ einrueckung
+ label
+ " (weggefallen)"
+ text.substring(bereich.bis()));
}
return TextErgebnis.ok(
(text.substring(0, bereich.von()) + text.substring(bereich.bis()))
.replaceAll(" +", " ")
.strip());
});
}
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()) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
var absatz = absaetze.get(fundstelle.absatzIndex());
absaetze.set(fundstelle.absatzIndex(), new Absatz(neuAbsatz.get().nummer(), absatz.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
// Satz-Umnummerierungen ändern den Text nicht (Sätze sind unnummeriert).
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
* zusammen. Nur wenn alle Teile gelingen, gilt der Befehl als angewandt; sonst wird er zur
* manuellen Prüfung markiert (bereits angewandte Teile bleiben wirksam).
*/
private static AngewandteAenderung wendeSammelAn(
List<Norm> normen, List<Gliederung> gliederungen, Sammelbefehl befehl) {
var betroffene = new LinkedHashSet<String>();
var fehler = new ArrayList<String>();
int i = 1;
for (var teil : befehl.teilbefehle()) {
var ergebnis = wendeAn(normen, gliederungen, teil);
betroffene.addAll(ergebnis.betroffeneEnbez());
if (ergebnis.status() != Status.ANGEWANDT) {
fehler.add("Teil " + i + " (" + teil.stelle().anzeigeText() + "): " + ergebnis.begruendung());
}
i++;
}
if (fehler.isEmpty()) {
return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene);
}
return new AngewandteAenderung(
befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene);
}
// --- Gemeinsame Helfer ---------------------------------------------------------------------
private record TextErgebnis(@Nullable String text, @Nullable String fehler) {
static TextErgebnis ok(String text) {
return new TextErgebnis(text, null);
}
static TextErgebnis fehler(String begruendung) {
return new TextErgebnis(null, begruendung);
}
}
private interface TextOperation {
TextErgebnis wende(String text);
}
private interface BereichsOperation {
TextErgebnis wende(String text, SatzTeiler.SatzBereich bereich);
}
/**
* Wendet eine Textoperation auf den durch die Stelle bestimmten Bereich an. Ohne
* Bereichs-/Absatzangabe wird die Operation auf jeden Absatz der Norm versucht; sie muss dann in
* genau einem Absatz anwendbar sein.
*/
private static AngewandteAenderung bearbeiteText(
List<Norm> normen, Aenderungsbefehl befehl, TextOperation operation) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
var norm = normen.get(fundstelle.normIndex());
if (fundstelle.absatzIndex() != null) {
var absaetze = new ArrayList<>(norm.absaetze());
var absatz = absaetze.get(fundstelle.absatzIndex());
var neuerText = wendeAufBereichAn(absatz.text(), fundstelle.bereich(), operation);
if (neuerText.fehler() != null) {
return manuell(befehl, neuerText.fehler());
}
absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
// Ganze Norm als Wirkungsbereich: Operation muss in genau einem Absatz gelingen.
var absaetze = new ArrayList<>(norm.absaetze());
Integer trefferIndex = null;
TextErgebnis treffer = null;
String letzterFehler = "Norm hat keine Absätze.";
for (int i = 0; i < absaetze.size(); i++) {
var versuch = operation.wende(absaetze.get(i).text());
if (versuch.fehler() == null) {
if (trefferIndex != null) {
return manuell(
befehl, "Mehrere Absätze von " + norm.enbez() + " kommen infrage; mehrdeutig.");
}
trefferIndex = i;
treffer = versuch;
} else {
letzterFehler = versuch.fehler();
}
}
if (trefferIndex == null) {
return manuell(befehl, letzterFehler);
}
absaetze.set(trefferIndex, absaetze.get(trefferIndex).mitText(treffer.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
/** Wie {@link #bearbeiteText}, aber die Operation braucht den konkreten Zeichenbereich. */
private static AngewandteAenderung bearbeiteBereich(
List<Norm> normen, Aenderungsbefehl befehl, BereichsOperation operation) {
var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle());
if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
return manuell(befehl, nicht.begruendung());
}
var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
if (fundstelle.absatzIndex() == null || fundstelle.bereich() == null) {
return manuell(
befehl,
"„" + befehl.stelle().anzeigeText() + "“ bezeichnet keinen konkreten Textbereich.");
}
var norm = normen.get(fundstelle.normIndex());
var absaetze = new ArrayList<>(norm.absaetze());
var absatz = absaetze.get(fundstelle.absatzIndex());
var neuerText = operation.wende(absatz.text(), fundstelle.bereich());
if (neuerText.fehler() != null) {
return manuell(befehl, neuerText.fehler());
}
absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text()));
normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
return angewandt(befehl, norm.enbez());
}
private static TextErgebnis wendeAufBereichAn(
String text, SatzTeiler.@Nullable SatzBereich bereich, TextOperation operation) {
if (bereich == null) {
return operation.wende(text);
}
var ausschnitt = text.substring(bereich.von(), bereich.bis());
var ergebnis = operation.wende(ausschnitt);
if (ergebnis.fehler() != null) {
return ergebnis;
}
return TextErgebnis.ok(
text.substring(0, bereich.von()) + ergebnis.text() + text.substring(bereich.bis()));
}
private record NormAufloesung(int normIndex, @Nullable String fehler) {}
private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) {
if (stelle.paragraph().isEmpty()) {
return new NormAufloesung(-1, "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText());
}
var enbez = "§ " + stelle.paragraph().get().nummer();
int index = StellenAufloeser.normIndex(gesetzAus(normen), enbez);
if (index < 0) {
return new NormAufloesung(-1, enbez + " existiert nicht im Gesetz.");
}
return new NormAufloesung(index, null);
}
private static boolean nurParagraph(Stelle stelle) {
return stelle.komponenten().size() == 1
&& stelle.komponenten().get(0) instanceof Stelle.Paragraph;
}
private static boolean feinsteIstAbsatz(Stelle stelle) {
return stelle.komponenten().stream()
.noneMatch(
k ->
k instanceof Stelle.SatzNr
|| k instanceof Stelle.NummerNr
|| k instanceof Stelle.BuchstabeNr);
}
private static @Nullable String labelVon(Stelle stelle) {
for (var komponente : stelle.komponenten().reversed()) {
if (komponente instanceof Stelle.NummerNr n) {
return n.nummer() + ".";
}
if (komponente instanceof Stelle.BuchstabeNr b) {
return b.kennung() + ")";
}
}
return null;
}
private static String einrueckungVon(String text, int position) {
int i = position;
var sb = new StringBuilder();
while (i < text.length() && (text.charAt(i) == ' ' || text.charAt(i) == '\t')) {
sb.append(text.charAt(i));
i++;
}
return sb.toString();
}
private record Fundpruefung(int index, @Nullable String fehler) {}
private static Fundpruefung eindeutigeFundstelle(String text, String woerter) {
int anzahl = zaehleVorkommen(text, woerter);
if (anzahl == 0) {
return new Fundpruefung(-1, "„" + woerter + "“ kommt im Zieltext nicht vor.");
}
if (anzahl > 1) {
return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig.");
}
return new Fundpruefung(text.indexOf(woerter), null);
}
private static int zaehleVorkommen(String text, String suchtext) {
if (suchtext.isEmpty()) {
return 0;
}
int anzahl = 0;
int index = 0;
while ((index = text.indexOf(suchtext, index)) >= 0) {
anzahl++;
index += suchtext.length();
}
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();
String titel = vorlage.titel();
var absatzStart = ABSATZ_MARKER.matcher(text);
int erster = absatzStart.find() ? absatzStart.start() : -1;
if (erster >= 0) {
var kopf = text.substring(0, erster).strip();
if (!kopf.isEmpty()) {
titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
if (titel.isEmpty()) {
titel = vorlage.titel();
}
}
return new Norm(
enbez, titel, vorlage.gliederung(), parseAbsaetze(text.substring(erster)), false);
}
// Ohne Absatzmarker (Einzelabsatz-Normen wie „§ 19 Außerkrafttreten Dieses Gesetz tritt …“):
// Steht „§ N“ allein auf der ersten Zeile, ist die zweite Zeile die Überschrift und der Rest
// der Normtext.
var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList();
if (zeilen.size() >= 3 && zeilen.get(0).matches("§\\s*\\d+[a-z]?")) {
titel = zeilen.get(1);
var rest = String.join("\n", zeilen.subList(2, zeilen.size()));
return new Norm(
enbez,
titel,
vorlage.gliederung(),
List.of(new Absatz(null, normalisiereZitatText(rest))),
false);
}
// Fallback: gesamter Text (ohne „§ N“-Präfix) als unnummerierter Absatz, Titel unverändert.
var inhalt = text.replaceFirst("^§\\s*\\S+\\s*", "");
return new Norm(
enbez,
titel,
vorlage.gliederung(),
List.of(new Absatz(null, normalisiereZitatText(inhalt))),
false);
}
/** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */
static List<Absatz> parseAbsaetze(String zitat) {
var text = zitat.strip();
var absaetze = new ArrayList<Absatz>();
var matcher = ABSATZ_MARKER.matcher(text);
int vorherigesEnde = 0;
String vorherigeNummer = null;
while (matcher.find()) {
if (matcher.start() > vorherigesEnde || vorherigeNummer != null) {
var inhalt = text.substring(vorherigesEnde, matcher.start()).strip();
if (!inhalt.isEmpty() || vorherigeNummer != null) {
absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(inhalt)));
}
}
vorherigeNummer = matcher.group(1);
vorherigesEnde = matcher.end();
}
var rest = text.substring(vorherigesEnde).strip();
if (!rest.isEmpty() || vorherigeNummer != null) {
absaetze.add(new Absatz(vorherigeNummer, normalisiereZitatText(rest)));
}
return absaetze;
}
/** Fließtext-Whitespace glätten, Aufzählungszeilen des Zitats aber erhalten. */
private static String normalisiereZitatText(String text) {
var zeilen = text.split("\n");
var sb = new StringBuilder();
for (var zeile : zeilen) {
var gestutzt = zeile.strip();
if (gestutzt.isEmpty()) {
continue;
}
if (sb.length() == 0) {
sb.append(gestutzt);
} else if (gestutzt.matches("^(\\d+[a-z]?\\.|[a-z]{1,3}\\))\\s.*")) {
// Aufzählungspunkt: eigene Zeile.
sb.append("\n ").append(gestutzt);
} else {
sb.append(' ').append(gestutzt);
}
}
return sb.toString();
}
private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, String enbez) {
return new AngewandteAenderung(
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());
}
private static Gesetz gesetzAus(List<Norm> normen) {
return new Gesetz("", null, null, normen);
}
}