chrono-node-rs 0.1.0

Behaviour-matched Rust port of chrono-node: natural-language date parsing, checked against chrono-node's own output
Documentation
//! Auto-registration of parsers and refiners.
//!
//! Every `.rs` file in these directories is a registrable step:
//!
//! - `src/common/parsers/`, `src/common/refiners/`
//! - `src/locales/<lang>/parsers/`, `src/locales/<lang>/refiners/`
//!
//! Each file must define:
//!
//! ```ignore
//! pub const UPSTREAM: &str = "ENWeekdayParser";          // the chrono-node class it ports
//! pub fn create(flags: &Flags) -> Box<dyn Parser>;        // or Box<dyn Refiner> in refiners/
//! ```
//!
//! This script generates the `mod` declarations and the registry lists into `OUT_DIR`, so adding a
//! parser means adding one file. Nobody edits a shared list, so parallel tasks don't conflict.
//! Files whose name starts with `_` are skipped (use them for helpers shared inside a directory).

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

fn step_files(dir: &Path) -> Vec<(String, PathBuf)> {
    let mut out = Vec::new();
    let Ok(entries) = fs::read_dir(dir) else {
        return out;
    };
    for entry in entries {
        let path = entry.expect("readable directory entry").path();
        let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
            continue;
        };
        if path.extension().and_then(|e| e.to_str()) != Some("rs") || stem == "mod" || stem.starts_with('_') {
            continue;
        }
        assert!(
            stem.chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
            "step file names must be snake_case: {}",
            path.display()
        );
        out.push((stem.to_string(), path));
    }
    out.sort();
    out
}

/// One generated file per directory: `mod` declarations plus an `ENTRIES` list.
fn generate_dir(out_dir: &Path, name: &str, dir: &Path, kind: &str) {
    let entry_type = match kind {
        "parsers" => "crate::core::registry::ParserEntry",
        _ => "crate::core::registry::RefinerEntry",
    };
    let mut code = String::from("// @generated by build.rs. Do not edit.\n");
    let files = step_files(dir);
    for (stem, path) in &files {
        let abs = path.canonicalize().expect("canonical path");
        writeln!(code, "#[path = {:?}]\npub mod {stem};", abs.display().to_string()).unwrap();
    }
    writeln!(code, "pub(crate) const ENTRIES: &[{entry_type}] = &[").unwrap();
    for (stem, _) in &files {
        writeln!(
            code,
            "    {entry_type} {{ upstream: {stem}::UPSTREAM, create: {stem}::create }},"
        )
        .unwrap();
    }
    code.push_str("];\n");
    fs::write(out_dir.join(format!("{name}.rs")), code).expect("write generated registry");
}

/// Generated locales (`harness/onboard/onboard.js`): a `src/locales/<lang>/` with a `locale.json`.
/// Writes `locales.rs` (their `mod` declarations and the list of their named configurations), and
/// per locale `<lang>_support.rs`: a `mod` for every other `.rs` file directly in the locale
/// directory (`constants.rs`, shared bases), so a task adding one edits no shared line.
/// Hand-scaffolded locales (no `locale.json`) are declared in `src/locales/mod.rs` as before.
fn generate_locales(out_dir: &Path, root: &Path, locales: &[String]) {
    let mut code = String::from("// @generated by build.rs. Do not edit.\n");
    let mut named = Vec::new();
    for lang in locales {
        let dir = root.join("src/locales").join(lang);
        if !dir.join("locale.json").is_file() {
            continue;
        }
        let mod_rs = dir
            .join("mod.rs")
            .canonicalize()
            .expect("generated locale has a mod.rs");
        writeln!(code, "#[path = {:?}]\npub mod {lang};", mod_rs.display().to_string()).unwrap();
        named.push(format!("{lang}::NAMED"));

        let mut support = String::from("// @generated by build.rs. Do not edit.\n");
        let mut files: Vec<(String, PathBuf)> = fs::read_dir(&dir)
            .expect("readable locale directory")
            .filter_map(|e| e.ok().map(|e| e.path()))
            .filter(|p| p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("rs"))
            .filter_map(|p| Some((p.file_stem()?.to_str()?.to_string(), p.clone())))
            .filter(|(stem, _)| stem != "mod" && stem != "configuration" && !stem.starts_with('_'))
            .collect();
        files.sort();
        for (stem, path) in files {
            assert!(
                stem.chars()
                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
                "locale support file names must be snake_case: {}",
                path.display()
            );
            let abs = path.canonicalize().expect("canonical path");
            writeln!(support, "#[path = {:?}]\npub mod {stem};", abs.display().to_string()).unwrap();
        }
        fs::write(out_dir.join(format!("{lang}_support.rs")), support).expect("write locale support");
    }
    writeln!(
        code,
        "/// Named configurations of the generated locales.\npub const GENERATED: &[&[crate::locales::NamedConfiguration]] = &[{}];",
        named.join(", ")
    )
    .unwrap();
    fs::write(out_dir.join("locales.rs"), code).expect("write generated locales");
}

fn main() {
    let root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()).join("registry");
    fs::create_dir_all(&out_dir).unwrap();
    println!("cargo:rerun-if-changed=src/common");
    println!("cargo:rerun-if-changed=src/locales");
    println!("cargo:rerun-if-changed=build.rs");

    let mut sources: Vec<(String, String)> = Vec::new(); // (module path, generated file stem)
    for kind in ["parsers", "refiners"] {
        generate_dir(
            &out_dir,
            &format!("common_{kind}"),
            &root.join("src/common").join(kind),
            kind,
        );
        sources.push((format!("crate::common::{kind}"), kind.to_string()));
    }

    let mut locales: Vec<String> = fs::read_dir(root.join("src/locales"))
        .map(|rd| {
            rd.filter_map(|e| e.ok())
                .filter(|e| e.path().is_dir())
                .filter_map(|e| e.file_name().to_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default();
    locales.sort();
    for lang in &locales {
        for kind in ["parsers", "refiners"] {
            generate_dir(
                &out_dir,
                &format!("{lang}_{kind}"),
                &root.join("src/locales").join(lang).join(kind),
                kind,
            );
            sources.push((format!("crate::locales::{lang}::{kind}"), kind.to_string()));
        }
    }

    generate_locales(&out_dir, &root, &locales);

    let mut all = String::from("// @generated by build.rs. Do not edit.\n");
    for kind in ["parsers", "refiners"] {
        let entry_type = if kind == "parsers" {
            "ParserEntry"
        } else {
            "RefinerEntry"
        };
        writeln!(
            all,
            "pub(crate) fn all_{kind}() -> Vec<&'static {entry_type}> {{\n    let mut v = Vec::new();"
        )
        .unwrap();
        for (module, k) in &sources {
            if k == kind {
                writeln!(all, "    v.extend({module}::ENTRIES.iter());").unwrap();
            }
        }
        all.push_str("    v\n}\n");
    }
    fs::write(out_dir.join("all.rs"), all).unwrap();
}