packages feed

idna2008-1.0.0.0: internal/tools/genIdnaNFC.py

#!/usr/bin/env python3
"""
Generate Text.IDNA2008.Internal.NFC.Data from the Unicode UCD
file DerivedNormalizationProps.txt.

Usage:
    python3 internal/tools/genIdnaNFC.py <unicode-version> \\
            <DerivedNormalizationProps.txt>

Reads NFC_Quick_Check entries (the lines tagged @NFC_QC@ with
values @N@ or @M@) and writes a complete Haskell module to
stdout with two parallel range tables: codepoints whose
@NFC_QC = No@ and those whose @NFC_QC = Maybe@.  Codepoints
not covered by either are implicitly @Yes@.

The source file lives at:

    https://www.unicode.org/Public/<version>/ucd/DerivedNormalizationProps.txt
"""
from __future__ import annotations

import re
import sys
from typing import Iterable

# Property-file line shape:
#   start[..end]  ; NFC_QC; <Y|N|M> [# comment]
# We only care about NFC_QC; everything else is skipped.
LINE = re.compile(
    r"^\s*([0-9A-Fa-f]+)(?:\.\.([0-9A-Fa-f]+))?\s*;\s*NFC_QC\s*;\s*([NMY])\b"
)


def parse_lines(path: str, wanted_values: set[str]) -> list[tuple[int, int, str]]:
    """Return [(start, end, value)] for every NFC_QC line whose
    third column is in `wanted_values`.  Lines are kept as given
    (no coalescing yet)."""
    out: list[tuple[int, int, str]] = []
    with open(path, encoding="utf-8") as f:
        for raw in f:
            line = raw.split("#", 1)[0]
            m = LINE.match(line)
            if not m:
                continue
            start = int(m.group(1), 16)
            end = int(m.group(2), 16) if m.group(2) else start
            value = m.group(3)
            if value in wanted_values:
                out.append((start, end, value))
    return out


def coalesce_pairs(pairs: Iterable[tuple[int, int]]) -> list[tuple[int, int]]:
    """Sort and merge adjacent (start, end) ranges."""
    pairs = sorted(pairs, key=lambda p: p[0])
    out: list[tuple[int, int]] = []
    for s, e in pairs:
        if out and out[-1][1] + 1 >= s:
            ps, pe = out[-1]
            out[-1] = (ps, max(pe, e))
        else:
            out.append((s, e))
    return out


def cp_hex(cp: int) -> str:
    return f"0x{cp:06X}"


def emit_ranges(
    base: str,
    qc_label: str,
    ranges: list[tuple[int, int]],
) -> str:
    """Emit @<base>RangeCount@ and @<base>Ranges@ bindings for
    the given coalesced (start, end) list, with comments
    referring to the QC label (\"No\" or \"Maybe\")."""
    name = f"{base}Ranges"
    count_name = f"{base}RangeCount"
    n = len(ranges)
    lines = []
    lines.append(f"-- | Number of (start, end) range pairs in '{name}'.")
    lines.append(f"{count_name} :: Int")
    lines.append(f"{count_name} = {n}")
    lines.append("")
    lines.append(
        f"-- | Codepoint ranges with @NFC_Quick_Check = {qc_label}@,"
    )
    lines.append("-- alternating start\\/end @Word32@ pairs.")
    lines.append(f"{name} :: ByteArray")
    if n == 0:
        lines.append(f"{name} = PBA.byteArrayFromList ([] :: [Word32])")
        return "\n".join(lines)
    lines.append(f"{name} = PBA.byteArrayFromList")
    flat: list[str] = []
    for i, (s, e) in enumerate(ranges):
        if i == 0:
            flat.append(f"({cp_hex(s)} :: Word32)")
        else:
            flat.append(cp_hex(s))
        flat.append(cp_hex(e))
    body = "\n    , ".join(flat)
    lines.append(f"    [ {body}")
    lines.append("    ]")
    return "\n".join(lines)


HEADER = """\
-- |
-- Module      : Text.IDNA2008.Internal.NFC.Data
-- Description : Codepoint range tables for the Unicode
--               @NFC_Quick_Check@ property values @No@ and
--               @Maybe@.
-- Copyright   : (c) Viktor Dukhovni, 2026
-- License     : BSD-3-Clause
--
-- Maintainer  : ietf-dane@dukhovni.org
-- Stability   : unstable
--
-- AUTOMATICALLY GENERATED.  Do not edit by hand.  Regenerate with:
--
--   python3 internal/tools/genIdnaNFC.py <unicode-version> \\\\
--           <DerivedNormalizationProps.txt>
--
-- Source: https://www.unicode.org/Public/{version}/ucd/DerivedNormalizationProps.txt
-- Aligned with: Unicode {version}
-- NFC_QC=No  ranges: {n_no}
-- NFC_QC=Maybe ranges: {n_maybe}
--
-- Codepoints absent from both tables are implicitly @Yes@
-- (already in NFC).
module Text.IDNA2008.Internal.NFC.Data
    ( nfcNoRangeCount
    , nfcNoRanges
    , nfcMaybeRangeCount
    , nfcMaybeRanges
    ) where

import Data.Array.Byte (ByteArray)
import Data.Word (Word32)
import qualified Data.Primitive.ByteArray as PBA

----------------------------------------------------------------------
-- NFC_Quick_Check = No
----------------------------------------------------------------------

"""


SECTION_BREAK = """

----------------------------------------------------------------------
-- NFC_Quick_Check = Maybe
----------------------------------------------------------------------

"""


def main() -> None:
    if len(sys.argv) != 3:
        sys.stderr.write(__doc__)
        sys.exit(1)

    version = sys.argv[1]
    props_path = sys.argv[2]

    raw = parse_lines(props_path, {"N", "M"})
    no_pairs = coalesce_pairs([(s, e) for (s, e, v) in raw if v == "N"])
    maybe_pairs = coalesce_pairs([(s, e) for (s, e, v) in raw if v == "M"])

    out = sys.stdout
    out.write(
        HEADER.format(
            version=version,
            n_no=len(no_pairs),
            n_maybe=len(maybe_pairs),
        )
    )
    out.write(emit_ranges("nfcNo", "No", no_pairs))
    out.write("\n")
    out.write(SECTION_BREAK)
    out.write(emit_ranges("nfcMaybe", "Maybe", maybe_pairs))
    out.write("\n")


if __name__ == "__main__":
    main()