csv_codegen/lib.rs
1//! # csv-codegen
2//!
3//! A Rust procedural macro for generating code from CSV data at compile time.
4//! Transform CSV files into Rust constants, functions, structs, and other code using a flexible templating syntax.
5//!
6//! ## Features
7//!
8//! - **Compile-time CSV processing** - CSV files are read and processed during compilation
9//! - **Template-based code generation** - Use a simple template syntax to generate any Rust code
10//! - **Field transformations** - Convert CSV data to valid Rust identifiers, constants, types, and literals
11//! - **Filtering support** - Include/exclude rows based on conditions
12//! - **Pivoting** - Transform columns into key-value pairs for more flexible data structures
13//! - **Type-safe literals** - Generate properly typed numeric literals (`42_f64`, `10_u32`, etc.)
14//!
15//! ## Getting Started
16//!
17//! See the [`csv_template!`] macro documentation for detailed usage instructions, syntax reference, and examples.
18//!
19//! ## Use Cases
20//!
21//! - **Configuration from CSV** - Generate constants and enums from configuration data
22//! - **Test data** - Create test fixtures from CSV files
23//! - **Code tables** - Transform lookup tables into efficient match statements
24//! - **Translations** - Create internationalization constants from CSV files
25//!
26//! ## Additional Resources
27//!
28//! - [README](../README.md) - Installation and quick start guide
29//! - [Integration Tests](../tests/test.rs) - Real-world examples
30//! - [Repository](https://git.sr.ht/~platy/csv-codegen) - Source code and issue tracking
31//!
32//! ## Status
33//!
34//! This crate is in development and was built with AI assistance (Claude Code).
35//! The API may see changes in future versions. Contributions and code review are especially welcome!
36
37use predicate::FilterExpression;
38use proc_macro2::TokenStream;
39use std::ops::Bound;
40use syn::token::Paren;
41use syn::{LitStr, RangeLimits, Token};
42use template::TemplateAst;
43
44// Re-export key types from new modules
45pub(crate) use data::{FieldIndex, QueryError, QueryErrorInner};
46
47mod data;
48mod expression;
49mod parse;
50mod pivot_iterator;
51mod predicate;
52mod record_ops;
53mod template;
54mod write;
55
56/// Generates Rust code from CSV data using a templating syntax.
57///
58/// This macro reads a CSV file at compile time and generates code by substituting CSV field values
59/// into a template. It supports filtering, pivoting, and various transformations to convert CSV
60/// data into valid Rust identifiers, literals, and other tokens.
61///
62/// # Syntax
63///
64/// ```ignore
65/// csv_codegen::csv_template!(
66/// "path/to/file.csv",
67/// [pivot(column_range, key_column, value_column),]
68/// {
69/// template_code
70/// }
71/// )
72/// ```
73///
74/// # Arguments
75///
76/// - **CSV path**: Relative path to the file the macro is invoked from, like `include!()`
77/// - **pivot()** (optional): Transforms specified columns into key-value pairs
78/// - `column_range`: Range of columns to pivot (e.g., `5..=9`, `"column_a"..`)
79/// - `key_column`: Name for the generated key field
80/// - `value_column`: Name for the generated value field
81/// - **Template body** : Direct code generation without wrapper (top-level only)
82/// - **#each()** : Iterates over rows, optionally filtering which rows are included
83/// - Can be used at any level with optional conditions
84/// - Condition uses CSV field names and supports `==`, `!=` comparisons
85/// - When used without conditions, processes all rows in the current group
86/// - **#find(condition)** : Finds exactly one matching row, with optional #else fallback
87/// - Always requires a condition to specify which row to find
88/// - Must match exactly one row, compilation error otherwise
89/// - Creates a context with only the matching row's data
90/// - **#having(condition)** : Parent group filtering with single group rendering
91/// - Always requires a condition to specify which rows must exist in the parent group
92/// - Filters the parent context: only parent groups with matching rows are processed
93/// - Internally behaves like #find: expects exactly one group and renders its template once
94/// - Cannot be used with #else clauses (if no matching rows, parent group is filtered out)
95/// - Similar to SQL HAVING clause - filters groups based on aggregate conditions
96///
97/// # Field Substitution
98///
99/// Fields from the CSV can be substituted into the template using several syntaxes:
100///
101/// ## Identifier transformations
102/// - `#ident(expression)` - Converts to a valid Rust identifier
103/// - Removes spaces, special characters, converts to snake_case
104/// - Example: "Green Apple" → `#ident(get_{field_name}_value)` → `get_green_apple_value`
105/// - `#CONST(expression)` - Converts to a valid Rust constant identifier (SCREAMING_SNAKE_CASE)
106/// - `#Type(expression)` - Converts to a valid Rust type identifier (PascalCase)
107///
108/// ## Literal formatting
109/// - `#({field_name}_suffix)` - Appends suffix to create typed literals
110/// - `#{price}_f64` converts "42" to `42_f64` (float literal)
111/// - `#{count}_u32` converts "10" to `10_u32` (unsigned integer literal)
112/// - `#("{field_name}")` - Format as literal string
113///
114/// # Repetition and Filtering
115///
116/// Use `#each(condition) { template_code }` to repeat template code for each matching row:
117///
118/// ```ignore
119/// #each(status == "active") {
120/// const #CONST({name}): u32 = #({value});
121/// }
122/// ```
123///
124/// The condition can reference any CSV field and supports:
125/// - `==` equality comparison
126/// - `!=` inequality comparison
127/// - String comparisons
128///
129/// # Pivoting
130///
131/// The `pivot()` modifier transforms multiple columns into key-value pairs:
132///
133/// ```rust
134/// let product = "Laptop Stand";
135/// let quarter = "q3_sales";
136/// let sales = csv_codegen::csv_template!("../tests/sales.csv", pivot("q1_sales"..="q4_sales", quarter, amount), {
137/// match (product, quarter) {
138/// #each{
139/// // Each row becomes multiple rows with metric/amount pairs
140/// (#("{product}"), #("{quarter}")) => #({amount}),
141/// }
142/// _ => panic!(),
143/// }
144/// });
145/// assert_eq!(sales, 320);
146/// ```
147///
148/// Given CSV columns `[name, age, height_cm, weight_kg, score_math, score_english]`,
149/// `pivot("height_cm"..="score_english", subject, value)` would create pairs like:
150/// - `subject="height_cm", value="175"`
151/// - `subject="weight_kg", value="70"`
152/// - `subject="score_math", value="95"`
153/// - `subject="score_english", value="88"`
154///
155/// # Examples
156///
157/// ## Basic code generation
158///
159/// ```rust
160/// // CSV: name,price,category
161/// // apple,1,fruit
162/// // carrot,0.80,vegetable
163///
164/// csv_codegen::csv_template!("../tests/products.csv", #each {
165/// pub const #CONST({name}_PRICE): f64 = #({price}_f64);
166/// });
167/// assert_eq!(WIRELESS_HEADPHONES_PRICE, 89.99);
168/// ```
169///
170/// Generates:
171/// ```rust
172/// pub const APPLE_PRICE: f64 = 1_f64;
173/// pub const CARROT_PRICE: f64 = 0.80_f64;
174/// ```
175///
176/// ## Function generation with filtering
177///
178/// ```rust
179/// csv_codegen::csv_template!("../tests/products.csv", {
180/// #each(category == "fruit"){
181/// pub fn #ident(get_{name}_price)() -> f64 {
182/// #({price}_f64)
183/// }
184/// }
185/// })
186/// ```
187///
188/// Generates:
189/// ```rust
190/// pub fn get_apple_price() -> f64 {
191/// 1.20_f64
192/// }
193/// ```
194///
195/// ## Match arms with nested filtering
196///
197/// ```rust
198/// csv_codegen::csv_template!("../tests/products.csv", {
199/// fn get_price(name: &str) -> Option<f64> {
200/// match name {
201/// #each(price != ""){
202/// #("{name}") => Some(#({price}_f64)),
203/// }
204/// _ => None,
205/// }
206/// }
207/// });
208/// assert_eq!(get_price("Ergonomic Chair").unwrap(), 399.99);
209/// ```
210///
211/// ## Pivoting example
212///
213/// ```rust
214/// // CSV: product,q1_sales,q2_sales,q3_sales,q4_sales
215/// // widget,100,150,120,200
216///
217/// csv_codegen::csv_template!("../tests/sales.csv", pivot("q1_sales"..="q4_sales", quarter, sales), {
218/// #each{
219/// struct #Type({product}Product);
220/// impl #Type({product}Product) {
221/// #each{
222/// pub const #CONST({quarter}): u32 = #({sales}_u32);
223/// }
224/// }
225/// }
226/// });
227/// assert_eq!(SmartWatchProduct::Q_2_SALES, 180);
228/// ```
229///
230/// ## Group filtering with #having
231///
232/// The `#having` directive conditionally renders content based on whether the current group
233/// contains rows matching a condition. If no rows match, the content is skipped entirely.
234/// Internally, it behaves like `#find` but also filters the parent context.
235///
236/// ```rust,ignore
237/// // CSV: department,employee,union_rep,salary
238/// // engineering,alice,false,85000
239/// // engineering,bob,true,90000
240/// // marketing,carol,false,70000
241/// // marketing,dave,false,72000
242/// // sales,eve,true,80000
243///
244/// csv_codegen::csv_template!("departments.csv", {
245/// #each {
246/// // Generate department info, but only if department has union representation
247/// let dept = (#("{department}"), #having(union_rep == true) {
248/// #("{employee}") // Name of A union rep (behaves like #find internally)
249/// });
250/// }
251/// });
252///
253/// // Generates tuples for: ("engineering", "bob"), ("sales", "eve")
254/// // Marketing department is skipped entirely (no union reps)
255/// ```
256///
257/// **Key insight**: `#having` asks "Does this group have any rows matching the condition?"
258/// - If **yes**: renders its template exactly once (like `#find`)
259/// - If **no**: skips the template entirely (filters out the parent group)
260/// - Cannot use `#else` because if no rows match, the parent context is filtered out
261///
262/// **Comparison**:
263/// - `#each(union_rep == true)`: Would iterate over each union rep individually
264/// - `#find(union_rep == true)`: Would find exactly one union rep or error
265/// - `#having(union_rep == true)`: Renders surrounding template if any union reps exist, and finds the union rep within the #having template
266///
267/// # Notes
268///
269/// - CSV files are read at compile time - changes require recompilation
270/// - Field names are derived from CSV headers
271/// - Empty cells are treated as empty strings
272#[proc_macro]
273pub fn csv_template(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
274 // Parse macro input
275 let input = match syn::parse::<MacroInvocation>(input) {
276 Ok(data) => data,
277 Err(err) => {
278 return err.to_compile_error().into();
279 }
280 };
281
282 // 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
283 let mut errors = TokenStream::new();
284
285 let output = match data::query_csv(&input.header) {
286 Err(err) => {
287 errors.extend(err.into_compile_error());
288 input.template.template.render(None, &mut errors)
289 }
290 Ok(data) => {
291 let for_template = input.template;
292 for_template.render(Some(&data), &mut errors)
293 }
294 };
295
296 // Return the generated code
297 errors.extend(output);
298 errors.into()
299}
300
301struct MacroInvocation {
302 pub header: CsvSource,
303 pub template: RowTemplate,
304}
305
306struct CsvSource {
307 from: LitStr,
308 pivot: Option<PivotSpec>,
309}
310
311mod kw {
312 syn::custom_keyword!(pivot);
313 syn::custom_keyword!(each);
314 syn::custom_keyword!(find);
315 syn::custom_keyword!(having);
316}
317
318struct PivotSpec {
319 _kw: kw::pivot,
320 _parens: Paren,
321 column_from: Option<syn::Lit>,
322 _range_limits: RangeLimits,
323 column_to: Bound<syn::Lit>,
324 key_field_name: syn::Ident,
325 value_field_name: syn::Ident,
326}
327
328#[derive(Debug)]
329#[allow(unused)]
330enum RowTemplateKind {
331 Each(kw::each), // #each() - can match 0, 1, or many
332 Find(kw::find), // #find() - must match exactly 1
333 Having(kw::having), // #having() - group must have matching rows, otherwise parent group excluded
334 Plain, // Default at top level - must match exactly 1
335}
336
337#[derive(Debug)]
338struct RowTemplate {
339 _hash: Token![#],
340 kind: RowTemplateKind,
341 filter: Option<FilterExpression>,
342 _template_braces: syn::token::Brace,
343 template: TemplateAst,
344 else_template: Option<ElseTemplate>,
345}
346
347#[derive(Debug)]
348struct ElseTemplate {
349 _hash: Token![#],
350 _else_kw: Token![else],
351 _template_braces: syn::token::Brace,
352 template: TemplateAst,
353}