commit 88072820158dbc32c15217d165e0375f70b30528 Author: Niklas Kapelle Date: Mon Jul 28 19:35:48 2025 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6726be6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,47 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "embed-dir" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7c6a840 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "embed-dir" +version = "0.1.0" +edition = "2024" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } + diff --git a/README.md b/README.md new file mode 100644 index 0000000..293609b --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +Simple way to use [`include_bytes!`](https://doc.rust-lang.org/std/macro.include_bytes.html) for directories. + +# Example +```rust +use embed_dir::Embed; + +#[derive(Embed)] +#[dir = "../web/static"] // Path is relativ to the current file +pub struct Assets; + +fn main(){ + let file: &[u8] = Assets::get("css/style.css").expect("Can't find file"); + + let string = str::from_utf8(file).expect("Failed to parse file"); + + println!("{string}"); +} +``` + diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0c199e5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,110 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::spanned::Spanned; +use syn::{Attribute, DeriveInput, Expr, Ident, Lit, Meta, parse_macro_input}; + +use std::fs; +use std::path::{Path, PathBuf}; + +#[proc_macro_derive(Embed, attributes(dir))] +pub fn embed(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let struct_name = &input.ident; + + let attr = input + .attrs + .iter() + .find(|e| e.path().is_ident("dir")) + .expect("No #[dir = \"...\"] attribute found"); + + let base_path = PathBuf::from(extract_dir_path(attr)); + let source_file = PathBuf::from(input.span().unwrap().file()); + + let source_dir = if let Some(parent) = source_file.parent() { + parent + } else { + // HACK: when running in rust-analyzer i can't seem to get the parent dir. + return TokenStream::from(generate_impl(struct_name, Vec::new(), Vec::new())); + }; + + let absolue_path = source_dir.join(&base_path); + + let mut match_arms = Vec::new(); + let mut entries = Vec::new(); + + for entry in collect_files(&absolue_path) { + let rel_path = entry + .strip_prefix(&absolue_path) + .unwrap() + .to_str() + .unwrap() + .replace("\\", "/"); + + let include_path = base_path.join(&rel_path); + let include_string = include_path.to_str(); + + match_arms.push(quote! { + #rel_path => Some(include_bytes!(#include_string) as &'static [u8]), + }); + + entries.push(quote! { + (#rel_path, include_bytes!(#include_string) as &'static [u8]) + }); + } + + let expanded = generate_impl(struct_name, match_arms, entries); + + proc_macro::TokenStream::from(expanded) +} + +fn collect_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + files.push(path); + } else if path.is_dir() { + files.extend(collect_files(&path)); + } + } + files +} + +fn extract_dir_path(attr: &Attribute) -> String { + let meta = match &attr.meta { + Meta::NameValue(meta) => meta, + _ => panic!("Expected #[dir = \"...\"] as a name-value attribute."), + }; + + let expr_lit = match &meta.value { + Expr::Lit(expr_lit) => expr_lit, + _ => panic!("Expected #[dir = \"...\"] with a string literal."), + }; + + match &expr_lit.lit { + Lit::Str(str) => str.value(), + _ => panic!("Expected #[dir = \"...\"] to be a string."), + } +} + +fn generate_impl( + struct_name: &Ident, + match_arms: Vec, + entries: Vec, +) -> proc_macro2::TokenStream { + quote! { + impl #struct_name { + pub fn get(name: &str) -> Option<&'static [u8]> { + match name { + #(#match_arms)* + _ => None, + } + } + + pub fn iter() -> impl Iterator { + [#(#entries),*].into_iter() + } + } + } +} diff --git a/testdata/file1.txt b/testdata/file1.txt new file mode 100644 index 0000000..08219db --- /dev/null +++ b/testdata/file1.txt @@ -0,0 +1 @@ +file1 \ No newline at end of file diff --git a/testdata/sub/file2.txt b/testdata/sub/file2.txt new file mode 100644 index 0000000..30d67d4 --- /dev/null +++ b/testdata/sub/file2.txt @@ -0,0 +1 @@ +file2 \ No newline at end of file diff --git a/tests/basic.rs b/tests/basic.rs new file mode 100644 index 0000000..fb83bff --- /dev/null +++ b/tests/basic.rs @@ -0,0 +1,44 @@ +use embed_dir::Embed; + +#[derive(Embed)] +#[dir = "./../testdata/"] +pub struct Assets; + +#[test] +fn get() { + assert!(Assets::get("file1.txt").is_some()); +} + +#[test] +fn get_missing() { + assert!(Assets::get("missing.txt").is_none()); +} + +#[test] +fn read_content() { + let content_should = "file1".as_bytes(); + let content_is = Assets::get("file1.txt").unwrap(); + assert_eq!(*content_is, *content_should); +} + +#[test] +fn parse_string() { + let file: &[u8] = Assets::get("file1.txt").expect("Can't find file"); + + let string = str::from_utf8(file).expect("Failed to parse file"); + + println!("{string}"); +} + +#[test] +fn sub_directories_get() { + assert!(Assets::get("sub/file2.txt").is_some()); +} + +#[test] +fn sub_directories_content() { + let content_should = "file2".as_bytes(); + let content_is = Assets::get("sub/file2.txt").unwrap(); + assert_eq!(*content_is, *content_should); +} +