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";
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();
}
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"
);
}
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");
}
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")
}
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..] {
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 {
ranges
.windows(2)
.all(|w| w[0].0 <= w[0].1 && w[0].1.saturating_add(1) < w[1].0)
}