Add bullet spacing check to pre-commit hooks

- Updated fix_bullet_spacing.py with --check mode for CI validation
- Added book-fix-bullet-spacing hook to auto-fix missing blank lines
  before bullet lists during commits
- Script now provides clear error messages with line numbers
This commit is contained in:
Vijay Janapa Reddi
2026-02-01 22:18:19 -05:00
parent 6a343e8767
commit f94e5514cf
2 changed files with 92 additions and 20 deletions

View File

@@ -104,6 +104,13 @@ repos:
pass_filenames: true
files: ^book/quarto/contents/.*\.qmd$
- id: book-fix-bullet-spacing
name: "Book: Fix bullet list spacing (blank line before lists)"
entry: python book/tools/scripts/utilities/fix_bullet_spacing.py
language: python
pass_filenames: true
files: ^book/quarto/contents/.*\.qmd$
- id: book-validate-json
name: "Book: Validate JSON files"
entry: python book/tools/scripts/utilities/validate_json.py

View File

@@ -4,6 +4,16 @@ Fix bullet list spacing in QMD files.
Ensures there's a blank line before bullet lists start.
Pattern: Text ending with colon followed directly by bullet should have blank line.
Usage:
# Check mode (warn only, for CI):
python fix_bullet_spacing.py --check file1.qmd file2.qmd
# Fix mode (auto-fix, default for pre-commit):
python fix_bullet_spacing.py file1.qmd file2.qmd
# Process entire directory:
python fix_bullet_spacing.py book/quarto/contents/vol1/
"""
import re
@@ -11,6 +21,35 @@ import sys
from pathlib import Path
def check_bullet_spacing(content: str) -> list[tuple[int, str]]:
"""
Check for bullet lists missing blank line before them.
Returns: list of (line_number, line_content) tuples for issues found
"""
issues = []
lines = content.split('\n')
for i, line in enumerate(lines):
if i < len(lines) - 1:
next_line = lines[i + 1]
# Line ends with : (but not in code block markers or URLs)
if (line.rstrip().endswith(':') and
not line.strip().startswith('```') and
not line.strip().startswith('#|') and
not '://' in line and
not line.strip().startswith('def ') and
not line.strip().startswith('class ') and
# Next line is a bullet
(next_line.startswith('* ') or
next_line.startswith('- ') or
next_line.startswith('- ') or
re.match(r'^\d+\. ', next_line))):
issues.append((i + 1, line.strip())) # 1-indexed line number
return issues
def fix_bullet_spacing(content: str) -> tuple[str, int]:
"""
Fix bullet lists that are missing blank line before them.
@@ -24,60 +63,86 @@ def fix_bullet_spacing(content: str) -> tuple[str, int]:
for i, line in enumerate(lines):
result.append(line)
# Check if this line ends with colon (intro text) and next line is bullet
if i < len(lines) - 1:
next_line = lines[i + 1]
# Line ends with : (but not in code block markers or URLs)
if (line.rstrip().endswith(':') and
not line.strip().startswith('```') and
not line.strip().startswith('#|') and
not '://' in line and
not line.strip().startswith('def ') and
not line.strip().startswith('class ') and
# Next line is a bullet
(next_line.startswith('* ') or
next_line.startswith('- ') or
next_line.startswith('- ') or
re.match(r'^\d+\. ', next_line))):
# Add blank line after this line (will be inserted before next)
result.append('')
fixes += 1
return '\n'.join(result), fixes
def process_file(filepath: Path, dry_run: bool = False) -> int:
"""Process a single file. Returns number of fixes."""
def process_file(filepath: Path, check_only: bool = False) -> int:
"""Process a single file. Returns number of issues/fixes."""
content = filepath.read_text()
fixed_content, fixes = fix_bullet_spacing(content)
if fixes > 0:
print(f"{filepath}: {fixes} fix(es)")
if not dry_run:
if check_only:
issues = check_bullet_spacing(content)
if issues:
print(f"{filepath}:")
for line_num, line_content in issues:
print(f" Line {line_num}: Missing blank line before bullet list")
print(f"{line_content[:60]}{'...' if len(line_content) > 60 else ''}")
return len(issues)
else:
fixed_content, fixes = fix_bullet_spacing(content)
if fixes > 0:
filepath.write_text(fixed_content)
return fixes
print(f"Fixed {fixes} bullet list(s) in {filepath}")
return fixes
def main():
import argparse
parser = argparse.ArgumentParser(description='Fix bullet list spacing in QMD files')
parser.add_argument('paths', nargs='*', default=['.'], help='Files or directories to process')
parser.add_argument('--dry-run', '-n', action='store_true', help='Show what would be fixed without making changes')
parser = argparse.ArgumentParser(
description='Fix bullet list spacing in QMD files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Check mode (CI/validation):
python fix_bullet_spacing.py --check book/quarto/contents/
# Fix mode (pre-commit default):
python fix_bullet_spacing.py file1.qmd file2.qmd
"""
)
parser.add_argument('paths', nargs='*', default=['.'],
help='Files or directories to process')
parser.add_argument('--check', '-c', action='store_true',
help='Check only, do not fix (exit 1 if issues found)')
parser.add_argument('--fix', action='store_true',
help='Auto-fix issues (default behavior)')
args = parser.parse_args()
total_fixes = 0
check_only = args.check and not args.fix
total_issues = 0
for path_str in args.paths:
path = Path(path_str)
if path.is_file() and path.suffix == '.qmd':
total_fixes += process_file(path, args.dry_run)
total_issues += process_file(path, check_only)
elif path.is_dir():
for qmd_file in path.rglob('*.qmd'):
total_fixes += process_file(qmd_file, args.dry_run)
total_issues += process_file(qmd_file, check_only)
print(f"\nTotal: {total_fixes} fix(es)" + (" (dry run)" if args.dry_run else ""))
return 0 if total_fixes == 0 else 1
if total_issues > 0:
if check_only:
print(f"\n❌ Found {total_issues} bullet list(s) missing blank line before them.")
print(" Run without --check to auto-fix, or add blank line before bullet lists.")
else:
print(f"\n✓ Fixed {total_issues} bullet list(s).")
return 1
return 0
if __name__ == '__main__':