diff options
15 files changed, 763 insertions, 144 deletions
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt index e4ca4c7..2083a3f 100644 --- a/FASSUNGEN.txt +++ b/FASSUNGEN.txt @@ -738,6 +738,87 @@ Fortschreibung der Prüfung (3) Der Prüfbestand umfasst hiernach dreihundertdreißig Prüfungen. + +Artikel 30 +Arten der Gründe + +(1) Neben den ausformulierten Grund tritt seine Art (Klasse „Grund“): Befehl + nicht erkannt, Stelle nicht auffindbar, Zieltext nicht vorhanden, Fundstelle + mehrdeutig, Bereich unbrauchbar, Zitat unbrauchbar, Bestand widerspricht dem + Befehl, nicht unterstützt, Anwendung fehlgeschlagen. + +(2) Die Art wird dort vergeben, wo der Grund entsteht. Die Hilfsergebnisse der + Stellenauflösung, der Textoperation und der Zeilensuche in der Inhalts- + übersicht führen sie mit ihrem Wortlaut, weil allein die erzeugende Stelle + sie kennt. Bleibt ein Verbund liegen, gilt die Art seines ersten + gescheiterten Teiles. + +(3) Der ausformulierte Grund bleibt unberührt und maßgeblich. Der Grundsatz der + Nichtverwerfung erfordert die genaue Auskunft im Einzelfall; die Art ordnet + sie nur. + + +Artikel 31 +Darstellung und Auszählung + +(1) Der Abschnitt „Manuell prüfen“ der Synopse bündelt die Befehle nach der Art + des Grundes und stellt jeder Gruppe ihre Häufigkeit voran. Innerhalb der + Gruppe bleibt die Reihenfolge des Dokuments. + +(2) Der Schalter „--dump-befehle“ schließt mit der Tabelle „Gründe nach + Häufigkeit“. + + +Artikel 32 +Rüge des jüngeren Stammgesetzes + +(1) Das Stammgesetz führt fortan den Stand, den seine Quelle angibt (Klasse + „Stand“). Maßgeblich ist nicht die Standzeile allein, sondern das späteste + Datum, das irgendeine Standangabe nennt: Die Hinweise der Form „Änderung + durch … textlich nachgewiesen, dokumentarisch noch nicht abschließend + bearbeitet“ bezeugen, dass der Wortlaut die Änderung bereits trägt, während + die Standzeile noch die vorige nennt. + +(2) Ist der Wortlaut des Stammgesetzes jünger als die Fassung, die der + Einleitungssatz des Änderungsgesetzes fortschreibt, so ergeht eine Warnung. + Sie nennt beide Daten und weist darauf hin, dass Befehle, deren Zieltext + „im Zieltext nicht vorkommt“, wahrscheinlich hierauf beruhen. + +(3) Die Warnung ergeht einmal je Dokument, auch wenn mehrere Artikel dasselbe + Stammgesetz betreffen. Bei fehlender Standangabe, bei unlesbarem + Einleitungssatz und bei gleichem oder früherem Datum schweigt die Prüfung; + geraten wird nicht. + + +Artikel 33 +Anfügen ganzer Paragraphen + +Die ankerlose Anfügung ganzer Paragraphen wird angewandt, statt als nicht +unterstützt gemeldet zu werden. Angefügt wird ans Ende des Gesetzes, bei +benannter Gliederungseinheit ans Ende ihres Blocks; trägt das Zitat keinen +Normkopf, bleibt der Befehl liegen. Die verankerte Form („Nach § 114 wird +folgender § 115 angefügt“) war schon bisher eine Struktureinfügung und bleibt es. + + +Artikel 34 +Einfügung am Ende einer Einheit + +Die Zurückweisung des Einfügeankers „am Ende“ für ganze Einheiten wird als +bewusst gezogene Grenze kenntlich gemacht und ihre Meldung neu gefasst: Wo eine +Einheit ans Ende tritt, sagt das Gesetzblatt „angefügt“. Die Verbindung kommt im +gesamten Prüfbestand kein einziges Mal vor; Vorschriften auf Verdacht werden +nicht geschrieben. + + +Artikel 35 +Fortschreibung der Prüfung + +Der Prüfbestand umfasst hiernach zweiunddreißig Prüfungen mehr als vor dem +Artikel 24, nämlich dreihundertzweiunddreißig. Neu sind namentlich die Prüfung +des Anfügens ganzer Paragraphen samt der Begründung bei fehlendem Normkopf, die +Prüfung der Bündelung im Abschnitt „Manuell prüfen“ und die Prüfung, dass die +Altersrüge beim zeitrichtigen Stamm unterbleibt und beim heutigen einmal ergeht. + ════════════════════════════════════════════════════════════════════════════════ Fassung vom 22. August 2026, zuletzt geändert durch die am 22. August 2026 vorgenommenen Änderungen @@ -76,6 +76,19 @@ gerichtet ist, deren Zieltext nicht mehr besteht —, wird mitsamt der Begründu seines Scheiterns in den Abschnitt **Manuell prüfen** der Synopse aufgenommen. Er wird niemals stillschweigend übergangen. +(5) Die Begründungen sind dort nach der Art des Grundes gebündelt und ausgezählt +(Befehl nicht erkannt, Stelle nicht auffindbar, Zieltext nicht vorhanden, +Fundstelle mehrdeutig, Bereich unbrauchbar, Zitat unbrauchbar, Bestand +widerspricht dem Befehl, nicht unterstützt, Anwendung fehlgeschlagen). Der +ausformulierte Grund bleibt daneben stehen; gebündelt ist nur, was ohne Ordnung +eine bloße Liste wäre. Dieselbe Auszählung gibt `--dump-befehle` unter der +Aufschrift „Gründe nach Häufigkeit“ aus. + +(6) Gibt die Quelle des Stammgesetzes ihren Stand an und ist ihr Wortlaut jünger +als die Fassung, die der Einleitungssatz des Änderungsgesetzes fortschreibt, so +wird das eigens gerügt. Das ist die häufigste Ursache liegengebliebener Befehle +und aus der Begründung „kommt im Zieltext nicht vor“ allein nicht zu erkennen. + ## § 2 Begriffsbestimmungen Im Sinne dieses Handbuchs ist diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index a55358e..a12515a 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -7,10 +7,12 @@ import eu.mulk.aendggner.aenderung.parse.DokumentErkenner; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; +import eu.mulk.aendggner.anwendung.Grund; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.EnumMap; import java.util.List; import java.util.concurrent.Callable; import java.util.logging.LogManager; @@ -182,6 +184,7 @@ public class AendGgner implements Callable<Integer> { System.out.printf( "Angewandt: %d, manuell prüfen: %d%n", anwendung.anzahlAngewandt(), anwendung.anzahlManuell()); + haeufigkeitDerGruende(anwendung); gesetz = anwendung.neu(); } return 0; @@ -212,6 +215,27 @@ public class AendGgner implements Callable<Integer> { return ergebnis.anzahlAngewandt() == 0 && ergebnis.anzahlProtokollEintraege() > 0 ? 2 : 0; } + /** + * „Gründe nach Häufigkeit“ — die Auszählung der liegengebliebenen Befehle nach Art des Grundes. + * Bei fünfzig Resten sagt sie mit einem Blick, woran es liegt; der ausformulierte Grund steht bei + * jedem Befehl darüber. + */ + private static void haeufigkeitDerGruende(BefehlAnwender.AnwendungsErgebnis anwendung) { + var haeufigkeit = new EnumMap<Grund, Integer>(Grund.class); + for (var eintrag : anwendung.protokoll()) { + if (eintrag.status() == BefehlAnwender.Status.MANUELL_PRUEFEN && eintrag.grund() != null) { + haeufigkeit.merge(eintrag.grund(), 1, Integer::sum); + } + } + if (haeufigkeit.isEmpty()) { + return; + } + System.out.println("Gründe nach Häufigkeit:"); + haeufigkeit.entrySet().stream() + .sorted(java.util.Map.Entry.<Grund, Integer>comparingByValue().reversed()) + .forEach(e -> System.out.printf(" %4d %s%n", e.getValue(), e.getKey().bezeichnung())); + } + private static String kuerze(String text) { var einzeilig = text.replaceAll("\\s+", " "); return einzeilig.length() <= 160 ? einzeilig : einzeilig.substring(0, 157) + "…"; diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java index 75dac0a..06186c7 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -7,8 +7,12 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; import eu.mulk.aendggner.aenderung.Provenienz; import eu.mulk.aendggner.aenderung.Stelle; import eu.mulk.aendggner.gesetz.Gesetz; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; +import java.util.Locale; +import java.util.Optional; import java.util.regex.Pattern; import org.jboss.logging.Logger; import org.jspecify.annotations.Nullable; @@ -77,6 +81,11 @@ public final class AenderungsgesetzParser { continue; } log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk()); + // Nur einmal je Dokument: Ein Gesetzblatt kann mehrere Artikel auf dasselbe Stammgesetz + // richten, die Auskunft über dessen Stand gilt aber für alle zusammen. + standWarnung(GliederungsScanner.scanne(artikel.zeilen).vorspann(), ziel) + .filter(w -> !warnungen.contains(w)) + .ifPresent(warnungen::add); if (paragraphenModus && artikelFilter != null && betroffeneArtikel.contains(artikel.label)) { // Ein GVBl-Heft enthält mehrere Gesetze mit je eigener §-Zählung. warnungen.add( @@ -338,6 +347,90 @@ public final class AenderungsgesetzParser { || vorspann.matches(".*\\b" + Pattern.quote(ziel.jurabk()) + "\\b.*"); } + /** + * Der Einleitungssatz eines Änderungsgesetzes nennt die Fassung, die es fortschreibt („das + * zuletzt durch Artikel 5 des Gesetzes vom 19. Juni 2020 (BGBl. I S. 1385) geändert worden ist“), + * und die Quelle des Stammgesetzes nennt ihren eigenen Stand („Zuletzt geändert durch Art. 5 G v. + * 19.6.2020 I 1385“). Weichen beide voneinander ab, so rechnet das Werkzeug auf der falschen + * Fassung — und zwar meist auf einer <em>jüngeren</em>, weil die Portale die heute geltende + * ausliefern. + * + * <p>Das ist die häufigste Ursache liegengebliebener Befehle überhaupt und aus der Meldung „kommt + * im Zieltext nicht vor“ allein nicht zu erkennen. Verglichen werden die beiden Daten; ist das + * des Stammes das spätere, ergeht eine Warnung. Bei gleichem oder früherem Datum, bei fehlender + * Standangabe (handgepflegter Klartext) und bei unlesbarem Einleitungssatz schweigt die Prüfung — + * geraten wird nicht. + */ + private static Optional<String> standWarnung(String vorspann, Gesetz ziel) { + var stand = ziel.stand(); + if (stand == null || stand.juengsteAenderung() == null) { + return Optional.empty(); + } + var satzDatum = EINLEITUNGS_DATUM.matcher(vorspann.replaceAll("\\s+", " ")); + if (!satzDatum.find()) { + return Optional.empty(); + } + var genannt = + datum(satzDatum.group(3), MONATSNAMEN.indexOf(satzDatum.group(2)) + 1, satzDatum.group(1)); + if (genannt == null || !stand.juengsteAenderung().isAfter(genannt)) { + return Optional.empty(); + } + return Optional.of( + "Das Stammgesetz ist jünger als das Änderungsgesetz: Sein Wortlaut ist bis zum " + + stand.juengsteAenderung().format(DATUM) + + " fortgeschrieben („" + + stand.kommentar() + + "“), während der Einleitungssatz die Fassung vom " + + satzDatum.group(1) + + ". " + + satzDatum.group(2) + + " " + + satzDatum.group(3) + + " fortschreibt. Befehle, deren Zieltext „im Zieltext nicht vorkommt“, beruhen" + + " wahrscheinlich hierauf und nicht auf einem Mangel des Werkzeugs; abzuhelfen ist" + + " ihnen nur mit der zeitrichtigen Fassung des Stammgesetzes."); + } + + private static final DateTimeFormatter DATUM = + DateTimeFormatter.ofPattern("d. MMMM yyyy", Locale.GERMAN); + + /** + * „… zuletzt durch Artikel 5 des Gesetzes vom 19. Juni 2020 (BGBl. I S. 1385) geändert worden + * ist“ — das Datum der <em>letzten Änderung</em>, nicht das der Ausfertigung. Deshalb muss ihm + * die Änderungsklausel vorausgehen: Der Einleitungssatz nennt zuerst das Ausfertigungsdatum („Das + * Infektionsschutzgesetz vom 20. Juli 2000“), und dieses zu vergleichen ergäbe bei jedem je + * geänderten Gesetz eine Warnung. + */ + private static final Pattern EINLEITUNGS_DATUM = + Pattern.compile( + "durch (?:Artikel|Art\\.)[^.]{0,80}?vom (\\d{1,2})\\. (\\p{Lu}\\p{L}+) (\\d{4})"); + + private static final List<String> MONATSNAMEN = + List.of( + "Januar", + "Februar", + "März", + "April", + "Mai", + "Juni", + "Juli", + "August", + "September", + "Oktober", + "November", + "Dezember"); + + private static @Nullable LocalDate datum(String jahr, int monat, String tag) { + if (monat < 1 || monat > 12) { + return null; + } + try { + return LocalDate.of(Integer.parseInt(jahr), monat, Integer.parseInt(tag)); + } catch (RuntimeException e) { + return null; + } + } + private static final List<String> STAMM_SUFFIXE = List.of("es", "er", "en", "em", "e", "s", "n"); /** Reduziert jedes Wort grob auf seinen Stamm, um Deklinationsendungen zu neutralisieren. */ diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 145334d..cefb285 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -49,8 +49,16 @@ public final class BefehlAnwender { MANUELL_PRUEFEN } + /** + * Ein Protokolleintrag: was mit dem Befehl geschehen ist. Der ausformulierte Grund bleibt + * maßgeblich; {@code grund} ordnet ihn nur einer Art zu und ist bei angewandten Befehlen leer. + */ public record AngewandteAenderung( - Aenderungsbefehl befehl, Status status, String begruendung, Set<String> betroffeneEnbez) {} + Aenderungsbefehl befehl, + Status status, + String begruendung, + Set<String> betroffeneEnbez, + @Nullable Grund grund) {} public record AnwendungsErgebnis(Gesetz neu, List<AngewandteAenderung> protokoll) { @@ -303,7 +311,7 @@ public final class BefehlAnwender { } if (von == schritt) { // Ein Verbund ohne Teilbefehle — nichts anzuwenden, aber auch nichts zu verschweigen. - protokoll.add(manuell(befehle.get(i), "Verbund ohne Teilbefehle.")); + protokoll.add(manuell(befehle.get(i), Grund.NICHT_ERKANNT, "Verbund ohne Teilbefehle.")); continue; } if (schritte.get(von).ganzerBefehl()) { @@ -323,10 +331,14 @@ public final class BefehlAnwender { int von) { var betroffene = new LinkedHashSet<String>(); var fehler = new ArrayList<String>(); + Grund ersterGrund = null; for (int k = 0; k < teilErgebnisse.size(); k++) { var ergebnis = teilErgebnisse.get(k); betroffene.addAll(ergebnis.betroffeneEnbez()); if (ergebnis.status() != Status.ANGEWANDT) { + if (ersterGrund == null) { + ersterGrund = ergebnis.grund(); + } fehler.add( "Teil " + (k + 1) @@ -337,10 +349,12 @@ public final class BefehlAnwender { } } if (fehler.isEmpty()) { - return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene); + return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", betroffene, null); } + // Ein Verbund kann aus mehreren Gründen liegenbleiben; maßgeblich für die Bündelung ist der + // erste, der aufgetreten ist. Die Wortlaute stehen ohnehin alle nebeneinander. return new AngewandteAenderung( - befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene); + befehl, Status.MANUELL_PRUEFEN, String.join(" ", fehler), betroffene, ersterGrund); } /** Bezeichnungen, die ein Befehl freigibt — die Ausgangsstellen seiner Umnummerierungen. */ @@ -517,7 +531,7 @@ public final class BefehlAnwender { private static AngewandteAenderung wendeAn( List<Norm> normen, List<Gliederung> gliederungen, Aenderungsbefehl befehl) { if (befehl instanceof UnbekannterBefehl) { - return manuell(befehl, "Befehl nicht erkannt."); + return manuell(befehl, Grund.NICHT_ERKANNT, "Befehl nicht erkannt."); } // Sammelbefehle vor den Spezialweichen dispatchen (jeder Teil wird einzeln geroutet). if (befehl instanceof Sammelbefehl s) { @@ -539,7 +553,11 @@ public final class BefehlAnwender { 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."); + default -> + manuell( + befehl, + Grund.NICHT_UNTERSTUETZT, + "Strukturänderung wird nicht automatisch angewandt."); }; } return switch (befehl) { @@ -557,6 +575,7 @@ public final class BefehlAnwender { case BereichsUmnummerierung b -> manuell( befehl, + Grund.BEREICH_UNGUELTIG, "Der Bereich " + b.stelle().anzeigeText() + " bis " @@ -570,10 +589,10 @@ public final class BefehlAnwender { case GliederungsUeberschriften g -> wendeGliederungsUeberschriftenAn(normen, gliederungen, g); case Sammelbefehl s -> wendeSammelAn(normen, gliederungen, s); - case UnbekannterBefehl u -> manuell(befehl, "Befehl nicht erkannt."); + case UnbekannterBefehl u -> manuell(befehl, Grund.NICHT_ERKANNT, "Befehl nicht erkannt."); }; } catch (RuntimeException e) { - return manuell(befehl, "Anwendung fehlgeschlagen: " + e); + return manuell(befehl, Grund.FEHLGESCHLAGEN, "Anwendung fehlgeschlagen: " + e); } } @@ -583,7 +602,7 @@ public final class BefehlAnwender { List<Gliederung> gliederungen, Neufassung befehl) { int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad()); if (idx < 0) { - return manuell(befehl, "Gliederungseinheit nicht gefunden."); + return manuell(befehl, Grund.STELLE_NICHT_AUFLOESBAR, "Gliederungseinheit nicht gefunden."); } var alt = gliederungen.get(idx); var titel = befehl.neuerText().replaceAll("\\s+", " ").strip(); @@ -603,7 +622,7 @@ public final class BefehlAnwender { List<Gliederung> gliederungen, Aufhebung befehl) { int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad()); if (idx < 0) { - return manuell(befehl, "Gliederungseinheit nicht gefunden."); + return manuell(befehl, Grund.STELLE_NICHT_AUFLOESBAR, "Gliederungseinheit nicht gefunden."); } var alt = gliederungen.get(idx); gliederungen.set(idx, alt.mitTitel("(weggefallen)")); @@ -617,11 +636,14 @@ public final class BefehlAnwender { List<Gliederung> gliederungen, Umnummerierung befehl) { int idx = findeGliederung(gliederungen, befehl.stelle().gliederungsPfad()); if (idx < 0) { - return manuell(befehl, "Gliederungseinheit nicht gefunden: " + befehl.stelle().anzeigeText()); + return manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "Gliederungseinheit nicht gefunden: " + befehl.stelle().anzeigeText()); } var neuPfad = befehl.neu().gliederungsPfad(); if (neuPfad.isEmpty()) { - return manuell(befehl, "Neue Gliederungsbezeichnung fehlt."); + return manuell(befehl, Grund.NICHT_ERKANNT, "Neue Gliederungsbezeichnung fehlt."); } var neueBezeichnung = neuPfad.get(neuPfad.size() - 1).bezeichnung(); var alt = gliederungen.get(idx); @@ -646,7 +668,10 @@ public final class BefehlAnwender { var bezeichnung = befehl.neue().get(i).bezeichnung(); starts[i] = flach.indexOf(bezeichnung, suchAb); if (starts[i] < 0) { - return manuell(befehl, "Die Überschrift zu „" + bezeichnung + "“ fehlt im Zitat."); + return manuell( + befehl, + Grund.ZITAT_UNBRAUCHBAR, + "Die Überschrift zu „" + bezeichnung + "“ fehlt im Zitat."); } suchAb = starts[i] + bezeichnung.length(); } @@ -668,6 +693,7 @@ public final class BefehlAnwender { if (idx < 0) { return manuell( befehl, + Grund.STELLE_NICHT_AUFLOESBAR, "Gliederungseinheit nicht gefunden: " + pfad.get(pfad.size() - 1).bezeichnung()); } indizes.add(idx); @@ -692,7 +718,7 @@ public final class BefehlAnwender { // Einfügeform: hinter dem Anker-§. var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var anker = normen.get(aufloesung.normIndex()); int gliederungsPos = @@ -766,7 +792,8 @@ public final class BefehlAnwender { if (befehl.amEnde()) { var gestutzt = text.stripTrailing(); if (!gestutzt.endsWith(befehl.alt())) { - return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“."); + return TextErgebnis.fehler( + "Der Text endet nicht mit „" + befehl.alt() + "“.", Grund.ZIELTEXT_FEHLT); } var rumpf = gestutzt.substring(0, gestutzt.length() - befehl.alt().length()); var fuge = @@ -779,7 +806,8 @@ public final class BefehlAnwender { } int anzahl = zaehleVorkommen(text, befehl.alt()); if (anzahl == 0) { - return TextErgebnis.fehler("„" + befehl.alt() + "“ kommt im Zieltext nicht vor."); + return TextErgebnis.fehler( + "„" + befehl.alt() + "“ kommt im Zieltext nicht vor.", Grund.ZIELTEXT_FEHLT); } if (anzahl > 1 && !befehl.jeweils()) { return TextErgebnis.fehler( @@ -787,7 +815,8 @@ public final class BefehlAnwender { + befehl.alt() + "“ kommt " + anzahl - + "-mal vor (ohne „jeweils“ mehrdeutig)."); + + "-mal vor (ohne „jeweils“ mehrdeutig).", + Grund.MEHRDEUTIG); } if (brauchtFuge(befehl.alt(), befehl.neu())) { return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); @@ -845,11 +874,12 @@ public final class BefehlAnwender { int anzahl = zaehleVorkommen(text, befehl.woerter()); if (anzahl == 0) { return TextErgebnis.fehler( - "„" + befehl.woerter() + "“ kommt im Zieltext nicht vor."); + "„" + befehl.woerter() + "“ kommt im Zieltext nicht vor.", + Grund.ZIELTEXT_FEHLT); } if (anzahl > 1) { return TextErgebnis.fehler( - "„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor."); + "„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor.", Grund.MEHRDEUTIG); } int naht = findeVorkommen(text, befehl.woerter(), 0); return TextErgebnis.ok( @@ -870,7 +900,7 @@ public final class BefehlAnwender { case WortAnker.NachWoertern nach -> { var pruefung = eindeutigeFundstelle(text, nach.woerter()); if (pruefung.fehler() != null) { - yield TextErgebnis.fehler(pruefung.fehler()); + yield TextErgebnis.fehler(pruefung.fehler(), pruefung.grund()); } int ende = pruefung.index() + nach.woerter().length(); yield TextErgebnis.ok( @@ -882,7 +912,7 @@ public final class BefehlAnwender { case WortAnker.VorWoertern vor -> { var pruefung = eindeutigeFundstelle(text, vor.woerter()); if (pruefung.fehler() != null) { - yield TextErgebnis.fehler(pruefung.fehler()); + yield TextErgebnis.fehler(pruefung.fehler(), pruefung.grund()); } yield TextErgebnis.ok( text.substring(0, pruefung.index()) @@ -893,7 +923,8 @@ public final class BefehlAnwender { case WortAnker.VorKommaAmEnde ignoriert -> { var gestutzt = text.stripTrailing(); if (!gestutzt.endsWith(",")) { - yield TextErgebnis.fehler("Der Zieltext endet nicht mit einem Komma."); + yield TextErgebnis.fehler( + "Der Zieltext endet nicht mit einem Komma.", Grund.ZIELTEXT_FEHLT); } yield TextErgebnis.ok( gestutzt.substring(0, gestutzt.length() - 1) @@ -926,7 +957,7 @@ public final class BefehlAnwender { if (stelle.betrifftUeberschrift()) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); var titel = befehl.neuerText().replaceFirst("^(?:§|Art\\.)\\s*\\S+\\s+", "").strip(); @@ -937,7 +968,7 @@ public final class BefehlAnwender { if (nurNorm(stelle)) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var alteNorm = normen.get(aufloesung.normIndex()); var neueNorm = parseNorm(befehl.neuerText(), alteNorm.enbez(), alteNorm); @@ -948,7 +979,7 @@ public final class BefehlAnwender { 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()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1001,11 +1032,14 @@ public final class BefehlAnwender { case ABSATZ -> { var stelle = befehl.stelle(); if (stelle.absatz().isEmpty()) { - yield manuell(befehl, "Ersetzungsziel nennt keinen Absatz: " + stelle.anzeigeText()); + yield manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "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()); + yield manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1015,16 +1049,20 @@ public final class BefehlAnwender { if (befehl.bisStelle() != null) { var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle()); if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - yield manuell(befehl, nicht.begruendung()); + yield manuell(befehl, nicht.grund(), 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."); + yield manuell( + befehl, + Grund.BEREICH_UNGUELTIG, + "Ersetzungsbereich liegt nicht in einer einzigen Norm."); } bisIndex = f2.absatzIndex(); } if (bisIndex < vonIndex) { - yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend."); + yield manuell( + befehl, Grund.BEREICH_UNGUELTIG, "Ersetzungsbereich ist leer oder absteigend."); } var absaetze = new ArrayList<>(norm.absaetze()); for (int k = bisIndex; k >= vonIndex; k--) { @@ -1066,23 +1104,25 @@ public final class BefehlAnwender { // wird entfernt und durch die Paragraphen des Blocks ersetzt. var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - yield manuell(befehl, aufloesung.fehler()); + yield manuell(befehl, aufloesung.grund(), 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()); + yield manuell(befehl, a2.grund(), a2.fehler()); } bisIndex = a2.normIndex(); } if (bisIndex < vonIndex) { - yield manuell(befehl, "Ersetzungsbereich ist leer oder absteigend."); + yield manuell( + befehl, Grund.BEREICH_UNGUELTIG, "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."); + yield manuell( + befehl, Grund.ZITAT_UNBRAUCHBAR, "Im Ersetzungsblock wurde kein Paragraph erkannt."); } for (int k = bisIndex; k >= vonIndex; k--) { normen.remove(k); @@ -1102,11 +1142,11 @@ public final class BefehlAnwender { 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()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle()); if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var f1 = ((StellenAufloeser.Ergebnis.Gefunden) e1).fundstelle(); var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle(); @@ -1115,12 +1155,13 @@ public final class BefehlAnwender { || !f1.absatzIndex().equals(f2.absatzIndex()) || f1.bereich() == null || f2.bereich() == null) { - return manuell(befehl, "Satz-Bereich liegt nicht in einem einzigen Absatz."); + return manuell( + befehl, Grund.BEREICH_UNGUELTIG, "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."); + return manuell(befehl, Grund.BEREICH_UNGUELTIG, "Satz-Bereich ist leer oder absteigend."); } var norm = normen.get(f1.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); @@ -1144,11 +1185,11 @@ public final class BefehlAnwender { 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()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var e2 = StellenAufloeser.aufloese(gesetzAus(normen), befehl.bisStelle()); if (e2 instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var f1 = ((StellenAufloeser.Ergebnis.Gefunden) e1).fundstelle(); var f2 = ((StellenAufloeser.Ergebnis.Gefunden) e2).fundstelle(); @@ -1157,12 +1198,16 @@ public final class BefehlAnwender { || !f1.absatzIndex().equals(f2.absatzIndex()) || f1.bereich() == null || f2.bereich() == null) { - return manuell(befehl, "Ersetzungsbereich liegt nicht in einem einzigen Absatz."); + return manuell( + befehl, + Grund.BEREICH_UNGUELTIG, + "Ersetzungsbereich liegt nicht in einem einzigen Absatz."); } int von = f1.bereich().von(); int bis = f2.bereich().bis(); if (bis < von) { - return manuell(befehl, "Ersetzungsbereich ist leer oder absteigend."); + return manuell( + befehl, Grund.BEREICH_UNGUELTIG, "Ersetzungsbereich ist leer oder absteigend."); } var norm = normen.get(f1.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); @@ -1186,7 +1231,7 @@ public final class BefehlAnwender { case PARAGRAPH -> { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - yield manuell(befehl, aufloesung.fehler()); + yield manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var anker = normen.get(aufloesung.normIndex()); int position = aufloesung.normIndex() + (befehl.vorher() ? 0 : 1); @@ -1195,11 +1240,15 @@ public final class BefehlAnwender { if (befehl.bezeichnung() == null) { var neue = parseNormenBlock(befehl.text(), anker.gliederung()); if (neue.isEmpty()) { - yield manuell(befehl, "Im Einfügeblock wurde kein Paragraph erkannt."); + yield manuell( + befehl, Grund.ZITAT_UNBRAUCHBAR, "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."); + yield manuell( + befehl, + Grund.BESTAND_WIDERSPRICHT, + n.enbez() + " existiert bereits im Stammgesetz."); } } normen.addAll(position, neue); @@ -1209,7 +1258,8 @@ public final class BefehlAnwender { var sigelNeu = befehl.stelle().paragraph().map(Stelle.Paragraph::sigel).orElse("§"); var enbezNeu = sigelNeu + " " + befehl.bezeichnung(); if (StellenAufloeser.normIndex(gesetzAus(normen), enbezNeu) >= 0) { - yield manuell(befehl, enbezNeu + " existiert bereits im Stammgesetz."); + yield manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, enbezNeu + " existiert bereits im Stammgesetz."); } var neueNorm = parseNorm( @@ -1222,11 +1272,14 @@ public final class BefehlAnwender { case ABSATZ -> { var stelle = befehl.stelle(); if (stelle.absatz().isEmpty()) { - yield manuell(befehl, "Einfügeanker nennt keinen Absatz: " + stelle.anzeigeText()); + yield manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "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()); + yield manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1296,18 +1349,23 @@ public final class BefehlAnwender { switch (anker) { case WortAnker.NachWoertern nach -> nach.woerter(); case WortAnker.VorWoertern vor -> vor.woerter(); - // „am Ende“ ist kein Einfügeanker für ganze Einheiten — dafür gibt es die - // Anfügung. + // „am Ende“ und „vor dem Komma am Ende“ sind keine Einfügeanker für ganze + // Einheiten: Wo eine Einheit ans Ende tritt, sagt das Gesetzblatt „angefügt“, + // und dafür gibt es die Anfügung. Die Grenze ist bewusst gezogen und nicht + // etwa eine offene Lücke — im gesamten Prüfbestand (elf Änderungsdokumente aus + // Bund und sieben Ländern) kommt die Verbindung kein einziges Mal vor. case WortAnker.AmEnde ignoriert -> null; case WortAnker.VorKommaAmEnde ignoriert -> null; }; if (woerter == null) { return TextErgebnis.fehler( - "Einfügeanker ohne Wortlaut wird für Struktureinfügungen nicht unterstützt."); + "Eine ganze Einheit wird nicht „am Ende“ eingefügt, sondern angefügt;" + + " so bezeichnet es auch das Gesetzblatt.", + Grund.NICHT_UNTERSTUETZT); } var pruefung = eindeutigeFundstelle(text, woerter); if (pruefung.fehler() != null) { - return TextErgebnis.fehler(pruefung.fehler()); + return TextErgebnis.fehler(pruefung.fehler(), pruefung.grund()); } int zeilenAnfang = text.lastIndexOf('\n', pruefung.index()) + 1; int zeilenEnde = text.indexOf('\n', pruefung.index()); @@ -1332,7 +1390,7 @@ public final class BefehlAnwender { case ABSATZ -> { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - yield manuell(befehl, aufloesung.fehler()); + yield manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); @@ -1360,17 +1418,71 @@ public final class BefehlAnwender { .orElse(""); return TextErgebnis.ok(text.stripTrailing() + "\n" + block); }); - case PARAGRAPH -> manuell(befehl, "Anfügen ganzer Paragraphen wird nicht unterstützt."); + case PARAGRAPH -> haengeParagraphenAn(normen, befehl); }; } + /** + * „Dem Gesetz werden die folgenden §§ … angefügt“ — ohne Anker. Die verankerte Form („Nach § 114 + * wird folgender § 115 angefügt“) ist eine Struktureinfügung und läuft dort; hier geht es um den + * Fall, dass der Befehl nur das Ende bezeichnet. + * + * <p>Angehängt wird ans Ende des Gesetzes, bei benannter Gliederungseinheit ans Ende ihres + * Blocks. Trägt das Zitat keinen Normkopf, bleibt der Befehl liegen — ein Wortlaut ohne + * Bezeichnung ließe sich nur raten. + */ + private static AngewandteAenderung haengeParagraphenAn(List<Norm> normen, Anfuegung befehl) { + var pfad = befehl.stelle().gliederungsPfad(); + var gliederung = pfad.isEmpty() ? null : gliederungVon(normen, pfad); + var neue = parseNormenBlock(befehl.text(), gliederung); + if (neue.isEmpty()) { + return manuell( + befehl, Grund.ZITAT_UNBRAUCHBAR, "Im angefügten Block wurde kein Paragraph erkannt."); + } + for (var n : neue) { + if (StellenAufloeser.normIndex(gesetzAus(normen), n.enbez()) >= 0) { + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, n.enbez() + " existiert bereits im Stammgesetz."); + } + } + normen.addAll(endeDesBlocks(normen, gliederung), neue); + return angewandt(befehl, neue.stream().map(Norm::enbez).toList()); + } + + /** Die Gliederung, die der Pfad benennt; {@code null}, wenn keine Norm ihr angehört. */ + private static @Nullable Gliederung gliederungVon( + List<Norm> normen, List<Stelle.Gliederungseinheit> pfad) { + var bezeichnung = pfad.get(pfad.size() - 1).bezeichnung(); + for (var norm : normen) { + var g = norm.gliederung(); + if (g != null && g.bezeichnung().equals(bezeichnung)) { + return g; + } + } + return null; + } + + /** Hinter die letzte Norm der Gliederung — ohne Gliederung ans Ende des Gesetzes. */ + private static int endeDesBlocks(List<Norm> normen, @Nullable Gliederung gliederung) { + if (gliederung == null) { + return normen.size(); + } + int ende = normen.size(); + for (int i = 0; i < normen.size(); i++) { + if (gliederung.equals(normen.get(i).gliederung())) { + ende = i + 1; + } + } + return ende; + } + 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()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); var nummer = stelle.absatzbezeichnung().get().nummer(); @@ -1389,13 +1501,14 @@ public final class BefehlAnwender { return angewandt(befehl, norm.enbez()); } } - return manuell(befehl, "Absatz (" + nummer + ") nicht gefunden."); + return manuell( + befehl, Grund.STELLE_NICHT_AUFLOESBAR, "Absatz (" + nummer + ") nicht gefunden."); } if (nurNorm(stelle)) { var aufloesung = loeseNormAuf(normen, stelle); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); if (norm.weggefallen()) { @@ -1409,7 +1522,7 @@ public final class BefehlAnwender { 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()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1458,12 +1571,14 @@ public final class BefehlAnwender { var enbezNeu = befehl.neu().paragraph().get().enbez(); int idx = StellenAufloeser.normIndex(gesetzAus(normen), enbezAlt); if (idx < 0) { - return manuell(befehl, enbezAlt + " existiert nicht im Gesetz."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, 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."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, enbezNeu + " existiert bereits im Gesetz."); } // Eine bereits weggefallene Zielnorm wird durch die Umnummerierung überschrieben. if (zielIdx >= 0) { @@ -1487,7 +1602,7 @@ public final class BefehlAnwender { && feinsteIstAbsatz(befehl.neu())) { var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1532,7 +1647,8 @@ public final class BefehlAnwender { + alteMarke + "“ steht nicht am Anfang von " + befehl.stelle().anzeigeText() - + "."); + + ".", + Grund.ZIELTEXT_FEHLT); } var umbenannt = text.substring(0, bereich.von()) @@ -1550,7 +1666,7 @@ public final class BefehlAnwender { if (altSatz != null && neuSatz != null) { var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); if (fundstelle.bereich() != null && fundstelle.absatzIndex() != null) { @@ -1661,7 +1777,7 @@ public final class BefehlAnwender { List<Norm> normen, WortlautZuAbsatz befehl) { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); // Steht genau ein unnummerierter Absatz zwischen nummerierten (bayerische Folge „Dem Wortlaut @@ -1693,7 +1809,7 @@ public final class BefehlAnwender { List<Norm> normen, WortlautZuSatz befehl) { var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1701,7 +1817,9 @@ public final class BefehlAnwender { if (absatzIndex == null) { if (norm.absaetze().size() != 1) { return manuell( - befehl, norm.enbez() + " hat " + norm.absaetze().size() + " Absätze; Ziel unklar."); + befehl, + Grund.MEHRDEUTIG, + norm.enbez() + " hat " + norm.absaetze().size() + " Absätze; Ziel unklar."); } absatzIndex = 0; } @@ -1720,7 +1838,7 @@ public final class BefehlAnwender { List<Norm> normen, WortlautVoranstellung befehl) { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); @@ -1729,7 +1847,7 @@ public final class BefehlAnwender { if (befehl.stelle().absatz().isPresent()) { var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var absaetze = new ArrayList<>(norm.absaetze()); @@ -1744,7 +1862,7 @@ public final class BefehlAnwender { var neue = parseAbsaetze(befehl.text()); if (neue.isEmpty()) { - return manuell(befehl, "Im Zitat wurde kein Absatz erkannt."); + return manuell(befehl, Grund.ZITAT_UNBRAUCHBAR, "Im Zitat wurde kein Absatz erkannt."); } var absaetze = new ArrayList<>(neue); absaetze.addAll(norm.absaetze()); @@ -1757,7 +1875,7 @@ public final class BefehlAnwender { List<Norm> normen, FussnotenAufhebung befehl) { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); @@ -1783,6 +1901,7 @@ public final class BefehlAnwender { if (!fehlend.isEmpty()) { return manuell( befehl, + Grund.ZIELTEXT_FEHLT, "Fußnote " + String.join(", ", fehlend) + " kommt in " + norm.enbez() + " nicht vor."); } normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); @@ -1799,7 +1918,8 @@ public final class BefehlAnwender { var marke = Superskript.zuSuperskript(befehl.nummer()); if (!text.startsWith(marke, bereich.von())) { return TextErgebnis.fehler( - "Die Satznummerierung „" + befehl.nummer() + "“ steht nicht am Anfang des Ziels."); + "Die Satznummerierung „" + befehl.nummer() + "“ steht nicht am Anfang des Ziels.", + Grund.ZIELTEXT_FEHLT); } return TextErgebnis.ok( text.substring(0, bereich.von()) + text.substring(bereich.von() + marke.length())); @@ -1830,13 +1950,14 @@ public final class BefehlAnwender { // --- Gemeinsame Helfer --------------------------------------------------------------------- - private record TextErgebnis(@Nullable String text, @Nullable String fehler) { + private record TextErgebnis( + @Nullable String text, @Nullable String fehler, @Nullable Grund grund) { static TextErgebnis ok(String text) { - return new TextErgebnis(text, null); + return new TextErgebnis(text, null, null); } - static TextErgebnis fehler(String begruendung) { - return new TextErgebnis(null, begruendung); + static TextErgebnis fehler(String begruendung, Grund grund) { + return new TextErgebnis(null, begruendung, grund); } } @@ -1881,15 +2002,16 @@ public final class BefehlAnwender { if (befehl.stelle().betrifftUeberschrift()) { var aufloesung = loeseNormAuf(normen, befehl.stelle()); if (aufloesung.fehler() != null) { - return manuell(befehl, aufloesung.fehler()); + return manuell(befehl, aufloesung.grund(), aufloesung.fehler()); } var norm = normen.get(aufloesung.normIndex()); if (norm.titel() == null) { - return manuell(befehl, norm.enbez() + " hat keine Überschrift."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, norm.enbez() + " hat keine Überschrift."); } var titelErgebnis = operation.wende(norm.titel()); if (titelErgebnis.fehler() != null) { - return manuell(befehl, titelErgebnis.fehler()); + return manuell(befehl, titelErgebnis.grund(), titelErgebnis.fehler()); } normen.set(aufloesung.normIndex(), norm.mitTitel(titelErgebnis.text())); return angewandt(befehl, norm.enbez()); @@ -1897,7 +2019,7 @@ public final class BefehlAnwender { var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), befehl.stelle()); if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) { - return manuell(befehl, nicht.begruendung()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); var norm = normen.get(fundstelle.normIndex()); @@ -1907,7 +2029,7 @@ public final class BefehlAnwender { var absatz = absaetze.get(fundstelle.absatzIndex()); var neuerText = wendeAufBereichAn(absatz.text(), fundstelle.bereich(), operation); if (neuerText.fehler() != null) { - return manuell(befehl, neuerText.fehler()); + return manuell(befehl, neuerText.grund(), neuerText.fehler()); } absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text())); normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); @@ -1919,21 +2041,25 @@ public final class BefehlAnwender { Integer trefferIndex = null; TextErgebnis treffer = null; String letzterFehler = "Norm hat keine Absätze."; + Grund letzterGrund = Grund.BESTAND_WIDERSPRICHT; 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."); + befehl, + Grund.MEHRDEUTIG, + "Mehrere Absätze von " + norm.enbez() + " kommen infrage; mehrdeutig."); } trefferIndex = i; treffer = versuch; } else { letzterFehler = versuch.fehler(); + letzterGrund = versuch.grund(); } } if (trefferIndex == null) { - return manuell(befehl, letzterFehler); + return manuell(befehl, letzterGrund, letzterFehler); } absaetze.set(trefferIndex, absaetze.get(trefferIndex).mitText(treffer.text())); normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); @@ -1945,12 +2071,13 @@ public final class BefehlAnwender { 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()); + return manuell(befehl, nicht.grund(), nicht.begruendung()); } var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle(); if (fundstelle.absatzIndex() == null || fundstelle.bereich() == null) { return manuell( befehl, + Grund.STELLE_NICHT_AUFLOESBAR, "„" + befehl.stelle().anzeigeText() + "“ bezeichnet keinen konkreten Textbereich."); } var norm = normen.get(fundstelle.normIndex()); @@ -1958,7 +2085,7 @@ public final class BefehlAnwender { var absatz = absaetze.get(fundstelle.absatzIndex()); var neuerText = operation.wende(absatz.text(), fundstelle.bereich()); if (neuerText.fehler() != null) { - return manuell(befehl, neuerText.fehler()); + return manuell(befehl, neuerText.grund(), neuerText.fehler()); } absaetze.set(fundstelle.absatzIndex(), absatz.mitText(neuerText.text())); normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); @@ -1979,7 +2106,7 @@ public final class BefehlAnwender { text.substring(0, bereich.von()) + ergebnis.text() + text.substring(bereich.bis())); } - private record NormAufloesung(int normIndex, @Nullable String fehler) {} + private record NormAufloesung(int normIndex, @Nullable String fehler, @Nullable Grund grund) {} private static NormAufloesung loeseNormAuf(List<Norm> normen, Stelle stelle) { String enbez; @@ -1988,13 +2115,17 @@ public final class BefehlAnwender { } else if (stelle.anlagenEnbez().isPresent()) { enbez = stelle.anlagenEnbez().get(); } else { - return new NormAufloesung(-1, "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText()); + return new NormAufloesung( + -1, + "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText(), + Grund.STELLE_NICHT_AUFLOESBAR); } int index = StellenAufloeser.normIndex(gesetzAus(normen), enbez); if (index < 0) { - return new NormAufloesung(-1, enbez + " existiert nicht im Gesetz."); + return new NormAufloesung( + -1, enbez + " existiert nicht im Gesetz.", Grund.BESTAND_WIDERSPRICHT); } - return new NormAufloesung(index, null); + return new NormAufloesung(index, null, null); } private static boolean nurParagraph(Stelle stelle) { @@ -2105,17 +2236,19 @@ public final class BefehlAnwender { return sb.toString(); } - private record Fundpruefung(int index, @Nullable String fehler) {} + private record Fundpruefung(int index, @Nullable String fehler, @Nullable Grund grund) {} 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."); + return new Fundpruefung( + -1, "„" + woerter + "“ kommt im Zieltext nicht vor.", Grund.ZIELTEXT_FEHLT); } if (anzahl > 1) { - return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig."); + return new Fundpruefung( + -1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig.", Grund.MEHRDEUTIG); } - return new Fundpruefung(findeVorkommen(text, woerter, 0), null); + return new Fundpruefung(findeVorkommen(text, woerter, 0), null, null); } private static int zaehleVorkommen(String text, String suchtext) { @@ -2382,15 +2515,27 @@ public final class BefehlAnwender { private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, String enbez) { return new AngewandteAenderung( - befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez))); + befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(enbez)), null); } private static AngewandteAenderung angewandt(Aenderungsbefehl befehl, List<String> enbezliste) { - return new AngewandteAenderung(befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(enbezliste)); + return new AngewandteAenderung( + befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(enbezliste), null); } - private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) { - return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of()); + /** + * Der Befehl bleibt liegen. Die Art des Grundes darf durchgereicht sein ({@link NormAufloesung}, + * {@link TextErgebnis}, {@link StellenAufloeser.Ergebnis.NichtGefunden} tragen sie mit ihrem + * Wortlaut, weil nur die erzeugende Stelle sie kennt); fehlt sie, gilt die unauffindbare Stelle. + */ + private static AngewandteAenderung manuell( + Aenderungsbefehl befehl, @Nullable Grund grund, String begruendung) { + return new AngewandteAenderung( + befehl, + Status.MANUELL_PRUEFEN, + begruendung, + Set.of(), + grund == null ? Grund.STELLE_NICHT_AUFLOESBAR : grund); } private static Gesetz gesetzAus(List<Norm> normen) { diff --git a/src/main/java/eu/mulk/aendggner/anwendung/Grund.java b/src/main/java/eu/mulk/aendggner/anwendung/Grund.java new file mode 100644 index 0000000..065567c --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/anwendung/Grund.java @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.anwendung; + +/** + * Die Art eines Grundes, aus dem ein Befehl nicht angewandt wurde. + * + * <p>Der ausformulierte Grund bleibt daneben stehen und wird nicht ersetzt — der Grundsatz, nichts + * stillschweigend zu verwerfen, verlangt die genaue Auskunft im Einzelfall. Die Art ordnet sie nur: + * Eine Synopse mit fünfzig Resten ist ohne Ordnung eine Liste, aus der niemand einen Schluss zieht; + * nach Arten gebündelt und ausgezählt zeigt sie auf einen Blick, ob eine Vorlage am Werkzeug + * scheitert, am Wortlaut des Änderungsgesetzes oder am falschen Stand des Stammgesetzes. + * + * <p>Die Reihenfolge ist die der Darstellung: erst, was am Befehl liegt, dann, was am Stammgesetz + * liegt, zuletzt die bewusst gezogenen Grenzen. + */ +public enum Grund { + + /** Der Befehl wurde nicht als Änderungsbefehl erkannt. */ + NICHT_ERKANNT("Befehl nicht erkannt"), + + /** Die benannte Stelle ist im Stammgesetz nicht auffindbar. */ + STELLE_NICHT_AUFLOESBAR("Stelle nicht auffindbar"), + + /** Der zu ersetzende oder zu streichende Wortlaut steht nicht im Zieltext. */ + ZIELTEXT_FEHLT("Zieltext nicht vorhanden"), + + /** Die Stelle oder der Zieltext kommt mehrfach vor; welche Fundstelle gemeint ist, ist offen. */ + MEHRDEUTIG("Fundstelle mehrdeutig"), + + /** Ein Bereich ist leer, absteigend oder überschreitet die Einheit, in der er liegen müsste. */ + BEREICH_UNGUELTIG("Bereich unbrauchbar"), + + /** Das Zitat trägt nicht, was der Befehl ihm entnehmen will (Normkopf, Absatz, Überschrift). */ + ZITAT_UNBRAUCHBAR("Zitat unbrauchbar"), + + /** Der Bestand des Stammgesetzes widerspricht dem Befehl (Bezeichnung belegt oder fehlt). */ + BESTAND_WIDERSPRICHT("Bestand widerspricht dem Befehl"), + + /** Die Anwendung dieser Befehlsart ist nicht umgesetzt; die Grenze ist bewusst gezogen. */ + NICHT_UNTERSTUETZT("Nicht unterstützt"), + + /** Die Anwendung ist mit einem Fehler abgebrochen. */ + FEHLGESCHLAGEN("Anwendung fehlgeschlagen"); + + private final String bezeichnung; + + Grund(String bezeichnung) { + this.bezeichnung = bezeichnung; + } + + /** Die Aufschrift der Gruppe in der Synopse. */ + public String bezeichnung() { + return bezeichnung; + } +} diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java index 5ffe0b7..af1034e 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java @@ -47,21 +47,28 @@ final class InhaltsuebersichtAnwender { var von = zielKette(s.stelle()); var bis = s.bisStelle() != null ? zielKette(s.bisStelle()) : von; if (von.isEmpty() || bis.isEmpty()) { - yield manuell(befehl, "Angabe-Bereich nennt kein auflösbares Ziel."); + yield manuell( + befehl, Grund.STELLE_NICHT_AUFLOESBAR, "Angabe-Bereich nennt kein auflösbares Ziel."); } yield ersetzeZeilen(normen, befehl, von, bis, s.text()); } case Aufhebung a -> { var ziel = zielKette(a.stelle()); if (ziel.isEmpty()) { - yield manuell(befehl, "Zu streichende Angabe nennt kein auflösbares Ziel."); + yield manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "Zu streichende Angabe nennt kein auflösbares Ziel."); } yield ersetzeZeilen(normen, befehl, ziel, ziel, null); } case StrukturEinfuegung e -> { var anker = zielKette(e.stelle()); if (anker.isEmpty()) { - yield manuell(befehl, "Einfügeanker in der Inhaltsübersicht nennt kein Ziel."); + yield manuell( + befehl, + Grund.STELLE_NICHT_AUFLOESBAR, + "Einfügeanker in der Inhaltsübersicht nennt kein Ziel."); } yield fuegeZeilenEin(normen, befehl, anker, e.vorher(), e.text()); } @@ -69,7 +76,10 @@ final class InhaltsuebersichtAnwender { case Aenderungsbefehl.Streichung ignoriert -> null; case Aenderungsbefehl.WoerterEinfuegung ignoriert -> null; default -> - manuell(befehl, "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt."); + manuell( + befehl, + Grund.NICHT_UNTERSTUETZT, + "Änderungen an der Inhaltsübersicht werden nicht automatisch angewandt."); }; } @@ -105,19 +115,23 @@ final class InhaltsuebersichtAnwender { @Nullable String zitat) { int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); if (normIndex < 0) { - return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, "Das Gesetz enthält keine Inhaltsübersicht."); } var norm = normen.get(normIndex); var vonFund = findeZeile(norm, von); if (vonFund.fehler() != null) { - return manuell(befehl, vonFund.fehler()); + return manuell(befehl, vonFund.grund(), vonFund.fehler()); } var bisFund = von.equals(bis) ? vonFund : findeZeile(norm, bis); if (bisFund.fehler() != null) { - return manuell(befehl, bisFund.fehler()); + return manuell(befehl, bisFund.grund(), bisFund.fehler()); } if (vonFund.absatzIndex() != bisFund.absatzIndex() || bisFund.bisZeile() < vonFund.vonZeile()) { - return manuell(befehl, "Angabe-Bereich liegt nicht zusammenhängend in der Inhaltsübersicht."); + return manuell( + befehl, + Grund.BEREICH_UNGUELTIG, + "Angabe-Bereich liegt nicht zusammenhängend in der Inhaltsübersicht."); } var zeilen = new ArrayList<>(zeilenVon(norm, vonFund.absatzIndex())); var einrueckung = einrueckungVon(zeilen.get(vonFund.vonZeile())); @@ -139,12 +153,13 @@ final class InhaltsuebersichtAnwender { String zitat) { int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); if (normIndex < 0) { - return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, "Das Gesetz enthält keine Inhaltsübersicht."); } var norm = normen.get(normIndex); var fund = findeZeile(norm, anker); if (fund.fehler() != null) { - return manuell(befehl, fund.fehler()); + return manuell(befehl, fund.grund(), fund.fehler()); } var zeilen = new ArrayList<>(zeilenVon(norm, fund.absatzIndex())); var einrueckung = einrueckungVon(zeilen.get(fund.vonZeile())); @@ -156,9 +171,10 @@ final class InhaltsuebersichtAnwender { // --- Zeilenmodell ---------------------------------------------------------------------------- - private record Zeilenfund(int absatzIndex, int vonZeile, int bisZeile, @Nullable String fehler) { - static Zeilenfund fehlgeschlagen(String begruendung) { - return new Zeilenfund(-1, -1, -1, begruendung); + private record Zeilenfund( + int absatzIndex, int vonZeile, int bisZeile, @Nullable String fehler, @Nullable Grund grund) { + static Zeilenfund fehlgeschlagen(String begruendung, Grund grund) { + return new Zeilenfund(-1, -1, -1, begruendung, grund); } } @@ -190,19 +206,22 @@ final class InhaltsuebersichtAnwender { int treffer = eindeutigeZeile(zeilen, zeilenMuster(ziel), von, bis); if (treffer == -2) { return Zeilenfund.fehlgeschlagen( - "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig."); + "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig.", + Grund.MEHRDEUTIG); } if (treffer >= 0) { if (gefunden != null) { return Zeilenfund.fehlgeschlagen( - "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig."); + "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht mehrdeutig.", + Grund.MEHRDEUTIG); } - gefunden = new Zeilenfund(a, treffer, treffer, null); + gefunden = new Zeilenfund(a, treffer, treffer, null, null); } } if (gefunden == null) { return Zeilenfund.fehlgeschlagen( - "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht nicht auffindbar."); + "Die Angabe zu „" + anzeige(ziel) + "“ ist in der Inhaltsübersicht nicht auffindbar.", + Grund.STELLE_NICHT_AUFLOESBAR); } return gefunden; } @@ -275,7 +294,8 @@ final class InhaltsuebersichtAnwender { List<Norm> normen, Neufassung befehl) { int normIndex = StellenAufloeser.normIndex(gesetzAus(normen), ENBEZ); if (normIndex < 0) { - return manuell(befehl, "Das Gesetz enthält keine Inhaltsübersicht."); + return manuell( + befehl, Grund.BESTAND_WIDERSPRICHT, "Das Gesetz enthält keine Inhaltsübersicht."); } var flach = befehl.neuerText().replaceAll("\\s+", " ").strip(); flach = flach.replaceFirst("^Inhaltsübersicht\\s*", ""); @@ -284,6 +304,7 @@ final class InhaltsuebersichtAnwender { if (flach.contains("wie folgt geändert") || flach.contains(" wird wie folgt gefasst")) { return manuell( befehl, + Grund.ZITAT_UNBRAUCHBAR, "Das Zitat der neuen Inhaltsübersicht enthält Befehlstext — vermutlich ist ein" + " Anführungszeichen unbalanciert; bitte manuell prüfen."); } @@ -305,7 +326,8 @@ final class InhaltsuebersichtAnwender { } } if (zeilen.size() < 2) { - return manuell(befehl, "Das Zitat enthält keine erkennbare Inhaltsübersicht."); + return manuell( + befehl, Grund.ZITAT_UNBRAUCHBAR, "Das Zitat enthält keine erkennbare Inhaltsübersicht."); } var norm = normen.get(normIndex); normen.set( @@ -387,11 +409,17 @@ final class InhaltsuebersichtAnwender { private static AngewandteAenderung angewandt(Aenderungsbefehl befehl) { return new AngewandteAenderung( - befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(ENBEZ))); + befehl, Status.ANGEWANDT, "", new LinkedHashSet<>(List.of(ENBEZ)), null); } - private static AngewandteAenderung manuell(Aenderungsbefehl befehl, String begruendung) { - return new AngewandteAenderung(befehl, Status.MANUELL_PRUEFEN, begruendung, Set.of()); + private static AngewandteAenderung manuell( + Aenderungsbefehl befehl, @Nullable Grund grund, String begruendung) { + return new AngewandteAenderung( + befehl, + Status.MANUELL_PRUEFEN, + begruendung, + Set.of(), + grund == null ? Grund.STELLE_NICHT_AUFLOESBAR : grund); } private static eu.mulk.aendggner.gesetz.Gesetz gesetzAus(List<Norm> normen) { diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java index b8d22d6..fbc3f65 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java @@ -26,14 +26,19 @@ final class StellenAufloeser { sealed interface Ergebnis { record Gefunden(Fundstelle fundstelle) implements Ergebnis {} - record NichtGefunden(String begruendung) implements Ergebnis {} + /** + * @param grund die Art des Grundes; der ausformulierte Grund steht daneben und bleibt + * maßgeblich (siehe {@link Grund}). + */ + record NichtGefunden(String begruendung, Grund grund) implements Ergebnis {} } private StellenAufloeser() {} static Ergebnis aufloese(Gesetz gesetz, Stelle stelle) { if (stelle.istLeer()) { - return new Ergebnis.NichtGefunden("Stelle nennt keine Fundstelle im Gesetz."); + return new Ergebnis.NichtGefunden( + "Stelle nennt keine Fundstelle im Gesetz.", Grund.STELLE_NICHT_AUFLOESBAR); } // 1. Norm bestimmen. @@ -45,11 +50,14 @@ final class StellenAufloeser { } else if (stelle.anlagenEnbez().isPresent()) { enbez = stelle.anlagenEnbez().get(); } else { - return new Ergebnis.NichtGefunden("Stelle nennt keinen Paragraphen: " + stelle.anzeigeText()); + return new Ergebnis.NichtGefunden( + "Stelle nennt keinen Paragraphen: " + stelle.anzeigeText(), + Grund.STELLE_NICHT_AUFLOESBAR); } int normIndex = normIndex(gesetz, enbez); if (normIndex < 0) { - return new Ergebnis.NichtGefunden(enbez + " existiert nicht im Gesetz."); + return new Ergebnis.NichtGefunden( + enbez + " existiert nicht im Gesetz.", Grund.BESTAND_WIDERSPRICHT); } var norm = gesetz.normen().get(normIndex); @@ -59,7 +67,8 @@ final class StellenAufloeser { var nummer = stelle.absatz().get().nummer(); absatzIndex = absatzIndex(norm, nummer); if (absatzIndex < 0) { - return new Ergebnis.NichtGefunden(enbez + " hat keinen Absatz " + nummer + "."); + return new Ergebnis.NichtGefunden( + enbez + " hat keinen Absatz " + nummer + ".", Grund.STELLE_NICHT_AUFLOESBAR); } } @@ -86,7 +95,8 @@ final class StellenAufloeser { + norm.absaetze().size() + " Absätze; „" + stelle.anzeigeText() - + "“ ist ohne Absatzangabe nicht eindeutig."); + + "“ ist ohne Absatzangabe nicht eindeutig.", + Grund.MEHRDEUTIG); } trefferAbsatz = i; trefferBereich = kandidat; @@ -94,7 +104,8 @@ final class StellenAufloeser { } if (trefferAbsatz == null) { return new Ergebnis.NichtGefunden( - "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar."); + "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar.", + Grund.STELLE_NICHT_AUFLOESBAR); } return new Ergebnis.Gefunden(new Fundstelle(normIndex, trefferAbsatz, trefferBereich)); } @@ -104,7 +115,8 @@ final class StellenAufloeser { var bereich = loeseFeinKomponentenAuf(stelle, text); if (bereich == null) { return new Ergebnis.NichtGefunden( - "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar."); + "„" + stelle.anzeigeText() + "“ ist im Text von " + enbez + " nicht auffindbar.", + Grund.STELLE_NICHT_AUFLOESBAR); } return new Ergebnis.Gefunden(new Fundstelle(normIndex, absatzIndex, bereich)); } diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java index bbbf47c..112247d 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/Gesetz.java @@ -4,21 +4,40 @@ package eu.mulk.aendggner.gesetz; import java.util.List; import java.util.Optional; +import org.jspecify.annotations.Nullable; /** * Ein Stammgesetz, bestehend aus einer geordneten Liste von Einzelnormen und den (ebenfalls * geordneten) Gliederungseinheiten (Teil/Abschnitt/Unterabschnitt/…), die die Normen strukturieren. + * + * @param stand der Stand der Quelle, soweit sie einen angibt; {@code null} bei handgepflegtem + * Klartext. An ihm hängt die Auskunft, ob das Stammgesetz jünger ist als das Änderungsgesetz, + * das darauf angewandt wird. */ public record Gesetz( - String jurabk, String langue, String kurzue, List<Norm> normen, List<Gliederung> gliederungen) { + String jurabk, + String langue, + String kurzue, + List<Norm> normen, + List<Gliederung> gliederungen, + @Nullable Stand stand) { public Gesetz { normen = List.copyOf(normen); gliederungen = List.copyOf(gliederungen); } + public Gesetz( + String jurabk, + String langue, + String kurzue, + List<Norm> normen, + List<Gliederung> gliederungen) { + this(jurabk, langue, kurzue, normen, gliederungen, null); + } + public Gesetz(String jurabk, String langue, String kurzue, List<Norm> normen) { - this(jurabk, langue, kurzue, normen, List.of()); + this(jurabk, langue, kurzue, normen, List.of(), null); } public Optional<Norm> norm(String enbez) { @@ -26,14 +45,14 @@ public record Gesetz( } public Gesetz mitNormen(List<Norm> neueNormen) { - return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen); + return new Gesetz(jurabk, langue, kurzue, neueNormen, gliederungen, stand); } public Gesetz mitGliederungen(List<Gliederung> neueGliederungen) { - return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen); + return new Gesetz(jurabk, langue, kurzue, normen, neueGliederungen, stand); } public Gesetz mitLangue(String neuerLangtitel) { - return new Gesetz(jurabk, neuerLangtitel, kurzue, normen, gliederungen); + return new Gesetz(jurabk, neuerLangtitel, kurzue, normen, gliederungen, stand); } } diff --git a/src/main/java/eu/mulk/aendggner/gesetz/Stand.java b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java new file mode 100644 index 0000000..26a6ee4 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/gesetz/Stand.java @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.gesetz; + +import java.time.LocalDate; +import org.jspecify.annotations.Nullable; + +/** + * Der Stand einer konsolidierten Fassung, wie ihn die Quelle selbst angibt. + * + * <p>Die Standzeile allein trägt nicht weit genug. Das gii-XML führt neben ihr Hinweise der Form + * „Änderung durch Art. 1 G v. 18.11.2020 I 2397 … textlich nachgewiesen, dokumentarisch noch nicht + * abschließend bearbeitet“: Der Wortlaut ist dann bereits fortgeschrieben, die Standzeile nennt + * aber noch die vorige Änderung. Wer nur sie liest, hält eine Fassung für älter, als sie ist — + * genau der Irrtum, an dem im Infektionsschutzgesetz siebenundzwanzig Befehle scheiterten, ohne + * dass jemand den Grund benennen konnte. + * + * @param kommentar die Standzeile im Wortlaut der Quelle, für die Anzeige. + * @param juengsteAenderung das späteste Datum, das irgendeine Standangabe nennt — der wirkliche + * Stand des Wortlauts; {@code null}, wenn keine Angabe ein Datum trägt. + */ +public record Stand(String kommentar, @Nullable LocalDate juengsteAenderung) {} diff --git a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java index 65e005c..8eeb91f 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/gii/GiiXmlLoader.java @@ -7,10 +7,12 @@ 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 eu.mulk.aendggner.gesetz.Stand; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.StringReader; import java.nio.file.Path; +import java.time.LocalDate; import java.util.ArrayList; import java.util.regex.Pattern; import javax.xml.XMLConstants; @@ -47,6 +49,7 @@ public final class GiiXmlLoader { String jurabk = null; String langue = null; String kurzue = null; + Stand stand = null; var normen = new ArrayList<Norm>(); var gliederungen = new ArrayList<Gliederung>(); Gliederung aktuelleGliederung = null; @@ -61,6 +64,7 @@ public final class GiiXmlLoader { jurabk = kindText(metadaten, "jurabk"); langue = kindText(metadaten, "langue"); kurzue = kindText(metadaten, "kurzue"); + stand = standangabe(metadaten); } var gliederungselement = erstesKind(metadaten, "gliederungseinheit"); @@ -91,7 +95,7 @@ public final class GiiXmlLoader { if (jurabk == null) { throw new SAXException("Keine <norm>-Elemente mit Metadaten gefunden: " + quelle.name()); } - return new Gesetz(jurabk, langue, kurzue, normen, gliederungen); + return new Gesetz(jurabk, langue, kurzue, normen, gliederungen, stand); } private static ArrayList<Absatz> leseAbsaetze(Element normElement) { @@ -161,6 +165,46 @@ public final class GiiXmlLoader { return null; } + /** „… v. 19.6.2020 I 1385“ — das Datum, das eine Standangabe nennt. */ + private static final Pattern STANDDATUM = + Pattern.compile("v\\. (\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})"); + + /** + * Der Stand des Gesetzes: die Standzeile im Wortlaut und das späteste Datum, das + * <em>irgendeine</em> Standangabe nennt. Die Hinweise („Änderung durch Art. 1 G v. 18.11.2020 … + * textlich nachgewiesen“) gehören dazu — sie bezeugen, dass der Wortlaut die Änderung bereits + * trägt, während die Standzeile noch die vorige nennt (siehe {@link Stand}). + */ + private static @Nullable Stand standangabe(Element metadaten) { + String zeile = null; + LocalDate juengste = null; + for (var angabe : kindElemente(metadaten, "standangabe")) { + var kommentar = kindText(angabe, "standkommentar"); + if (kommentar == null) { + continue; + } + if (zeile == null && "Stand".equals(kindText(angabe, "standtyp"))) { + zeile = kommentar; + } + var m = STANDDATUM.matcher(kommentar); + while (m.find()) { + try { + var datum = + LocalDate.of( + Integer.parseInt(m.group(3)), + Integer.parseInt(m.group(2)), + Integer.parseInt(m.group(1))); + if (juengste == null || datum.isAfter(juengste)) { + juengste = datum; + } + } catch (RuntimeException ignoriert) { + // Ein verschriebenes Datum („19.5..2020“) kommt vor; es bleibt außer Betracht. + } + } + } + return zeile == null ? null : new Stand(zeile, juengste); + } + private static @Nullable String kindText(Element parent, String name) { var element = erstesKind(parent, name); if (element == null) { diff --git a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java index 03f61eb..9b8f406 100644 --- a/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java +++ b/src/main/java/eu/mulk/aendggner/synopse/HtmlRenderer.java @@ -3,8 +3,11 @@ package eu.mulk.aendggner.synopse; import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; +import eu.mulk.aendggner.anwendung.BefehlAnwender; +import eu.mulk.aendggner.anwendung.Grund; import eu.mulk.aendggner.gesetz.Norm; import java.time.LocalDate; +import java.util.LinkedHashMap; import java.util.List; /** @@ -228,20 +231,38 @@ public final class HtmlRenderer { sb.append("</ul>\n"); } if (!synopse.manuellZuPruefen().isEmpty()) { - sb.append("<h3>Nicht automatisch angewandte Befehle</h3>\n<ol>\n"); - for (var eintrag : synopse.manuellZuPruefen()) { - var befehl = eintrag.befehl(); - sb.append("<li><strong>") - .append(esc(befehl.provenienz().anzeigeText())) - .append("</strong>"); - if (!(befehl instanceof UnbekannterBefehl) || !eintrag.begruendung().isEmpty()) { - sb.append(" — ").append(esc(eintrag.begruendung())); + sb.append("<h3>Nicht automatisch angewandte Befehle</h3>\n"); + // Nach Art des Grundes gebündelt und ausgezählt. Der ausformulierte Grund bleibt bei jedem + // Befehl stehen — gebündelt ist nur, was ohne Ordnung eine bloße Liste wäre. Innerhalb der + // Gruppe bleibt die Reihenfolge des Dokuments. + var gruppen = new LinkedHashMap<Grund, List<BefehlAnwender.AngewandteAenderung>>(); + for (var grund : Grund.values()) { + var eintraege = + synopse.manuellZuPruefen().stream().filter(e -> e.grund() == grund).toList(); + if (!eintraege.isEmpty()) { + gruppen.put(grund, eintraege); } - sb.append("<br><span class=\"originaltext\">") - .append(esc(befehl.provenienz().originalText())) - .append("</span></li>\n"); } - sb.append("</ol>\n"); + for (var gruppe : gruppen.entrySet()) { + sb.append("<h4>") + .append(esc(gruppe.getKey().bezeichnung())) + .append(" <span class=\"anzahl\">") + .append(gruppe.getValue().size()) + .append("</span></h4>\n<ol>\n"); + for (var eintrag : gruppe.getValue()) { + var befehl = eintrag.befehl(); + sb.append("<li><strong>") + .append(esc(befehl.provenienz().anzeigeText())) + .append("</strong>"); + if (!(befehl instanceof UnbekannterBefehl) || !eintrag.begruendung().isEmpty()) { + sb.append(" — ").append(esc(eintrag.begruendung())); + } + sb.append("<br><span class=\"originaltext\">") + .append(esc(befehl.provenienz().originalText())) + .append("</span></li>\n"); + } + sb.append("</ol>\n"); + } } sb.append("</section>\n"); } @@ -485,6 +506,24 @@ public final class HtmlRenderer { section.gliederung-aenderungen { padding-top: 1.25rem; } section.manuell { margin-top: 1.5rem; padding-top: 1.25rem; } section.manuell h3 { margin: 1rem 0 0.4rem; font-size: 0.6875rem; } + /* Die Gruppenaufschrift trägt ihre Häufigkeit in einem Kästchen wie die Randziffer der + Normen — sie ist eine Zahl und keine Zierde. */ + section.manuell h4 { + margin: 0.9rem 0 0.3rem; + font-size: 0.6875rem; + font-weight: bold; + color: var(--muted); + } + section.manuell h4 .anzahl { + display: inline-block; + min-width: 1.5rem; + margin-left: 0.35rem; + padding: 0 0.25rem; + border: 1px solid var(--linie); + background: var(--grund); + color: var(--fg); + text-align: center; + } section.manuell li { margin-bottom: 0.6rem; font-size: 0.75rem; } .originaltext { font-family: var(--antiqua); color: var(--muted); font-size: 0.8125rem; } diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index 66b7f00..97bcffb 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -66,6 +66,13 @@ class EndToEndTest { // Das Original-BGBl enthält ein überzähliges Anführungszeichen (Artikel 3) — muss als // Warnung gemeldet werden, nicht als Abbruch. assertThat(parseErgebnis.warnungen()).isNotEmpty(); + // Und der Leser erfährt, woran die Reste liegen: Das XML trägt die Änderung dieses + // Gesetzes bereits als „textlich nachgewiesen“ und ist damit jünger als das Gesetz selbst. + assertThat(parseErgebnis.warnungen()) + .anyMatch(w -> w.contains("Das Stammgesetz ist jünger als das Änderungsgesetz")) + // Die Rüge ergeht einmal, obwohl zwei Artikel dasselbe Stammgesetz betreffen. + .filteredOn(w -> w.contains("ist jünger als")) + .hasSize(1); // 4. Anwenden: wirft nicht; jeder Befehl erhält einen Protokolleintrag. Auch hier ist das XML // jünger als das Änderungsgesetz — die Sollzahlen stehen in ifsgGegenZeitrichtigenStamm(). @@ -102,6 +109,9 @@ class EndToEndTest { assertThat(parseErgebnis.artikel()).containsExactly("1", "2"); assertThat(parseErgebnis.befehle()).hasSize(75); + // Gegen den zeitrichtigen Stamm ergeht keine Altersrüge; gegen den heutigen schon (siehe + // gesamtePipelineAufIfSgBeispiel). + assertThat(parseErgebnis.warnungen()).noneMatch(w -> w.contains("ist jünger als")); var anwendung = BefehlAnwender.anwenden(gesetz, parseErgebnis.befehle()); var manuellPfade = diff --git a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java index 8b60017..1650d1b 100644 --- a/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java +++ b/src/test/java/eu/mulk/aendggner/anwendung/BefehlAnwenderTest.java @@ -342,6 +342,37 @@ class BefehlAnwenderTest { } @Test + void haengtGanzeParagraphenAnsGesetzAn() { + var befehl = + new Anfuegung( + Stelle.LEER, + Ebene.PARAGRAPH, + null, + "§ 4\nÜbergang\nWer zuvor erprobt hat, erprobt weiter.\n\n" + + "§ 5\nSchluss\nDiese Vorschrift gilt zuletzt.", + PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.ANGEWANDT); + assertThat(ergebnis.neu().normen().stream().map(Norm::enbez)) + .containsExactly("§ 1", "§ 2", "§ 3", "§ 4", "§ 5"); + assertThat(ergebnis.neu().norm("§ 4").orElseThrow().titel()).isEqualTo("Übergang"); + } + + @Test + void haengtNichtAnUndBegruendetEsWennDasZitatKeinenNormkopfTraegt() { + var befehl = + new Anfuegung(Stelle.LEER, Ebene.PARAGRAPH, null, "Wer zuvor erprobt hat, …", PROV); + + var ergebnis = BefehlAnwender.anwenden(gesetz(), List.of(befehl)); + + assertThat(ergebnis.protokoll().get(0).status()).isEqualTo(Status.MANUELL_PRUEFEN); + assertThat(ergebnis.protokoll().get(0).grund()).isEqualTo(Grund.ZITAT_UNBRAUCHBAR); + assertThat(ergebnis.neu().normen()).hasSize(3); + } + + @Test void hebtParagraphenAuf() { var befehl = new Aufhebung(stelle(new Stelle.Paragraph("2")), PROV); diff --git a/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java index b80fc88..6d8c840 100644 --- a/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java +++ b/src/test/java/eu/mulk/aendggner/synopse/HtmlRendererTest.java @@ -72,6 +72,8 @@ class HtmlRendererTest { assertThat(html).contains("Manuell prüfen"); assertThat(html).contains("Die Nummern 1 bis 3 werden aufgehoben."); + // Nach Art des Grundes gebündelt, mit vorangestellter Häufigkeit. + assertThat(html).contains("Befehl nicht erkannt").contains("<span class=\"anzahl\">1</span>"); } @Test |
