| |
|
|
| import argparse |
| import sys |
| from pathlib import Path |
|
|
| """ |
| Utility script that takes a list of files and returns non-zero if any of them |
| contain non-ASCII characters other than those in the allowed list. |
| |
| If --fix is used, it will attempt to replace non-ASCII characters with ASCII |
| equivalents. |
| |
| The motivation behind this script is that characters like U+00A0 (non-breaking |
| space) can cause regexes not to match and can result in surprising anchor |
| values for headings when GitHub renders Markdown as HTML. |
| """ |
|
|
|
|
| """ |
| When --fix is used, perform the following substitutions. |
| """ |
| substitutions: dict[int, str] = { |
| 0x00A0: " ", |
| 0x2011: "-", |
| 0x2013: "-", |
| 0x2014: "-", |
| 0x2018: "'", |
| 0x2019: "'", |
| 0x201C: '"', |
| 0x201D: '"', |
| 0x2026: "...", |
| 0x202F: " ", |
| } |
|
|
| """ |
| Unicode codepoints that are allowed in addition to ASCII. |
| Be conservative with this list. |
| |
| Note that it is always an option to use the hex HTML representation |
| instead of the character itself so the source code is ASCII-only. |
| For example, U+2728 (sparkles) can be written as `✨`. |
| """ |
| allowed_unicode_codepoints = { |
| 0x2728, |
| } |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser( |
| description="Check for non-ASCII characters in files." |
| ) |
| parser.add_argument( |
| "--fix", |
| action="store_true", |
| help="Rewrite files, replacing non-ASCII characters with ASCII equivalents, where possible.", |
| ) |
| parser.add_argument( |
| "files", |
| nargs="+", |
| help="Files to check for non-ASCII characters.", |
| ) |
| args = parser.parse_args() |
|
|
| has_errors = False |
| for filename in args.files: |
| path = Path(filename) |
| has_errors |= lint_utf8_ascii(path, fix=args.fix) |
| return 1 if has_errors else 0 |
|
|
|
|
| def lint_utf8_ascii(filename: Path, fix: bool) -> bool: |
| """Returns True if an error was printed.""" |
| try: |
| with open(filename, "rb") as f: |
| raw = f.read() |
| text = raw.decode("utf-8") |
| except UnicodeDecodeError as e: |
| print("UTF-8 decoding error:") |
| print(f" byte offset: {e.start}") |
| print(f" reason: {e.reason}") |
| |
| partial = raw[: e.start] |
| line = partial.count(b"\n") + 1 |
| col = e.start - (partial.rfind(b"\n") if b"\n" in partial else -1) |
| print(f" location: line {line}, column {col}") |
| return True |
|
|
| errors = [] |
| for lineno, line in enumerate(text.splitlines(keepends=True), 1): |
| for colno, char in enumerate(line, 1): |
| codepoint = ord(char) |
| if char == "\n": |
| continue |
| if ( |
| not (0x20 <= codepoint <= 0x7E) |
| and codepoint not in allowed_unicode_codepoints |
| ): |
| errors.append((lineno, colno, char, codepoint)) |
|
|
| if errors: |
| for lineno, colno, char, codepoint in errors: |
| safe_char = repr(char)[1:-1] |
| print( |
| f"Invalid character at line {lineno}, column {colno}: U+{codepoint:04X} ({safe_char})" |
| ) |
|
|
| if errors and fix: |
| print(f"Attempting to fix {filename}...") |
| num_replacements = 0 |
| new_contents = "" |
| for char in text: |
| codepoint = ord(char) |
| if codepoint in substitutions: |
| num_replacements += 1 |
| new_contents += substitutions[codepoint] |
| else: |
| new_contents += char |
| with open(filename, "w", encoding="utf-8") as f: |
| f.write(new_contents) |
| print(f"Fixed {num_replacements} of {len(errors)} errors in {filename}.") |
|
|
| return bool(errors) |
|
|
|
|
| if __name__ == "__main__": |
| sys.exit(main()) |
|
|