packages feed

idna2008-1.0.0.0: internal/tools/genIdnaScript.py

#!/usr/bin/env python3
"""
Generate Text.IDNA2008.Internal.Script.Data from the Unicode UCD
Scripts.txt extracted property file.

Usage:
    python3 internal/tools/genIdnaScript.py <unicode-version> \\
            <Scripts.txt>

Reads Scripts.txt and emits a complete Haskell module to stdout with
three tables:

  * 'greekRanges'  -- codepoints with Script = Greek
  * 'hebrewRanges' -- codepoints with Script = Hebrew
  * 'hkhRanges'    -- the union Hiragana \\| Katakana \\| Han

Each table is encoded as alternating @start, end@ Word32 entries
(both endpoints inclusive), sorted by start with abutting ranges
coalesced.  RFC 5892 Appendix A.4-A.7 are the consumers; the
Hiragana/Katakana/Han trio is merged because A.7 only ever asks
for "any of these three".

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

import re
import sys
from typing import Iterable

# Property-file line: hex-cp[..hex-cp] ; property-name [# comment]
LINE = re.compile(
    r"^\s*([0-9A-Fa-f]+)(?:\.\.([0-9A-Fa-f]+))?\s*;\s*([A-Za-z0-9_]+)"
)


def parse_lines(path: str, wanted: set[str]) -> dict[str, list[tuple[int, int]]]:
    """Return {script -> [(start, end)]} for every entry whose
    script name is in 'wanted'."""
    out: dict[str, list[tuple[int, int]]] = {s: [] for s in wanted}
    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
            script = m.group(3)
            if script not in wanted:
                continue
            start = int(m.group(1), 16)
            end = int(m.group(2), 16) if m.group(2) else start
            out[script].append((start, end))
    return out


def coalesce(pairs: Iterable[tuple[int, int]]) -> list[tuple[int, int]]:
    """Sort and merge adjacent (or overlapping) (start, end) pairs."""
    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:
    """Format a codepoint as 0xNNNNNN with at least 6 hex digits."""
    return f"0x{cp:06X}"


def emit_word32_pairs(name: str, pairs: list[tuple[int, int]]) -> str:
    """Render @name :: ByteArray ; name = PBA.byteArrayFromList [...]@
    with one (start, end) row per source line."""
    lines = [f"{name} :: ByteArray"]
    if not pairs:
        lines.append(f"{name} = PBA.byteArrayFromList ([] :: [Word32])")
        return "\n".join(lines)
    lines.append(f"{name} = PBA.byteArrayFromList")
    rendered: list[str] = []
    for i, (s, e) in enumerate(pairs):
        if i == 0:
            cell = f"({cp_hex(s)} :: Word32), {cp_hex(e)}"
        else:
            cell = f"{cp_hex(s)}, {cp_hex(e)}"
        rendered.append(cell)
    body = "\n    , ".join(rendered)
    lines.append(f"    [ {body}")
    lines.append("    ]")
    return "\n".join(lines)


HEADER = """\
-- |
-- Module      : Text.IDNA2008.Internal.Script.Data
-- Description : Compact codepoint range tables for the Unicode
--               Scripts consulted by the CONTEXTO contextual rules.
-- 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/genIdnaScript.py <unicode-version> \\\\
--           <Scripts.txt>
--
-- Source: https://www.unicode.org/Public/{version}/ucd/Scripts.txt
-- Aligned with: Unicode {version}
-- Greek ranges: {n_grek}
-- Hebrew ranges: {n_hebr}
-- HKH (Hiragana | Katakana | Han) ranges: {n_hkh}
--
-- Only the four scripts whose membership is tested by RFC 5892
-- Appendix A.4-A.7 (Greek, Hebrew, Hiragana \\/ Katakana \\/ Han) are
-- represented.  Each script's @sc@ (Script) ranges are stored as a
-- sorted sequence of @(start, end)@ pairs (inclusive on both ends),
-- packed into a single 'ByteArray' as alternating 'Word32's:
--
-- > [start0, end0, start1, end1, ...]
--
-- Lookup is binary search for the largest @start_i \\<= cp@ and a
-- bounds check @cp \\<= end_i@; see "Text.IDNA2008.Internal.Script".
--
-- The Hiragana, Katakana, and Han ranges are merged into a single
-- @hkhRanges@ table because RFC 5892 A.7 only ever asks for
-- \\"any of these three\\", and the merged table costs no extra
-- comparisons at lookup time.  Greek and Hebrew remain separate
-- because A.4 only asks for Greek and A.5\\/A.6 only for Hebrew.
module Text.IDNA2008.Internal.Script.Data
    ( greekRangeCount
    , greekRanges
    , hebrewRangeCount
    , hebrewRanges
    , hkhRangeCount
    , hkhRanges
    ) where

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

"""


SECTION_HEADING = """\

----------------------------------------------------------------------
-- {title}
----------------------------------------------------------------------

"""


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

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

    wanted = {"Greek", "Hebrew", "Hiragana", "Katakana", "Han"}
    raw = parse_lines(scripts_path, wanted)

    greek  = coalesce(raw["Greek"])
    hebrew = coalesce(raw["Hebrew"])
    hkh    = coalesce(raw["Hiragana"] + raw["Katakana"] + raw["Han"])

    out = sys.stdout
    out.write(HEADER.format(
        version=version,
        n_grek=len(greek),
        n_hebr=len(hebrew),
        n_hkh=len(hkh),
    ))

    out.write(SECTION_HEADING.format(title="Greek (Script: Grek)"))
    out.write("-- | Number of (start, end) range pairs in 'greekRanges'.\n")
    out.write("greekRangeCount :: Int\n")
    out.write(f"greekRangeCount = {len(greek)}\n\n")
    out.write("-- | Greek script codepoint ranges, alternating start\\/end.\n")
    out.write(emit_word32_pairs("greekRanges", greek))
    out.write("\n")

    out.write(SECTION_HEADING.format(title="Hebrew (Script: Hebr)"))
    out.write("-- | Number of (start, end) range pairs in 'hebrewRanges'.\n")
    out.write("hebrewRangeCount :: Int\n")
    out.write(f"hebrewRangeCount = {len(hebrew)}\n\n")
    out.write("-- | Hebrew script codepoint ranges, alternating start\\/end.\n")
    out.write(emit_word32_pairs("hebrewRanges", hebrew))
    out.write("\n")

    out.write(SECTION_HEADING.format(
        title="Hiragana | Katakana | Han (merged for RFC 5892 A.7)"))
    out.write("-- | Number of (start, end) range pairs in 'hkhRanges'.\n")
    out.write("hkhRangeCount :: Int\n")
    out.write(f"hkhRangeCount = {len(hkh)}\n\n")
    out.write("-- | Codepoints whose @sc@ (Script) is Hiragana, Katakana,\n")
    out.write("-- or Han, merged into one sorted range list.\n")
    out.write(emit_word32_pairs("hkhRanges", hkh))
    out.write("\n")


if __name__ == "__main__":
    main()