Skip to content

03 Eigene Regeln

Asterios Raptis edited this page Mar 11, 2026 · 5 revisions

Eigene Regeln schreiben

Der Checker ist erweiterbar. Eine Regel ist jedes Callable mit folgender Signatur:

def rule_name(text: str, path: Path) -> list[StyleViolation]

Keine Basisklasse, kein Interface, kein Decorator. Einfach eine Funktion.

Dieses Tutorial zeigt Schritt für Schritt, wie eine Regel entsteht, am Beispiel der eingebauten Regel rule_non_german_quotes, die nicht-deutsche Anführungszeichen erkennt.

Schritt 1: Das Problem definieren

Deutsche Texte verwenden „ " (U+201E / U+201C) als Anführungszeichen. In der Praxis landen aber oft englische (" ", U+201C / U+201D), gerade ASCII-Quotes (") oder gemischte Varianten im Text. Das passiert beim Kopieren aus anderen Quellen, durch Editor-Autokorrektur oder durch Gewohnheit.

Das Ziel: eine Regel, die jede Zeile mit nicht-deutschen Anführungszeichen markiert.

Schritt 2: Die Erkennungsfunktion

Zuerst eine pure Function, die prüft ob eine Zeile das Problem enthält. Wichtig: Code-Spans und HTML-Attribute müssen ausgenommen werden.

import re
from pathlib import Path

# Zeichen, die in deutschen Texten nicht vorkommen sollten
_NON_GERMAN_QUOTE_RE = re.compile(r'["\u201d\u2019]')

# Inline-Code und HTML-Attribute schützen
_INLINE_CODE_RE = re.compile(r"`[^`]+`")
_HTML_ATTR_RE = re.compile(r'(?:[\w-]+)\s*=\s*"[^"]*"')


def _mask_protected(line: str) -> list[tuple[int, int]]:
    """Bereiche ermitteln, die nicht geprüft werden dürfen."""
    protected = []
    for pattern in (_INLINE_CODE_RE, _HTML_ATTR_RE):
        for m in pattern.finditer(line):
            protected.append((m.start(), m.end()))
    return protected


def _is_protected(pos: int, protected: list[tuple[int, int]]) -> bool:
    return any(start <= pos < end for start, end in protected)


def has_non_german_quotes(line: str) -> bool:
    """Prüft ob eine Zeile nicht-deutsche Anführungszeichen enthält."""
    protected = _mask_protected(line)
    return any(
        not _is_protected(m.start(), protected)
        for m in _NON_GERMAN_QUOTE_RE.finditer(line)
    )

Das ist die Kernlogik: ein Regex, eine Schutzmaske, eine Prüfung. Noch keine Abhängigkeit zu manuscript-tools.

Schritt 3: Daraus eine Checker-Regel machen

Jetzt wird die Erkennungsfunktion in das Regel-Format gebracht:

from manuscript_tools.models import StyleViolation


def rule_non_german_quotes(text: str, path: Path) -> list[StyleViolation]:
    """Markiert Zeilen mit nicht-deutschen Anführungszeichen."""
    violations: list[StyleViolation] = []
    in_code_block = False

    for lineno, line in enumerate(text.splitlines(), start=1):
        stripped = line.strip()

        # Code-Blöcke überspringen
        if stripped.startswith("```"):
            in_code_block = not in_code_block
            continue
        if in_code_block:
            continue

        if has_non_german_quotes(line):
            violations.append(
                StyleViolation(
                    file=path,
                    rule="non-german-quotes",
                    message="Nicht-deutsche Anführungszeichen gefunden",
                    line=lineno,
                )
            )
    return violations

Drei Dinge passieren hier:

  1. Code-Blöcke werden übersprungen (Fenced Code zwischen ```)
  2. Jede Zeile wird geprüft mit der Erkennungsfunktion aus Schritt 2
  3. Bei Treffer wird eine StyleViolation erzeugt mit Datei, Regelname, Nachricht und Zeilennummer

Schritt 4: Testen (ohne Dateisystem)

Regeln sind pure Functions. Tests brauchen kein Dateisystem:

from pathlib import Path


def test_finds_straight_quotes() -> None:
    text = 'Er sagte "Hallo" und ging.\n'
    violations = rule_non_german_quotes(text, Path("test.md"))
    assert len(violations) == 1
    assert violations[0].rule == "non-german-quotes"
    assert violations[0].line == 1


def test_correct_quotes_clean() -> None:
    text = 'Er sagte \u201eHallo\u201c und ging.\n'
    violations = rule_non_german_quotes(text, Path("test.md"))
    assert len(violations) == 0


def test_ignores_code_blocks() -> None:
    text = '```\necho "hello"\n```\n'
    violations = rule_non_german_quotes(text, Path("test.md"))
    assert len(violations) == 0


def test_ignores_inline_code() -> None:
    text = 'Nutze `echo "hello"` im Terminal.\n'
    violations = rule_non_german_quotes(text, Path("test.md"))
    assert len(violations) == 0

Vier Tests, vier Fälle: Treffer, kein Treffer, Code-Block, Inline-Code. Das deckt die wesentlichen Pfade ab.

Schritt 5: In den Checker einbinden

Temporär (für ein einzelnes Projekt)

from manuscript_tools.checker import check_file, CORE_RULES

my_rules = [*CORE_RULES, rule_non_german_quotes]
report = check_file(Path("kapitel.md"), rules=my_rules)

Dauerhaft (als eingebaute Regel)

Die Regel in checker.py einfügen und zur passenden Gruppe hinzufügen:

# In src/manuscript_tools/checker.py

CORE_RULES: list[StyleRule] = [
    rule_no_dashes,
    rule_no_invisible_chars,
    rule_no_repeated_words,
    rule_no_double_spaces,
    rule_non_german_quotes,   # <-- neu
]

Core-Regeln laufen bei jedem ms-check. Prosa-Regeln (PROSE_RULES_DE) laufen nur mit --strict oder über ms-validate.

Schritt 6: Vom Erkennen zum Beheben

Eine Checker-Regel meldet Probleme, behebt sie aber nicht. Der nächste Schritt ist optional: ein Korrektur-Modul, das die Anführungszeichen automatisch ersetzt.

Das ist genau das, was ms-quotes macht. Die Struktur:

quotes.py
├── convert_text()     # Pure Function: String → String + Stats
├── convert_line()     # Einzelne Zeile konvertieren
├── convert_file()     # I/O-Wrapper (lesen, schreiben, Backup)
└── has_non_german_quotes()  # Für die Checker-Regel

Die Trennung in Erkennung (Checker-Regel) und Korrektur (eigenes Modul) ist bewusst: ms-check warnt, ms-quotes fixt. Der Autor entscheidet, wann was läuft.

Regelgruppen im Überblick

Gruppe Inhalt Aktiv bei
CORE_RULES no-dashes, no-invisible-chars, no-repeated-words, no-double-spaces, non-german-quotes, broken-formatting ms-check
PROSE_RULES_DE max-sentence-length, filler-words-de, passive-voice-de ms-check --strict
ALL_RULES_DE Core + Prosa ms-validate
DEFAULT_RULES Identisch mit CORE_RULES Standard

Weitere Beispiel-Regeln

Fehlende Leerzeile nach Überschrift

def rule_blank_after_heading(text: str, path: Path) -> list[StyleViolation]:
    violations: list[StyleViolation] = []
    lines = text.splitlines()
    for i, line in enumerate(lines):
        if line.startswith("#") and i + 1 < len(lines):
            if lines[i + 1].strip():
                violations.append(
                    StyleViolation(
                        file=path,
                        rule="blank-after-heading",
                        message="Keine Leerzeile nach Überschrift",
                        line=i + 1,
                    )
                )
    return violations

Verbotene Wörter (Factory-Pattern)

def make_rule_forbidden_words(
    words: set[str],
    rule_name: str = "forbidden-words",
) -> StyleRule:
    """Factory: erstellt eine Regel für projektspezifische Wortsperren."""
    import re
    pattern = re.compile(
        r"\b(" + "|".join(re.escape(w) for w in sorted(words)) + r")\b",
        re.IGNORECASE,
    )

    def _rule(text: str, path: Path) -> list[StyleViolation]:
        return [
            StyleViolation(
                file=path,
                rule=rule_name,
                message=f"Verbotenes Wort: '{m.group(1)}'",
                line=lineno,
            )
            for lineno, line in enumerate(text.splitlines(), start=1)
            for m in pattern.finditer(line)
        ]

    return _rule

# Verwendung:
rule_no_brand_names = make_rule_forbidden_words(
    {"Google", "Facebook", "Amazon"},
    rule_name="no-brand-names",
)

Zurück: Verwendung | Weiter: Integration