-
Notifications
You must be signed in to change notification settings - Fork 0
03 Eigene Regeln
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.
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.
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.
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 violationsDrei Dinge passieren hier:
- Code-Blöcke werden übersprungen (Fenced Code zwischen ```)
- Jede Zeile wird geprüft mit der Erkennungsfunktion aus Schritt 2
-
Bei Treffer wird eine
StyleViolationerzeugt mit Datei, Regelname, Nachricht und Zeilennummer
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) == 0Vier Tests, vier Fälle: Treffer, kein Treffer, Code-Block, Inline-Code. Das deckt die wesentlichen Pfade ab.
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)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.
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.
| 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 |
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 violationsdef 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