csv_codegen/lib.rs
1use crate::expression::SelectExpression;
2use proc_macro2::Delimiter;
3use proc_macro2::TokenStream;
4use std::ops::Bound;
5use syn::punctuated::Punctuated;
6use syn::token::Paren;
7use syn::{Ident, Lit, LitStr, RangeLimits, Token};
8
9mod data;
10pub(crate) mod expression;
11mod parse;
12mod write;
13
14/// Generates Rust code from CSV data using a templating syntax.
15///
16/// This macro reads a CSV file at compile time and generates code by substituting CSV field values
17/// into a template. It supports filtering, pivoting, and various transformations to convert CSV
18/// data into valid Rust identifiers, literals, and other tokens.
19///
20/// # Syntax
21///
22/// ```ignore
23/// csv_codegen::csv_template!(
24/// "path/to/file.csv",
25/// [pivot(column_range, key_column, value_column),]
26/// #for([condition])(
27/// template_code
28/// )
29/// )
30/// ```
31///
32/// # Arguments
33///
34/// - **CSV path**: Relative path to the CSV file from the crate root
35/// - **pivot()** (optional): Transforms specified columns into key-value pairs
36/// - `column_range`: Range of columns to pivot (e.g., `5..=9`, `"column_a"..`)
37/// - `key_column`: Name for the generated key field
38/// - `value_column`: Name for the generated value field
39/// - **#for()** : Optionally filters which rows are included in the output
40/// - Can be used both at the top level and within repetitions
41/// - Condition uses CSV field names and supports `==`, `!=` comparisons
42///
43/// # Field Substitution
44///
45/// Fields from the CSV can be substituted into the template using several syntaxes:
46///
47/// ## Identifier transformations
48/// - `#ident(expression)` - Converts to a valid Rust identifier
49/// - Removes spaces, special characters, converts to snake_case
50/// - Example: "Green Apple" → `#ident(get_{field_name}_value)` → `get_green_apple_value`
51/// - `#CONST(expression)` - Converts to a valid Rust constant identifier (SCREAMING_SNAKE_CASE)
52/// - `#Type(expression)` - Converts to a valid Rust type identifier (PascalCase)
53///
54/// ## Literal formatting
55/// - `#({field_name}_suffix)` - Appends suffix to create typed literals
56/// - `#{price}_f64` converts "42" to `42_f64` (float literal)
57/// - `#{count}_u32` converts "10" to `10_u32` (unsigned integer literal)
58/// - `#("{field_name}")` - Format as literal string
59///
60/// # Repetition and Filtering
61///
62/// Use `#for(condition)(template_code)` to repeat template code for each matching row:
63///
64/// ```ignore
65/// #for(status == "active")(
66/// const #CONST({name}): u32 = #({value});
67/// )
68/// ```
69///
70/// The condition can reference any CSV field and supports:
71/// - `==` equality comparison
72/// - `!=` inequality comparison
73/// - String comparisons
74///
75/// # Pivoting
76///
77/// The `pivot()` modifier transforms multiple columns into key-value pairs:
78///
79/// ```rust
80/// let product = "Laptop Stand";
81/// let quarter = "q3_sales";
82/// let sales = csv_codegen::csv_template!("tests/sales.csv", pivot("q1_sales"..="q4_sales", quarter, amount), #for()(
83/// match (product, quarter) {
84/// #for()(
85/// // Each row becomes multiple rows with metric/amount pairs
86/// (#("{product}"), #("{quarter}")) => #({amount}),
87/// )
88/// _ => panic!(),
89/// }
90/// ));
91/// assert_eq!(sales, 320);
92/// ```
93///
94/// Given CSV columns `[name, age, height_cm, weight_kg, score_math, score_english]`,
95/// `pivot("height_cm"..="score_english", subject, value)` would create pairs like:
96/// - `subject="height_cm", value="175"`
97/// - `subject="weight_kg", value="70"`
98/// - `subject="score_math", value="95"`
99/// - `subject="score_english", value="88"`
100///
101/// # Examples
102///
103/// ## Basic code generation
104///
105/// ```rust
106/// // CSV: name,price,category
107/// // apple,1,fruit
108/// // carrot,0.80,vegetable
109///
110/// csv_codegen::csv_template!("tests/products.csv", #for()(
111/// pub const #CONST({name}_PRICE): f64 = #({price}_f64);
112/// ));
113/// assert_eq!(WIRELESS_HEADPHONES_PRICE, 89.99);
114/// ```
115///
116/// Generates:
117/// ```rust
118/// pub const APPLE_PRICE: f64 = 1_f64;
119/// pub const CARROT_PRICE: f64 = 0.80_f64;
120/// ```
121///
122/// ## Function generation with filtering
123///
124/// ```rust
125/// csv_codegen::csv_template!("tests/products.csv", #for(category == "fruit")(
126/// pub fn #ident(get_{name}_price)() -> f64 {
127/// #({price}_f64)
128/// }
129/// ))
130/// ```
131///
132/// Generates:
133/// ```rust
134/// pub fn get_apple_price() -> f64 {
135/// 1.20_f64
136/// }
137/// ```
138///
139/// ## Match arms with nested filtering
140///
141/// ```rust
142/// csv_codegen::csv_template!("tests/products.csv", #for()(
143/// fn get_price(name: &str) -> Option<f64> {
144/// match name {
145/// #for(price != "")(
146/// #("{name}") => Some(#({price}_f64)),
147/// )
148/// _ => None,
149/// }
150/// }
151/// ));
152/// assert_eq!(get_price("Ergonomic Chair").unwrap(), 399.99);
153/// ```
154///
155/// ## Pivoting example
156///
157/// ```rust
158/// // CSV: product,q1_sales,q2_sales,q3_sales,q4_sales
159/// // widget,100,150,120,200
160///
161/// csv_codegen::csv_template!("tests/sales.csv", pivot("q1_sales"..="q4_sales", quarter, sales), #for()(
162/// struct #Type({product}Product);
163/// impl #Type({product}Product) {
164/// #for()(
165/// pub const #CONST({quarter}): u32 = #({sales}_u32);
166/// )
167/// }
168/// ));
169/// assert_eq!(SmartWatchProduct::Q_2_SALES, 180);
170/// ```
171///
172/// # Notes
173///
174/// - CSV files are read at compile time - changes require recompilation
175/// - Field names are derived from CSV headers
176/// - Empty cells are treated as empty strings
177#[proc_macro]
178pub fn csv_template(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
179 // Parse macro input
180 let input = match syn::parse::<ParsedCsvGenInvocation>(input) {
181 Ok(data) => data,
182 Err(err) => {
183 return err.to_compile_error().into();
184 }
185 };
186
187 // Parse csv data, and prepare for application to template, some querying is also done when generating code based on subqueries, this is the global filtering
188 let mut errors = TokenStream::new();
189
190 let output = match data::query_csv(&input.header) {
191 Err(err) => {
192 errors.extend(err.into_compile_error());
193 input.template.template.render(None, &mut errors)
194 }
195 Ok(data) => {
196 let for_template = input.template;
197 for_template.render(Some(&data), &mut errors)
198 }
199 };
200
201 // Return the generated code
202 errors.extend(output);
203 errors.into()
204}
205
206struct ParsedCsvGenInvocation {
207 pub header: Header,
208 pub template: ForTemplate,
209}
210
211struct Header {
212 from: LitStr,
213 pivot: Option<PivotSpec>,
214}
215
216mod kw {
217 syn::custom_keyword!(pivot);
218}
219
220struct PivotSpec {
221 _kw: kw::pivot,
222 _parens: Paren,
223 column_from: Option<syn::Lit>,
224 _range_limits: RangeLimits,
225 column_to: Bound<syn::Lit>,
226 key_field_name: syn::Ident,
227 value_field_name: syn::Ident,
228}
229
230#[derive(Debug)]
231struct ForTemplate {
232 _hash: Token![#],
233 _for_kw: Token![for],
234 _predicate_parens: Paren,
235 filter: Option<WherePredicate>,
236 _template_parens: Paren,
237 template: TemplatedTokenStream,
238 else_template: Option<ElseTemplate>,
239}
240
241#[derive(Debug)]
242struct ElseTemplate {
243 _hash: Token![#],
244 _else_kw: Token![else],
245 _template_parens: Paren,
246 template: TemplatedTokenStream,
247}
248
249#[derive(Debug, PartialEq)]
250enum WherePredicate {
251 All(Punctuated<WherePredicate, Token![&&]>),
252 Any(Punctuated<WherePredicate, Token![||]>),
253 Comparison(PredicateComparison),
254}
255
256#[derive(Debug, PartialEq)]
257struct PredicateComparison {
258 field: Ident,
259 operator: WhereOp,
260 value: Lit,
261}
262
263#[allow(dead_code)]
264#[derive(Debug, Clone, PartialEq)]
265enum WhereOp {
266 Equal(Token![==]),
267 NotEqual(Token![!=]),
268}
269
270#[derive(Debug)]
271struct TemplatedTokenStream(Vec<TemplatedTokenTree>);
272
273#[derive(Debug)]
274enum TemplatedTokenTree {
275 Group(TemplatedGroup),
276 Ident(proc_macro2::Ident),
277 Punct(proc_macro2::Punct),
278 Literal(proc_macro2::Literal),
279 Replacement(Replacement),
280 Error(syn::Error),
281}
282
283#[derive(Debug)]
284struct TemplatedGroup {
285 delimiter: Delimiter,
286 stream: TemplatedTokenStream,
287}
288
289#[derive(Debug)]
290enum Replacement {
291 Expression(SelectExpression),
292 Group(ForTemplate),
293}