m2-syn-macros 0.1.0

Procedural macros for m2-syn
Documentation
#[path = "../syntax.rs"]
mod syntax;
#[path = "../tokens.rs"]
mod tokens;

use std::error::Error;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use proc_macro2::TokenStream;
use syn::Item;

const HEADER: &str =
    "// @generated by `cargo run -p m2-syn-macros --bin generate`; do not edit.\n\n";

fn main() -> Result<(), Box<dyn Error>> {
    let check = match std::env::args().skip(1).collect::<Vec<_>>().as_slice() {
        [] => false,
        [argument] if argument == "--check" => true,
        _ => return Err("usage: generate [--check]".into()),
    };
    let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("macro crate is inside the repository")
        .to_owned();
    let source_path = repository.join("src/nodes.rs");
    let source = fs::read_to_string(&source_path)?;
    let input = syntax_input(&source, &source_path)?;
    let generated = syntax::generate(input)
        .map_err(|error| format!("failed to parse syntax schema: {error}"))?;
    let directory = repository.join("src/gen");

    if !check {
        fs::create_dir_all(&directory)?;
    }

    let mut stale = Vec::new();
    for (name, tokens) in generated.files() {
        let contents = rustfmt(&format!("{HEADER}{tokens}"))?;
        let path = directory.join(name);
        if check {
            if fs::read_to_string(&path).ok().as_deref() != Some(&contents) {
                stale.push(path);
            }
        } else {
            fs::write(path, contents)?;
        }
    }

    if stale.is_empty() {
        Ok(())
    } else {
        let paths = stale
            .iter()
            .map(|path| path.display().to_string())
            .collect::<Vec<_>>()
            .join(", ");
        Err(format!("generated files are stale: {paths}").into())
    }
}

fn syntax_input(source: &str, path: &Path) -> Result<TokenStream, Box<dyn Error>> {
    let file = syn::parse_file(source)
        .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
    file.items
        .into_iter()
        .find_map(|item| match item {
            Item::Macro(item) if item.mac.path.is_ident("syntax_schema") => Some(item.mac.tokens),
            _ => None,
        })
        .ok_or_else(|| {
            format!(
                "{} does not contain a `syntax_schema!` block",
                path.display()
            )
            .into()
        })
}

fn rustfmt(source: &str) -> Result<String, Box<dyn Error>> {
    let source = source
        .replace("macro_rules !", "macro_rules!")
        .replace("matches ! (", "matches!(")
        .replace("compile_error ! (", "compile_error!(")
        .replace("$ crate", "$crate")
        .replace(":: m2_syn :: Spanned", "::m2_syn::Spanned")
        .replace("Token ! [", "Token![")
        .replace("Delimiter ! [", "Delimiter![")
        .replace("Delimiter![< | | >]", "Delimiter![<| |>]")
        .replace("Token![_ =]", "Token![_=]")
        .replace("Token![_ <]", "Token![_<]")
        .replace("Token![_ <=]", "Token![_<=]")
        .replace("Token![_ >]", "Token![_>]")
        .replace("Token![_ >=]", "Token![_>=]")
        .replace("Token![| _]", "Token![|_]")
        .replace("Token![| _ =]", "Token![|_=]")
        .replace("Token![_ !]", "Token![_!]")
        .replace("Token![_ *]", "Token![_*]")
        .replace("Token![_ ~]", "Token![_~]");
    let source = format_macro_rules(&source);
    let mut child = Command::new("rustfmt")
        .args(["--edition", "2024", "--emit", "stdout"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;
    child
        .stdin
        .take()
        .expect("piped rustfmt stdin")
        .write_all(source.as_bytes())?;
    let output = child.wait_with_output()?;
    if !output.status.success() {
        return Err(format!(
            "rustfmt failed: {}",
            String::from_utf8_lossy(&output.stderr)
        )
        .into());
    }
    Ok(String::from_utf8(output.stdout)?)
}

fn format_macro_rules(source: &str) -> String {
    let mut formatted = String::with_capacity(source.len());
    let mut cursor = 0;

    while let Some(relative_start) = source[cursor..].find("macro_rules!") {
        let start = cursor + relative_start;
        let Some(relative_opening) = source[start..].find('{') else {
            break;
        };
        let opening = start + relative_opening;
        let Some(closing) = matching_brace(source, opening) else {
            break;
        };

        formatted.push_str(&source[cursor..=opening]);
        formatted.push('\n');
        for arm in macro_arms(&source[opening + 1..closing]) {
            formatted.push_str("    ");
            formatted.push_str(&normalize_macro_spacing(arm));
            formatted.push_str(";\n");
        }
        formatted.push('}');
        cursor = closing + 1;
    }

    formatted.push_str(&source[cursor..]);
    formatted
}

fn matching_brace(source: &str, opening: usize) -> Option<usize> {
    let mut depth = 0;
    let mut quoted = false;
    let mut escaped = false;

    for (offset, character) in source[opening..].char_indices() {
        if quoted {
            escaped = character == '\\' && !escaped;
            if character == '"' && !escaped {
                quoted = false;
            }
            if character != '\\' {
                escaped = false;
            }
            continue;
        }
        if character == '"' {
            quoted = true;
        } else if character == '{' {
            depth += 1;
        } else if character == '}' {
            depth -= 1;
            if depth == 0 {
                return Some(opening + offset);
            }
        }
    }
    None
}

fn macro_arms(body: &str) -> Vec<&str> {
    let mut arms = Vec::new();
    let mut start = 0;
    let (mut braces, mut brackets, mut parentheses) = (0, 0, 0);
    let mut quoted = false;
    let mut escaped = false;

    for (offset, character) in body.char_indices() {
        if quoted {
            escaped = character == '\\' && !escaped;
            if character == '"' && !escaped {
                quoted = false;
            }
            if character != '\\' {
                escaped = false;
            }
            continue;
        }
        match character {
            '"' => quoted = true,
            '{' => braces += 1,
            '}' => braces -= 1,
            '[' => brackets += 1,
            ']' => brackets -= 1,
            '(' => parentheses += 1,
            ')' => parentheses -= 1,
            ';' if braces == 0 && brackets == 0 && parentheses == 0 => {
                let arm = body[start..offset].trim();
                if !arm.is_empty() {
                    arms.push(arm);
                }
                start = offset + 1;
            }
            _ => {}
        }
    }
    arms
}

fn normalize_macro_spacing(arm: &str) -> String {
    arm.replace("$crate :: ", "$crate::")
        .replace("$ (", "$(")
        .replace("$ spelling", "$spelling")
        .replace("$ parse_info", "$parse_info")
        .replace("$ unary_action", "$unary_action")
        .replace("$ statement_kind", "$statement_kind")
        .replace("$ unsupported", "$unsupported")
        .replace(" : expr", ":expr")
        .replace(" : ident", ":ident")
        .replace(" : tt", ":tt")
        .replace(") *", ")*")
        .replace("compile_error ! (", "compile_error!(")
        .replace("[{ }]", "[{}]")
        .replace("[< | | >]", "[<| |>]")
        .replace("[| _ =]", "[|_=]")
        .replace("[_ ", "[_")
        .replace(" _]", "_]")
}

#[cfg(test)]
mod tests {
    use super::format_macro_rules;

    #[test]
    fn generated_macro_arms_are_split_and_compact() {
        let source = "macro_rules ! Example { [_ =] => { $ crate :: Item } ; \
                      [$ ($ unsupported : tt) *] => { compile_error ! (\"nope\") } ; }";

        assert_eq!(
            format_macro_rules(
                &source
                    .replace("macro_rules !", "macro_rules!")
                    .replace("$ crate", "$crate")
            ),
            "macro_rules! Example {\n    [_=] => { $crate::Item };\n    \
             [$($unsupported:tt)*] => { compile_error!(\"nope\") };\n}"
        );
    }
}