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}