diff options
| author | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-23 21:17:36 +0200 |
|---|---|---|
| committer | Matthias Andreas Benkard <code@mail.matthias.benkard.de> | 2026-08-23 21:17:36 +0200 |
| commit | 2febdfd49776c473377ec08c0d4920ede38a242a (patch) | |
| tree | d517c9a56889cb9744a03b3986c7de86ed7e334a /src/main | |
| parent | 2b614b00b5becbdef8a401ec0d4b73da6b9b1bd2 (diff) | |
Der Rest sagt fortan, welcher Art er ist
Eine Synopse mit fünfzig liegengebliebenen Befehlen war bisher eine unsortierte
Liste von achtundsechzig verschiedenen Sätzen. Wer sie las, erfuhr über jeden
einzelnen Befehl das Genaue und über das Ganze nichts — namentlich nicht, ob
eine Vorlage am Werkzeug scheitert, am Wortlaut des Änderungsgesetzes oder am
falschen Stand des Stammgesetzes.
Neben den ausformulierten Grund tritt deshalb seine Art. Vergeben wird sie
dort, wo der Grund entsteht: Die Stellenauflösung, die Textoperation und die
Zeilensuche in der Inhaltsübersicht führen sie mit ihrem Wortlaut, weil allein
die erzeugende Stelle sie kennt. Der Wortlaut bleibt unberührt und maßgeblich —
der Grundsatz der Nichtverwerfung verlangt die genaue Auskunft im Einzelfall;
gebündelt wird nur, was ohne Ordnung eine bloße Liste wäre.
Den größten Ertrag trägt die Auszählung sofort: Von den fünfzig Resten des
Gebäudeenergiegesetzes gegen den heutigen Stamm sind einundvierzig von einer
einzigen Art, nämlich „Zieltext nicht vorhanden“ — die Handschrift eines
Stammgesetzes, das jünger ist als das Änderungsgesetz.
Diese Lage rügt das Werkzeug nunmehr eigens. Dazu liest es die Standangabe der
Quelle, und zwar nicht die Standzeile allein: Das gii-XML führt daneben
Hinweise der Form „Änderung durch Art. 1 G v. 18.11.2020 … textlich
nachgewiesen“, die bezeugen, dass der Wortlaut die Änderung längst trägt,
während die Standzeile noch die vorige nennt. Genau daran scheiterten im
Infektionsschutzgesetz siebenundzwanzig Befehle, ohne dass jemand den Grund
benennen konnte. Verglichen wird das späteste genannte Datum mit der Fassung,
die der Einleitungssatz fortschreibt; ist der Stamm der jüngere, ergeht die
Rüge einmal je Dokument. Fehlt eine Angabe, schweigt die Prüfung.
Zwei Anwendungslücken sind daneben geschlossen: Das ankerlose Anfügen ganzer
Paragraphen wird angewandt statt gemeldet, und die Zurückweisung des
Einfügeankers „am Ende“ ist als bewusst gezogene Grenze kenntlich gemacht — die
Verbindung kommt im gesamten Prüfbestand kein einziges Mal vor, und Vorschriften
auf Verdacht werden nicht geschrieben.
Geprüft mit „mvnw verify“: 332 Prüfungen, kein Fehlschlag; keine gepinnte Zahl
hat sich geändert. REUSE 168/168.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I042c4389034f728c4b66f8161dc15e05938185ba
Diffstat (limited to 'src/main')
10 files changed, 626 insertions, 144 deletions
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; } |
