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