#!/usr/bin/env python3

"""Check prose and messages for likely incorrect uses of "a" and "an".

The check is intentionally conservative.  English article choice depends on
pronunciation rather than spelling, so this script ignores known exceptions
and ambiguous abbreviation-like words instead of trying to be a grammar
checker.  With no path arguments, it scans tracked files in the repository.
"""

import argparse
import os
from pathlib import Path
import re
import subprocess
import sys


ARTICLE_RE = re.compile(
    r"(?<![A-Za-z0-9_<./-])\b(a|an)[ \t]{1,3}([A-Za-z][A-Za-z0-9'-]*)",
    re.IGNORECASE,
)
TAG_RE = re.compile(r"<[^>]*>")
SGML_BLOCK_OPEN_RE = re.compile(
    r"<(programlisting|screen|synopsis|literallayout|cmdsynopsis)\b[^>]*>",
    re.IGNORECASE,
)
SGML_EMPTY_RE = re.compile(r"<([A-Za-z][A-Za-z0-9_-]*)\b[^>]*/>")
MARKDOWN_CODE_RE = re.compile(r"`([^`]*)`")

PROSE_SUFFIXES = {".htm", ".html", ".man", ".md", ".rst", ".txt", ".xml", ".xsl"}
C_SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".l", ".pgc", ".s", ".y"}
SCRIPT_SUFFIXES = {".pl", ".pm", ".py", ".sh", ".t"}
COMMENT_ONLY_SUFFIXES = {".build", ".conf", ".in", ".mk", ".sample"}

# These vowel-letter prefixes begin with a consonant sound.
A_EXCEPTIONS = (
    "eucaly",
    "eugen",
    "eul",
    "eup",
    "euro",
    "ewe",
    "once",
    "one",
    "ubiquit",
    "u-",
    "uchar",
    "unicast",
    "unicode",
    "unicorn",
    "uniform",
    "unifi",
    "union",
    "unique",
    "unit",
    "univ",
    "unify",
    "unilateral",
    "uniprocessor",
    "unix",
    "upc",
    "uri",
    "unary",
    "umask",
    "usable",
    "usage",
    "use",
    "user",
    "usual",
    "utc",
    "utf",
    "utility",
    "uint",
    "uuid",
)

SILENT_H_PREFIXES = ("heir", "herb", "honest", "honor", "honour", "hour")

# A consonant in this set unambiguously has a consonant sound in ordinary
# words.  Other initial letters are often spoken as abbreviations ("an LSN",
# "an XID"), so treating them as errors would be too noisy.
UNAMBIGUOUS_CONSONANTS = frozenset("bcdgjkpqtvwyz")

# These are commonly adjacent code tokens or verbs, not article/noun pairs.
NON_ARTICLE_FOLLOWERS = frozenset(
    (
        "a",
        "already",
        "an",
        "and",
        "anyelement",
        "are",
        "as",
        "at",
        "if",
        "in",
        "inner",
        "int",
        "integer",
        "into",
        "is",
        "isnull",
        "implies",
        "of",
        "on",
        "or",
        "overlap",
        "overlaps",
        "uint",
        "uint8-array",
        "used",
        "using",
    )
)


def repository_root():
    return Path(__file__).resolve().parents[2]


def tracked_files(root):
    result = subprocess.run(
        ["git", "ls-files", "-z"],
        cwd=root,
        check=True,
        stdout=subprocess.PIPE,
    )
    names = result.stdout.decode("utf-8", errors="surrogateescape").split("\0")
    return [root / name for name in names if name]


def requested_files(paths):
    files = []
    for path in paths:
        path = path.resolve()
        if path.is_dir():
            for dirname, dirnames, filenames in os.walk(path):
                dirnames[:] = [name for name in dirnames if name != ".git"]
                files.extend(Path(dirname) / name for name in filenames)
        else:
            files.append(path)
    return files


def file_mode(path):
    suffix = path.suffix.lower()
    name = path.name

    if suffix == ".sgml":
        return "sgml"
    if suffix in PROSE_SUFFIXES or name.startswith("README"):
        return "prose"
    if suffix in C_SOURCE_SUFFIXES:
        return "c-source"
    if suffix in SCRIPT_SUFFIXES:
        return "script"
    if suffix in COMMENT_ONLY_SUFFIXES or name in {"GNUmakefile", "Makefile"}:
        return "comments"
    return None


def quoted_fragment(line, start, quote):
    i = start + 1
    while i < len(line):
        if line[i] == "\\":
            i += 2
        elif line[i] == quote:
            return line[start + 1 : i], i + 1
        else:
            i += 1
    return line[start + 1 :], len(line)


def c_source_fragments(line, in_block_comment):
    fragments = []
    i = 0

    while i < len(line):
        if in_block_comment:
            end = line.find("*/", i)
            if end < 0:
                fragments.append(line[i:])
                return fragments, True
            fragments.append(line[i:end])
            i = end + 2
            in_block_comment = False
        elif line.startswith("/*", i):
            in_block_comment = True
            i += 2
        elif line.startswith("//", i):
            fragments.append(line[i + 2 :])
            break
        elif line[i] == '"':
            fragment, i = quoted_fragment(line, i, '"')
            fragments.append(fragment)
        elif line[i] == "'":
            _, i = quoted_fragment(line, i, "'")
        else:
            i += 1

    return fragments, in_block_comment


def script_fragments(line):
    in_quote = None
    i = 0

    while i < len(line):
        if in_quote:
            if line[i] == "\\":
                i += 2
            elif line[i] == in_quote:
                in_quote = None
                i += 1
            else:
                i += 1
        elif line[i] in {'"', "'"}:
            in_quote = line[i]
            i += 1
        elif line[i] == "#":
            return [line[i + 1 :]]
        else:
            i += 1
    return []


def comment_fragment(line):
    stripped = line.lstrip()
    if stripped.startswith("#"):
        return [stripped[1:]]
    return []


def sgml_fragments(line, blocked_tag):
    fragments = []
    pos = 0

    while pos < len(line):
        if blocked_tag:
            close = re.search(
                r"</{}\s*>".format(re.escape(blocked_tag)),
                line[pos:],
                re.IGNORECASE,
            )
            if close is None:
                return fragments, blocked_tag
            pos += close.end()
            blocked_tag = None
            continue

        opening = SGML_BLOCK_OPEN_RE.search(line, pos)
        end = opening.start() if opening else len(line)
        fragment = line[pos:end]
        fragment = SGML_EMPTY_RE.sub(
            lambda match: " {} ".format(match.group(1)), fragment
        )
        fragments.append(TAG_RE.sub(" ", fragment))

        if opening is None:
            break
        blocked_tag = opening.group(1)
        pos = opening.end()

    return fragments, blocked_tag


def a_takes_an(word):
    lower = word.lower()
    if lower[0] not in "aeiou":
        return False
    if lower.startswith(A_EXCEPTIONS):
        return False
    if word[0] == "U" and (len(word) == 1 or not word[1].islower()):
        return False
    return True


def an_takes_a(word):
    lower = word.lower()

    if lower.startswith(A_EXCEPTIONS):
        return True
    if lower.startswith(SILENT_H_PREFIXES):
        return False
    if lower[0] in "aeiou":
        return False
    if lower[0] in UNAMBIGUOUS_CONSONANTS:
        return True
    return False


def check_fragment(fragment):
    for match in ARTICLE_RE.finditer(fragment):
        raw_article = match.group(1)
        article = raw_article.lower()
        word = match.group(2)
        lower_word = word.lower()

        if lower_word in NON_ARTICLE_FOLLOWERS:
            continue
        if word[0].islower() and any(char.isdigit() for char in word):
            continue
        if raw_article == "A":
            prefix = fragment[: match.start()].rstrip()
            if prefix and prefix[-1] not in ".!?:":
                continue
        if article == "a" and a_takes_an(word):
            yield "an", word
        elif article == "an" and an_takes_a(word):
            yield "a", word


def display_path(path, root):
    try:
        return path.relative_to(root)
    except ValueError:
        return path


def check_file(path, root):
    mode = file_mode(path)
    if mode is None:
        return 0

    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except UnicodeDecodeError:
        return 0
    except OSError as error:
        print(
            "{}: cannot read: {}".format(display_path(path, root), error),
            file=sys.stderr,
        )
        return 0

    findings = 0
    in_block_comment = False
    blocked_sgml_tag = None
    for lineno, line in enumerate(lines, 1):
        if mode == "sgml":
            fragments, blocked_sgml_tag = sgml_fragments(line, blocked_sgml_tag)
        elif mode == "prose":
            fragments = [MARKDOWN_CODE_RE.sub(r"\1", line)]
        elif mode == "c-source":
            fragments, in_block_comment = c_source_fragments(line, in_block_comment)
        elif mode == "script":
            fragments = script_fragments(line)
        else:
            fragments = comment_fragment(line)

        for fragment in fragments:
            for replacement, word in check_fragment(fragment):
                context = " ".join(line.strip().split())
                if len(context) > 160:
                    context = context[:157] + "..."
                print(
                    '{}:{}: use "{}" before "{}": {}'.format(
                        display_path(path, root), lineno, replacement, word, context
                    )
                )
                findings += 1
    return findings


def parse_arguments():
    parser = argparse.ArgumentParser(
        description="check likely a/an errors in tracked prose and messages"
    )
    parser.add_argument(
        "paths",
        metavar="PATH",
        nargs="*",
        type=Path,
        help="files or directories to scan (default: tracked repository files)",
    )
    return parser.parse_args()


def main():
    args = parse_arguments()
    root = repository_root()
    files = requested_files(args.paths) if args.paths else tracked_files(root)

    findings = sum(check_file(path, root) for path in sorted(set(files)))
    if findings:
        sys.stdout.flush()
        print("{} likely article error(s) found".format(findings), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
