sorug 0.2.0

Ultra-high-performance, zero-copy, WHATWG-compliant URL parser
Documentation
//! Build script for `sorug`.
//!
//! 1. Ensures `tests/urltestdata.json` exists (download from WPT if missing).
//! 2. Parses `data/idna_ranges.txt` into merged, sorted `(u32, u32)` tables and
//!    emits them to `$OUT_DIR/idna_tables.rs` for O(log N) binary search at
//!    runtime (`CheckBidi` / `CheckJoiners` / RTL script classification).
//!
//! Manual WPT refresh:
//! ```bash
//! curl -fsSL -o tests/urltestdata.json \
//!   https://raw.githubusercontent.com/web-platform-tests/wpt/master/url/resources/urltestdata.json
//! ```

use std::collections::BTreeMap;
use std::env;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const WPT_URL: &str = "https://raw.githubusercontent.com/web-platform-tests/wpt/master/url/resources/urltestdata.json";
const WPT_PATH: &str = "tests/urltestdata.json";
const WPT_SETTERS_URL: &str = "https://raw.githubusercontent.com/web-platform-tests/wpt/master/url/resources/setters_tests.json";
const WPT_SETTERS_PATH: &str = "tests/setters_tests.json";
const RANGES_PATH: &str = "data/idna_ranges.txt";

/// Known table sections → Rust static identifiers in the generated file.
const SECTIONS: &[(&str, &str)] = &[
    ("bidi_ignored", "BIDI_IGNORED"),
    ("joining_letters", "JOINING_LETTERS"),
    ("rtl_non_letters", "RTL_NON_LETTERS"),
    ("rtl_alphabetic", "RTL_ALPHABETIC"),
    ("bidi_numbers", "BIDI_NUMBERS"),
    ("legacy_arabic", "LEGACY_ARABIC"),
    ("disallowed", "DISALLOWED"),
    ("ltr_letters", "LTR_LETTERS"),
    ("arabic_ext_b_letters", "ARABIC_EXT_B_LETTERS"),
    ("arabic_ext_b_compatible", "ARABIC_EXT_B_COMPATIBLE"),
    ("arabic_ext_b_era_marks", "ARABIC_EXT_B_ERA_MARKS"),
    ("arabic_ext_b_era_ok", "ARABIC_EXT_B_ERA_OK"),
    ("nfkc_via_space", "NFKC_VIA_SPACE"),
    ("uts46_ignored", "UTS46_IGNORED"),
    ("uts46_needs_map", "UTS46_NEEDS_MAP"),
];

fn main() {
    ensure_wpt_fixture(WPT_PATH, WPT_URL);
    ensure_wpt_fixture(WPT_SETTERS_PATH, WPT_SETTERS_URL);
    generate_idna_tables();
}

// ---------------------------------------------------------------------------
// WPT fixtures
// ---------------------------------------------------------------------------

fn ensure_wpt_fixture(path: &str, url: &str) {
    println!("cargo:rerun-if-changed={path}");

    let path_buf = Path::new(path);
    if path_buf.is_file() {
        return;
    }

    if let Some(parent) = path_buf.parent() {
        fs::create_dir_all(parent).unwrap_or_else(|e| {
            panic!("failed to create {}: {e}", parent.display());
        });
    }

    eprintln!("cargo:warning=downloading WPT fixture → {path}");

    let status = Command::new("curl")
        .args(["-fsSL", "-o", path, url])
        .status()
        .unwrap_or_else(|e| {
            panic!(
                "failed to run curl ({e}). Install curl or place {path} manually from:\n  {url}"
            );
        });

    assert!(
        status.success(),
        "curl failed downloading {path} (status {status}). Source:\n  {url}"
    );
    assert!(
        path_buf.is_file(),
        "download reported success but {path} is missing"
    );
}

// ---------------------------------------------------------------------------
// IDNA range tables
// ---------------------------------------------------------------------------

fn generate_idna_tables() {
    println!("cargo:rerun-if-changed={RANGES_PATH}");
    println!("cargo:rerun-if-changed=build.rs");

    let src = Path::new(RANGES_PATH);
    let text = fs::read_to_string(src).unwrap_or_else(|e| {
        panic!(
            "failed to read {}: {e}\nExpected compressed Unicode range tuples for IDNA tables.",
            src.display()
        );
    });

    let tables = parse_range_file(&text);
    validate_required_sections(&tables);

    let mut rust = String::with_capacity(16 * 1024);
    rust.push_str("// @generated by build.rs from data/idna_ranges.txt — do not edit.\n");
    rust.push_str("// Inclusive Unicode scalar ranges as (lo, hi) pairs, sorted & merged.\n");
    rust.push_str("// Lookups use `unicode_ranges::in_range_table` (binary search, no alloc).\n\n");

    for &(section, rust_name) in SECTIONS {
        let ranges = tables.get(section).map_or(&[][..], Vec::as_slice);
        write_static_table(&mut rust, rust_name, ranges);
    }

    let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR"));
    let dest = out_dir.join("idna_tables.rs");
    fs::write(&dest, rust).unwrap_or_else(|e| {
        panic!("failed to write {}: {e}", dest.display());
    });
}

fn validate_required_sections(tables: &BTreeMap<String, Vec<(u32, u32)>>) {
    for &(section, _) in SECTIONS {
        let Some(ranges) = tables.get(section) else {
            panic!("data/idna_ranges.txt: missing required section [{section}]");
        };
        assert!(
            !ranges.is_empty(),
            "data/idna_ranges.txt: section [{section}] is empty"
        );
    }
    for name in tables.keys() {
        assert!(
            SECTIONS.iter().any(|(s, _)| s == name),
            "data/idna_ranges.txt: unknown section [{name}]; known: {}",
            SECTIONS
                .iter()
                .map(|(s, _)| *s)
                .collect::<Vec<_>>()
                .join(", ")
        );
    }
}

fn write_static_table(out: &mut String, name: &str, ranges: &[(u32, u32)]) {
    let cps: u64 = ranges
        .iter()
        .map(|&(lo, hi)| u64::from(hi.saturating_sub(lo).saturating_add(1)))
        .sum();
    let _ = writeln!(
        out,
        "/// Inclusive `(lo, hi)` ranges — {} entries, {cps} code points.",
        ranges.len(),
    );
    let _ = writeln!(out, "pub static {name}: &[(u32, u32)] = &[");
    for &(lo, hi) in ranges {
        let _ = writeln!(out, "    (0x{lo:04X}, 0x{hi:04X}),");
    }
    let _ = writeln!(out, "];\n");
}

/// Parse `data/idna_ranges.txt`.
///
/// Grammar (line-oriented):
/// - `#` starts a comment (full-line or trailing).
/// - `[section_name]` begins a table.
/// - Range lines: `HEX` or `HEX..=HEX` (inclusive, 1–6 hex digits).
fn parse_range_file(text: &str) -> BTreeMap<String, Vec<(u32, u32)>> {
    let mut tables: BTreeMap<String, Vec<(u32, u32)>> = BTreeMap::new();
    let mut current: Option<String> = None;

    for (lineno, raw) in text.lines().enumerate() {
        let line_no = lineno + 1;
        let line = strip_comment(raw).trim();
        if line.is_empty() {
            continue;
        }

        if let Some(name) = parse_section_header(line) {
            assert!(
                is_ident(&name),
                "{RANGES_PATH}:{line_no}: invalid section name [{name}]"
            );
            assert!(
                !tables.contains_key(&name),
                "{RANGES_PATH}:{line_no}: duplicate section [{name}]"
            );
            tables.insert(name.clone(), Vec::new());
            current = Some(name);
            continue;
        }

        let Some(section) = current.as_ref() else {
            panic!("{RANGES_PATH}:{line_no}: range outside any [section]: {line}");
        };

        let (lo, hi) = parse_range_token(line).unwrap_or_else(|e| {
            panic!("{RANGES_PATH}:{line_no}: {e}: {line}");
        });
        assert!(
            lo <= hi,
            "{RANGES_PATH}:{line_no}: inverted range {lo:04X}..={hi:04X}"
        );
        assert!(
            hi <= 0x10_FFFF,
            "{RANGES_PATH}:{line_no}: code point past U+10FFFF: {hi:04X}"
        );
        tables.get_mut(section).unwrap().push((lo, hi));
    }

    for ranges in tables.values_mut() {
        *ranges = merge_ranges(std::mem::take(ranges));
    }

    tables
}

fn strip_comment(line: &str) -> &str {
    match line.find('#') {
        Some(i) => &line[..i],
        None => line,
    }
}

fn parse_section_header(line: &str) -> Option<String> {
    let line = line.trim();
    if line.starts_with('[') && line.ends_with(']') && line.len() >= 3 {
        Some(line[1..line.len() - 1].trim().to_string())
    } else {
        None
    }
}

fn is_ident(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
        _ => return false,
    }
    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

fn parse_range_token(token: &str) -> Result<(u32, u32), &'static str> {
    if let Some((a, b)) = token.split_once("..=") {
        let lo = parse_hex(a.trim())?;
        let hi = parse_hex(b.trim())?;
        Ok((lo, hi))
    } else if token.contains("..") {
        Err("use inclusive `HEX..=HEX` (not exclusive `..`)")
    } else {
        let cp = parse_hex(token)?;
        Ok((cp, cp))
    }
}

fn parse_hex(s: &str) -> Result<u32, &'static str> {
    if s.is_empty() || s.len() > 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err("expected 1–6 hex digits");
    }
    u32::from_str_radix(s, 16).map_err(|_| "invalid hex")
}

/// Sort by `lo`, then coalesce overlapping / adjacent inclusive ranges.
fn merge_ranges(mut ranges: Vec<(u32, u32)>) -> Vec<(u32, u32)> {
    if ranges.is_empty() {
        return ranges;
    }
    ranges.sort_unstable_by_key(|&(lo, _)| lo);
    let mut out = Vec::with_capacity(ranges.len());
    let mut cur = ranges[0];
    for &(lo, hi) in &ranges[1..] {
        // Adjacent (`hi + 1 == lo`) merges: denser tables, same membership.
        if lo <= cur.1.saturating_add(1) {
            cur.1 = cur.1.max(hi);
        } else {
            out.push(cur);
            cur = (lo, hi);
        }
    }
    out.push(cur);
    debug_assert!(is_strictly_sorted_disjoint(&out));
    out
}

fn is_strictly_sorted_disjoint(ranges: &[(u32, u32)]) -> bool {
    // After merge: each range ends strictly before the next begins (gap ≥ 1).
    ranges
        .windows(2)
        .all(|w| w[0].0 <= w[0].1 && w[0].1.saturating_add(1) < w[1].0)
}